Análisis exploratorio

Stack moderno de Python para EDA y visualización

python
eda
visualization
pandas
polars
matplotlib
plotly
Referencia comentada de las librerías que estructuran el flujo de exploración y visualización de datos en Python: manipulación tabular, cálculo numérico y gráficos estáticos, declarativos e interactivos.

Sobre análisis exploratorio en Python

El stack de EDA en Python se asienta sobre dos capas: una capa de cálculo y manipulación tabular (numpy, pandas, polars) y una capa de visualización fragmentada (matplotlib como base imperativa, seaborn y plotnine como gramáticas declarativas sobre matplotlib, plotly y altair como alternativas interactivas basadas en JavaScript). A diferencia del ecosistema tidyverse en R, donde todo converge hacia un único data frame y una única gramática gráfica, en Python conviven varias estructuras de datos y varias filosofías de gráfico, y conocer cuándo usar cada una es lo que distingue un análisis maduro de uno improvisado.

Instalación mínima recomendada para un entorno de exploración serio:

# Con uv (rápido y reproducible) o pip
uv pip install numpy pandas polars matplotlib seaborn plotly altair pyarrow

Tres principios del ecosistema que conviene tener interiorizados:

  • NumPy es la base. Tanto pandas como buena parte de matplotlib operan sobre arrays NumPy por debajo. Entender vectorización, broadcasting y dtype ahorra cuellos de botella opacos más adelante.
  • Apache Arrow es el formato franco. pyarrow y polars operan sobre columnas Arrow. Pandas 2.x admite backend Arrow opcional. Esto permite intercambiar datos entre librerías sin copia y leer Parquet eficientemente.
  • Visualización fragmentada por diseño. No hay un equivalente único a ggplot2. Para producir un gráfico de publicación, casi siempre acabarás encadenando matplotlib (base) + seaborn o plotnine (gramática) + ajustes manuales. Para dashboards interactivos, plotly o altair son la elección natural.

Esta página cataloga siete librerías que estructuran la mayor parte de los flujos de EDA en Python. El orden refleja jerarquía conceptual: primero la base numérica, después la manipulación tabular (pandas y su alternativa moderna polars), y finalmente las cuatro grandes opciones de visualización ordenadas de menor a mayor abstracción.


NumPy

NumPy es la librería fundacional del cálculo científico en Python: define el ndarray, un array multidimensional homogéneo con operaciones vectorizadas en C, y la maquinaria de broadcasting que permite combinar arrays de formas distintas sin loops explícitos.

Todo el stack científico de Python descansa sobre NumPy: pandas, scipy, scikit-learn, matplotlib, PyTorch (vía interop) y un largo etcétera consumen o producen ndarray. Internalizar su API y su modelo mental, dtype, shape, strides, views vs copies, es prerrequisito para cualquier análisis cuantitativo serio en Python.

Cuándo usarlo

Siempre que necesites computación numérica vectorizada: álgebra lineal, estadísticas elementales, simulaciones, generación de datos sintéticos, manipulación de imágenes o tensores. También como capa de interoperabilidad entre librerías que esperan arrays, incluso si tu objeto principal es un DataFrame, los métodos .values o .to_numpy() aparecen continuamente.

Cuándo NO usarlo

  • Datos tabulares heterogéneos. Si tus columnas tienen distintos tipos (string, datetime, numérico), usa pandas o polars. NumPy fuerza un único dtype por array y degrada todo a object en caso contrario, perdiendo rendimiento.
  • Cálculo distribuido o out-of-core. Para datasets que no caben en RAM, mira dask.array o polars con streaming. NumPy es estrictamente en memoria.
  • Cálculo en GPU. Sustituye por cupy (API casi idéntica) o jax.numpy si necesitas aceleración. NumPy clásico es solo CPU.

Conceptos clave

  • dtype. Tipo elemental del array (float64, int32, bool, object…). Determina precisión y consumo de memoria. dtype=object es la trampa habitual: indica que NumPy ha caído al path lento de Python puro.
  • shape y strides. El shape es la forma lógica (filas, columnas, …). Los strides son los saltos en bytes que NumPy usa internamente para indexar. Operaciones como .T o .reshape() modifican strides sin copiar memoria, útil, pero también fuente de bugs si no entiendes qué es una view.
  • Broadcasting. Reglas para combinar arrays de formas distintas. La regla práctica: dos dimensiones son compatibles si son iguales o si una es 1.
  • Vectorización. Reemplaza loops Python por operaciones sobre arrays completos. Diferencias de rendimiento típicas: 50-200×.
  • np.random.default_rng(). El generador moderno con semilla explícita. La API legacy np.random.seed() + np.random.rand() sigue funcionando pero es desaconsejada desde NumPy 1.17.

Patrón mínimo

import numpy as np

rng = np.random.default_rng(seed=42)

# Array 2D con broadcasting
x = rng.normal(size=(1000, 4))
means = x.mean(axis=0)          # estadística por columna
x_centered = x - means          # broadcasting: (1000,4) - (4,)

# Álgebra lineal
cov = (x_centered.T @ x_centered) / (x.shape[0] - 1)
eigvals, eigvecs = np.linalg.eigh(cov)

# Indexación booleana (la herramienta más usada en EDA)
outliers = np.abs(x).max(axis=1) > 3
x_clean = x[~outliers]

Trampas habituales

  • Views vs copies. arr[:, 0] devuelve una vista (modificarla altera el original). arr[[0, 2, 4]] devuelve una copia. Si dudas, usa .copy() explícitamente. El warning SettingWithCopyWarning no existe en NumPy puro, los efectos colaterales pasan silenciosos.
  • np.nan propaga. Cualquier operación con NaN produce NaN. Usa np.nanmean, np.nansum, etc., o pandas que gestiona NaN automáticamente.
  • Comparaciones de enteros con NaN. NaN solo existe en floats. Asignar NaN a un array int falla o promueve silenciosamente. En pandas 2.x con backend Arrow, la nullable integer (Int64) resuelve este problema.

Enlaces


pandas

pandas es la librería estándar de manipulación tabular en Python. Su estructura central, el DataFrame, combina la ergonomía de un data frame de R con la potencia del ndarray de NumPy. Cubre lectura/escritura (CSV, Parquet, Excel, SQL, JSON), limpieza, joins, group-by, pivot, resampling temporal y todo el aparato clásico de EDA.

Maduro, omnipresente y con una API muy amplia. La versión 2.x introdujo backend opcional basado en PyArrow, que mejora rendimiento, soporta enteros nullable y unifica el manejo de strings, vale la pena habilitarlo en proyectos nuevos.

Cuándo usarlo

Análisis tabular interactivo en notebooks Jupyter, pipelines de limpieza, integración con scikit-learn, matplotlib y casi cualquier otra librería del ecosistema. Es el formato franco para datasets que caben cómodamente en RAM (hasta unos pocos GB en máquinas modernas).

Cuándo NO usarlo

  • Datasets grandes (>10 GB). Considera polars (que soporta streaming y lazy execution) o duckdb (SQL embebido sobre Parquet). Pandas degrada drásticamente por encima del tamaño de RAM.
  • Pipelines de producción con tipos estrictos. La API de pandas tolera mucha ambigüedad de tipos. En producción polars ofrece más garantías (lazy validation, esquema explícito).
  • Cálculo distribuido. Mira dask.dataframe, modin (drop-in replacement) o pyspark.

Conceptos clave

  • Index. Cada DataFrame y Series tiene un índice. Útil para alineación automática en operaciones, pero también fuente recurrente de sorpresas (resets implícitos, multi-índices que se cuelan). En la práctica, reset_index(drop=True) se usa más de lo que parece.
  • .loc[] vs .iloc[]. loc indexa por etiqueta, iloc por posición. No las mezcles, df[0] es ambiguo y comportamiento varía según el tipo del índice.
  • groupby + agregaciones. El idioma central de EDA en pandas: df.groupby("col").agg({"x": "mean", "y": ["sum", "std"]}). Devuelve resultados con multi-índices que muchas veces conviene aplanar.
  • merge / join. Equivalentes a JOIN de SQL. Especifica siempre how= y validate= para detectar duplicados inesperados en las claves.
  • Backend Arrow. Habilítalo con pd.options.mode.string_storage = "pyarrow" o dtype_backend="pyarrow" en read_*. Mejora rendimiento y soluciona limitaciones históricas de tipos.

Patrón mínimo

import pandas as pd

df = pd.read_parquet("ventas.parquet")

# Limpieza básica
df = (
    df.dropna(subset=["cliente_id"])
      .assign(fecha=lambda d: pd.to_datetime(d["fecha"]))
      .query("importe > 0")
)

# Group-by con agregaciones múltiples
resumen = (
    df.groupby(["region", pd.Grouper(key="fecha", freq="ME")])
      .agg(total=("importe", "sum"),
           media=("importe", "mean"),
           n=("importe", "size"))
      .reset_index()
)

# Pivot largo → ancho
tabla = resumen.pivot_table(index="fecha", columns="region", values="total")

Trampas habituales

  • SettingWithCopyWarning. Modificar un slice sin saber si es vista o copia. La regla: usa .loc[fila, columna] = valor para asignaciones, nunca df[df["x"] > 0]["y"] = 5. Pandas 3.0 (vía Copy-on-Write) cambia este comportamiento. Conviene activar pd.options.mode.copy_on_write = True ya.
  • object dtype para strings. Sin backend Arrow, las columnas de texto se almacenan como object (puntero a objetos Python), con rendimiento pésimo. Activa pd.options.future.infer_string = True o usa el backend Arrow.
  • Multi-índice tras groupby. Devuelve resultados con índices jerárquicos. Si no los necesitas, encadena .reset_index() siempre.
  • .apply() es lento. Casi siempre existe una operación vectorizada equivalente (.map, .replace, np.where, pd.cut…). .apply cae al loop de Python y es 10-100× más lento.

Enlaces

Relacionados en esta página

  • NumPy, base sobre la que se construye pandas.
  • polars, alternativa moderna con mejor rendimiento y API más estricta.

polars

polars es una librería tabular moderna escrita en Rust sobre Apache Arrow. Ofrece una API expresiva basada en expresiones encadenables, un query optimizer tipo SQL y dos modos de ejecución: eager (estilo pandas) y lazy (construye un plan que solo se ejecuta al pedir .collect()).

En la mayoría de benchmarks supera a pandas por un factor de 5-30× y maneja datasets que no caben en RAM mediante streaming. La sintaxis es más estricta, no hay índice, las operaciones se expresan con pl.col(...), pero esa misma rigidez evita la mayoría de bugs sutiles de pandas.

Cuándo usarlo

  • Datasets de tamaño medio-grande (cientos de MB a varios GB) donde pandas empieza a sentirse lento.
  • Pipelines de producción donde quieres garantías de tipo y un plan de ejecución optimizado.
  • ETL sobre Parquet o CSV con filtros, joins y agregaciones complejas.
  • Cuando trabajas mucho con expresiones encadenables y la API de pandas te resulta verbosa.

Cuándo NO usarlo

  • Cuando ya tienes todo el código en pandas y el dataset cabe holgado en RAM. Migrar por migrar no suele compensar.
  • Integración con el resto del ecosistema científico. scikit-learn, statsmodels, seaborn y compañía consumen pandas DataFrames o NumPy arrays. Polars expone .to_pandas() y .to_numpy(), pero la conversión añade fricción.
  • Manejo extensivo de índices. Polars no tiene Index. Si tu flujo se apoya en multi-índices y reindexado, pandas es más natural.

Conceptos clave

  • Expresiones (pl.col, pl.when, etc.). El núcleo de la API. No son strings ni lambdas, son objetos que polars compone en un plan de ejecución.
  • Lazy vs eager. pl.scan_parquet(...) devuelve un LazyFrame. pl.read_parquet(...) un DataFrame. El primero permite optimización (predicate pushdown, projection pushdown), usa lazy siempre que tu pipeline tenga más de dos pasos.
  • group_by + agg + expresiones. df.group_by("region").agg(pl.col("importe").sum()). Las expresiones se aplican dentro de cada grupo.
  • with_columns para añadir/sobreescribir columnas. Equivalente a assign en pandas pero composable con expresiones complejas.
  • Esquema explícito. Cada DataFrame tiene un .schema accesible. Los errores de tipo se detectan temprano.

Patrón mínimo

import polars as pl

# Lazy desde el principio: el plan se optimiza antes de leer
ventas = (
    pl.scan_parquet("ventas.parquet")
      .filter(pl.col("importe") > 0)
      .with_columns(pl.col("fecha").cast(pl.Date))
      .group_by(["region", pl.col("fecha").dt.month_start().alias("mes")])
      .agg(
          total=pl.col("importe").sum(),
          media=pl.col("importe").mean(),
          n=pl.len(),
      )
      .sort(["region", "mes"])
      .collect()      # ejecuta el plan
)

# Conversión a pandas si lo necesita el siguiente paso
ventas_pd = ventas.to_pandas()

Trampas habituales

  • Confundir LazyFrame con DataFrame. Métodos como .head() o .describe() funcionan en ambos, pero el lazy no muestra datos hasta .collect(). Si ves <LazyFrame> al imprimir, te falta el collect.
  • No hay índice. Si vienes de pandas, vas a echar de menos .loc["clave"]. La operación equivalente es .filter(pl.col("id") == "clave"). Acaba siendo más limpio una vez te acostumbras.
  • group_by no preserva orden por defecto. Pasa maintain_order=True si lo necesitas, añade coste, por eso no es el default.
  • .to_pandas() con tipos Arrow. Por defecto usa use_pyarrow_extension_array=False, lo que copia a NumPy. Pasa True para preservar tipos Arrow en pandas.

Enlaces

Relacionados en esta página

  • pandas, alternativa más madura pero más lenta y permisiva.
  • NumPy, interoperable vía .to_numpy().

matplotlib

matplotlib es la librería fundacional de gráficos en Python. Imperativa, de bajo nivel y omnipresente: prácticamente todas las librerías de visualización estática (seaborn, plotnine, pandas .plot) la usan como motor de renderizado. Conocer su modelo de objetos (Figure, Axes, Artist) es prerrequisito para producir gráficos publicables, incluso si normalmente operas con una capa de mayor nivel.

La API tiene dos interfaces que conviven: la interfaz pyplot (estado global, estilo MATLAB) y la interfaz orientada a objetos (explícita, recomendada). En código serio usa siempre la segunda.

Cuándo usarlo

  • Cuando necesitas control fino sobre cada elemento del gráfico (ejes secundarios, anotaciones precisas, insets, layouts complejos con gridspec).
  • Como motor de seaborn / plotnine. Aunque uses una capa declarativa por encima, casi siempre acabarás llamando a métodos de matplotlib para el ajuste final.
  • Exportación de calidad publicación (PDF, SVG, PNG a 300 dpi).

Cuándo NO usarlo

  • Para EDA rápida con datos tabulares. La sintaxis es verbosa. Empieza con seaborn o df.plot() y baja a matplotlib solo si necesitas afinar.
  • Para gráficos interactivos. plotly o altair ofrecen mucha mejor experiencia con un esfuerzo similar.
  • Para gramática declarativa. Mira plotnine (port de ggplot2) o altair (basado en Vega-Lite).

Conceptos clave

  • Figure vs Axes. La Figure es el contenedor. Los Axes son los paneles donde se dibuja. fig, ax = plt.subplots() es el punto de partida canónico.
  • Interfaz orientada a objetos. ax.plot(...), ax.set_xlabel(...), ax.legend(). Olvida plt.plot, plt.xlabel, plt.legend para todo lo que no sea un one-liner en consola.
  • plt.subplots(nrows, ncols, ...). Crea grids de paneles. Para layouts irregulares, gridspec o subplot_mosaic.
  • tight_layout() y constrained_layout. Ajustan automáticamente márgenes. Usa layout="constrained" en subplots(), es más robusto que llamar tight_layout() al final.
  • Estilos y rcParams. plt.style.use("seaborn-v0_8-whitegrid") o configuración fina vía plt.rcParams. Definir un estilo propio (archivo .mplstyle) ahorra horas en proyectos serios.

Patrón mínimo

import matplotlib.pyplot as plt
import numpy as np

rng = np.random.default_rng(42)
x = np.linspace(0, 10, 200)
y = np.sin(x) + rng.normal(0, 0.1, size=x.size)

fig, ax = plt.subplots(figsize=(7, 4), layout="constrained")
ax.scatter(x, y, s=10, alpha=0.6, label="datos")
ax.plot(x, np.sin(x), color="black", lw=1.5, label="modelo")

ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_title("Ejemplo mínimo en matplotlib OO")
ax.legend(frameon=False)
ax.spines[["top", "right"]].set_visible(False)

fig.savefig("figura.pdf", dpi=300, bbox_inches="tight")

Trampas habituales

  • Mezclar plt. y ax.. El estado global de pyplot genera bugs sutiles cuando hay varios Axes. Decide la convención al principio del archivo y mantenla.
  • plt.show() en scripts vs notebooks. En notebooks, los gráficos se muestran automáticamente si la última expresión es la figura. En scripts, plt.show() es necesario y bloquea. En contexto headless (CI, servidor), usa matplotlib.use("Agg") antes de importar pyplot.
  • No cerrar figuras. En loops largos, no llamar plt.close(fig) causa fugas de memoria. En notebooks, también, %matplotlib inline retiene referencias.

Enlaces

Relacionados en esta página

  • seaborn, capa declarativa estadística sobre matplotlib.
  • plotnine, gramática de gráficos al estilo ggplot2.

seaborn

seaborn es una capa declarativa estadística sobre matplotlib. Diseñada por Michael Waskom, ofrece defaults estéticos sensatos y funciones de alto nivel para los gráficos estadísticos más habituales: distribuciones, relaciones bivariadas, categorical plots, regresiones, heatmaps, pairplots. Internamente sigue siendo matplotlib, así que cualquier ajuste fino se hace bajando al Axes subyacente.

La versión 0.12 introdujo la API seaborn.objects (también llamada new interface), inspirada en la gramática de ggplot2. Conviven con la API clásica. La nueva es más composable, pero la clásica sigue siendo la más usada en EDA cotidiana.

Cuándo usarlo

  • EDA rápida sobre DataFrames con varias variables categóricas y numéricas.
  • Visualización estadística integrada: regplot, lmplot, kdeplot, boxplot, violinplot, pairplot, heatmap.
  • Facetas (relplot, catplot, displot, FacetGrid) para explorar relaciones condicionadas a variables categóricas, el caso de uso donde brilla.

Cuándo NO usarlo

  • Cuando necesitas control milimétrico. Vuelve a matplotlib puro. Seaborn oculta detalles que a veces necesitas.
  • Para gráficos interactivos. Usa plotly, altair o bokeh. Seaborn produce solo estático.
  • Para gramática declarativa pura. Mira plotnine o altair si quieres una API más cercana a ggplot2.

Conceptos clave

  • Functions vs axes-level vs figure-level. Hay funciones que operan sobre un único Axes (scatterplot, boxplot) y funciones que crean su propia figura con facetas (relplot, catplot, displot). Las segundas devuelven un FacetGrid, no un Axes.
  • hue, style, size, col, row. Estéticas que mapean variables a propiedades visuales. La filosofía es declarativa: describes qué mapear, no cómo dibujar.
  • Paletas (palette=...). seaborn tiene paletas integradas ("viridis", "deep", "colorblind"…). Para datos ordinales o divergentes, usa paletas apropiadas, el default no siempre es adecuado.
  • seaborn.objects. API nueva: so.Plot(data, x=, y=).add(so.Dot()).facet("group"). Más composable, pero todavía con funcionalidad incompleta frente a la clásica.
  • set_theme(). Configura defaults globales (paleta, estilo, escala de fuente). Llamarlo al inicio del notebook es el patrón habitual.

Patrón mínimo

import seaborn as sns
import matplotlib.pyplot as plt

sns.set_theme(style="whitegrid", palette="deep")

penguins = sns.load_dataset("penguins")

# Figure-level con facetas
g = sns.relplot(
    data=penguins,
    x="bill_length_mm", y="bill_depth_mm",
    hue="species", col="island",
    kind="scatter", height=3.5, aspect=1,
)
g.set_titles("{col_name}")
g.figure.suptitle("Pico de pingüinos por isla", y=1.05)

# Axes-level: control sobre el objeto matplotlib
fig, ax = plt.subplots(figsize=(6, 4))
sns.violinplot(data=penguins, x="species", y="body_mass_g", ax=ax)
ax.set_ylabel("Masa corporal (g)")

Trampas habituales

  • Confundir axes-level y figure-level. Pasar ax= a relplot falla. relplot crea su propia figura. Usa scatterplot si quieres dibujar sobre un Axes existente.
  • hue con variable continua. Seaborn discretiza por defecto en bins, a veces es lo que quieres, a veces no. Pasa palette= explícita o usa una cmap de matplotlib.
  • set_theme() afecta a matplotlib global. Si compartes proceso con otros gráficos, los rcParams quedan modificados. En notebooks tipicamente está bien. En pipelines, encapsula con sns.axes_style() o plt.rc_context().

Enlaces

Relacionados en esta página

  • matplotlib, motor subyacente. Imprescindible para ajustes finos.
  • plotnine, alternativa con gramática ggplot2 más estricta.

plotnine

plotnine es el port más fiel de ggplot2 a Python. Implementa la gramática de gráficos de Wilkinson tal como la formalizó Hadley Wickham: ggplot(data) + aes(...) + geom_*() + facet_*() + scale_*() + theme_*(). Si vienes de R y echas de menos esa composabilidad, es la opción natural.

Internamente usa matplotlib como motor de renderizado, así que comparte limitaciones (estático) y se integra con el resto del stack. Está bien mantenido (versión activa en 2024-2025) y cubre la mayor parte de la API de ggplot2.

Cuándo usarlo

  • Cuando ya conoces ggplot2 y la gramática te resulta más natural que la API de seaborn.
  • Gráficos compuestos con muchas capas (puntos + suavizados + facetas + escalas personalizadas).
  • Reproducir figuras de papers en R sin reescribir la lógica de visualización.

Cuándo NO usarlo

  • Si no vienes de ggplot2. La curva de aprendizaje frente a seaborn no compensa si no tienes el modelo mental previo.
  • Para gráficos interactivos. No es su dominio. Usa plotly o altair.
  • En pipelines donde rendimiento importa. plotnine añade overhead frente a matplotlib puro. Para producir cientos de figuras automatizadas es más lento.

Conceptos clave

  • Composición con +. La API es idéntica a ggplot2: cada geom_*, scale_*, facet_*, theme_* se añade con +. Esto permite construir gráficos paso a paso.
  • aes(). Mapeo entre columnas del DataFrame y propiedades estéticas (x, y, color, fill, shape, size, alpha).
  • Estadísticas (stat_*). Casi todos los geom_* tienen un stat_* asociado que computa la transformación (binning, suavizado, conteo). Sobreescribir el stat es habitual.
  • Facets (facet_wrap, facet_grid). Equivalente exacto al de ggplot2.
  • Temas. theme_bw(), theme_minimal(), theme_classic() están portados. También se puede componer un tema propio.

Patrón mínimo

from plotnine import (
    ggplot, aes, geom_point, geom_smooth,
    facet_wrap, scale_color_brewer, labs, theme_minimal
)
import pandas as pd

# Asumiendo un DataFrame `penguins` (e.g. de palmerpenguins o seaborn)
import seaborn as sns
penguins = sns.load_dataset("penguins").dropna()

p = (
    ggplot(penguins, aes(x="bill_length_mm", y="bill_depth_mm", color="species"))
    + geom_point(alpha=0.6, size=2)
    + geom_smooth(method="lm", se=False)
    + facet_wrap("~island")
    + scale_color_brewer(type="qual", palette="Set1")
    + labs(x="Longitud pico (mm)", y="Profundidad pico (mm)",
           title="Morfometría de pingüinos por isla")
    + theme_minimal()
)
p.save("plot.pdf", width=8, height=4, dpi=300)

Trampas habituales

  • Strings en aes() vs variables Python. En plotnine los nombres de columnas se pasan como strings (aes(x="bill_length_mm")), no como expresiones libres como en R. Es una diferencia ergonómica menor pero recurrente.
  • Compatibilidad con pandas. Funciona perfectamente con DataFrame de pandas. Con polars necesita .to_pandas() primero (no soporta el protocolo dataframe interchange en todas las versiones).
  • Rendimiento con datasets grandes. Por encima de unos cientos de miles de puntos, considera precomputar bins o usar geom_bin2d / geom_hex. matplotlib puro es más rápido para volcados masivos.

Enlaces

Relacionados en esta página

  • matplotlib, motor subyacente.
  • seaborn, alternativa declarativa con sintaxis distinta.

plotly

plotly (la biblioteca Python. El motor JavaScript se llama plotly.js) es la opción dominante para visualización interactiva en notebooks y dashboards. Produce gráficos HTML con zoom, hover, toggle de series y exportación a PNG/SVG/PDF. Integra plotly.express como API de alto nivel (estilo seaborn) y plotly.graph_objects como API de bajo nivel.

Bajo el capó es JavaScript renderizado en el navegador. Esto le da interactividad pero limita su uso fuera del entorno web, para figuras de papers estáticas, prefiere matplotlib.

Cuándo usarlo

  • Notebooks Jupyter con audiencia interactiva (presentaciones, exploración compartida).
  • Dashboards (vía Dash, Streamlit, Panel o exportación HTML standalone).
  • Gráficos 3D, mapas, Sankey diagrams, parallel coordinates, casos donde matplotlib es incómodo y plotly brilla.
  • Cuando el lector necesita hacer hover para inspeccionar valores individuales.

Cuándo NO usarlo

  • Figuras estáticas para publicación. Plotly puede exportar a PNG/PDF (vía kaleido), pero la calidad tipográfica y el control fino son inferiores a matplotlib.
  • Datasets enormes (>50-100k puntos sin downsampling). El navegador se atasca. Usa datashader + plotly (vía holoviews) o WebGL (scatter_gl).
  • Cuando quieres reproducibilidad estricta de byte. El output HTML cambia con la versión de plotly.js embebida.

Conceptos clave

  • plotly.express vs plotly.graph_objects. Express es alto nivel (una línea por gráfico). Graph_objects construye Figure paso a paso con Traces. Empieza con express, baja a graph_objects para ajustes que express no cubre.
  • Figure. El objeto resultante. fig.show() lo renderiza en notebook. fig.write_html(...) lo serializa. fig.write_image(...) exporta estático (requiere kaleido).
  • Layouts y update_layout. Cambiar título, anotaciones, márgenes, plantillas (template="plotly_white").
  • plotly.io.templates. Plantillas globales tipo theme. Útiles para coherencia de estilo en un dashboard.
  • Integración con pandas y polars. px.scatter(df, x="a", y="b", color="c") acepta cualquier dataframe que cumpla el protocolo de intercambio.

Patrón mínimo

import plotly.express as px
import plotly.io as pio

pio.templates.default = "plotly_white"

df = px.data.gapminder().query("year == 2007")

fig = px.scatter(
    df,
    x="gdpPercap", y="lifeExp",
    size="pop", color="continent",
    log_x=True, hover_name="country",
    size_max=60,
    title="Esperanza de vida vs PIB per cápita (2007)",
)
fig.update_layout(xaxis_title="PIB per cápita (log)", yaxis_title="Esperanza de vida")

fig.write_html("gapminder.html", include_plotlyjs="cdn")
# Para PNG: fig.write_image("gapminder.png", scale=2)  # necesita kaleido

Trampas habituales

  • Exportar a imagen requiere kaleido. Sin él, write_image falla con error opaco. Instala pip install -U kaleido.
  • HTML standalone es pesado. Por defecto embebe todo plotly.js (~3 MB). Usa include_plotlyjs="cdn" para referenciar el CDN.
  • px.scatter con muchos puntos. Por encima de 10-20k puntos el render lag se nota. Usa render_mode="webgl" (en express) o Scattergl (en graph_objects).
  • Colores categóricos por orden de aparición. Si quieres un mapeo explícito, pasa color_discrete_map={"A": "#1f77b4", ...}.

Enlaces

Relacionados en esta página

  • altair, alternativa interactiva declarativa basada en Vega-Lite.
  • seaborn, para el mismo nicho exploratorio pero estático.

altair

altair es una librería declarativa interactiva basada en Vega-Lite. Producida por Jake VanderPlas (uno de los pesos pesados del ecosistema PyData), su filosofía es: describes la especificación del gráfico (qué codifica cada canal) y Vega-Lite la traduce a JavaScript interactivo. La API es notablemente más limpia que plotly y conceptualmente cercana a una gramática de gráficos.

Es la opción favorita de quien valora composabilidad y consistencia conceptual sobre cobertura. Cubre menos tipos de gráfico que plotly, pero los que cubre los hace de forma muy elegante.

Cuándo usarlo

  • Dashboards interactivos en notebooks o Streamlit donde la gramática declarativa simplifica el código.
  • Gráficos vinculados (selección en uno filtra otro), Vega-Lite los expresa de forma muy concisa.
  • Cuando aprecias una API consistente: la misma sintaxis cubre scatter, bar, line, heatmap, area, etc.
  • Publicación en notebooks o web donde Vega-Lite es ciudadano de primera clase (JupyterLab, VS Code, GitHub).

Cuándo NO usarlo

  • Datasets grandes (>5-10k filas) sin pre-agregación. Altair, por defecto, embebe los datos en el spec JSON. Para volúmenes mayores necesitas alt.data_transformers.enable("vegafusion") o pre-agregar.
  • Gráficos 3D, mapas complejos o tipos exóticos. Plotly cubre más casos.
  • Figuras de publicación estáticas. Aunque exporta a PNG/SVG/PDF (vía vl-convert), el ajuste tipográfico es menos refinado que en matplotlib.

Conceptos clave

  • Chart(data).mark_*().encode(...). El triple esquema: datos, geometría (mark), codificación (encode mapea columnas a canales x/y/color/size/…).
  • Canales con tipos explícitos. alt.X("col:Q") declara el tipo: Q cuantitativo, O ordinal, N nominal, T temporal. Vega-Lite usa esto para elegir escalas razonables automáticamente.
  • Composición (|, &, +). Concatenación horizontal, vertical y layered. Equivalente al patchwork de R, pero parte del core.
  • Selecciones e interactividad. alt.selection_point(), alt.selection_interval() permiten enlazar gráficos con muy poco código.
  • VegaFusion. Para datasets grandes, habilita el backend Rust con alt.data_transformers.enable("vegafusion"), preprocesa los datos antes de pasarlos al cliente.

Patrón mínimo

import altair as alt
from vega_datasets import data

cars = data.cars()

# Brush para selección
brush = alt.selection_interval()

points = (
    alt.Chart(cars)
    .mark_point()
    .encode(
        x=alt.X("Horsepower:Q"),
        y=alt.Y("Miles_per_Gallon:Q"),
        color=alt.condition(brush, "Origin:N", alt.value("lightgray")),
        tooltip=["Name", "Origin", "Year"],
    )
    .add_params(brush)
    .properties(width=400, height=300)
)

bars = (
    alt.Chart(cars)
    .mark_bar()
    .encode(x="count():Q", y="Origin:N", color="Origin:N")
    .transform_filter(brush)
)

chart = points | bars
chart.save("cars.html")

Trampas habituales

  • 5000 filas como límite por defecto. Altair embebe los datos en el spec. Superado el umbral falla con MaxRowsError. Sube el límite con alt.data_transformers.disable_max_rows() o (mejor) activa vegafusion.
  • Tipos no declarados. Si omites el sufijo :Q/:N/:O/:T, Altair infiere, a veces mal. Para datos categóricos numéricos (códigos), declara :N explícitamente.
  • Exportación estática. Requiere vl-convert-python. Sin él, solo HTML.
  • Render en JupyterLab vs notebooks clásicos. En clásicos puede requerir la extensión vega. JupyterLab y VS Code lo manejan nativo.

Enlaces

Relacionados en esta página

  • plotly, alternativa interactiva con mayor cobertura.
  • plotnine, gramática declarativa pero estática.