Caso completo: EDA publicable

python
pandas
eda
Un análisis exploratorio de principio a fin sobre un dataset real. Lectura, limpieza, exploración, agregación, visualización y conclusiones, el flujo idiomático moderno con pandas y seaborn unido en un solo recorrido.

El caso

Nos llega un dataset clásico para terminar la ruta: propinas en un restaurante. Cada fila es una cuenta, con la propina recibida, el total, la mesa, el día y otros datos del comensal. La pregunta de negocio: ¿qué factores influyen en la propina?

Lo trabajamos como un EDA real: leer, inspeccionar, limpiar, agrupar, visualizar, concluir.

Dataset usado: tips de seaborn (sns.load_dataset("tips")). 244 filas, datos reales de un mesero estadounidense en los años 90, recopilados como caso académico.

1. Lectura e inspección

import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt

sns.set_theme(style="whitegrid")
pd.set_option("display.precision", 2)

df = sns.load_dataset("tips")
df.head()
#    total_bill   tip     sex smoker  day    time  size
# 0       16.99  1.01  Female     No  Sun  Dinner     2
# 1       10.34  1.66    Male     No  Sun  Dinner     3
# 2       21.01  3.50    Male     No  Sun  Dinner     3
# 3       23.68  3.31    Male     No  Sun  Dinner     2
# 4       24.59  3.61  Female     No  Sun  Dinner     4

Lo primero después de cargar: info() y describe().

df.info()
# 244 entries, 4 numeric + 4 categorical, 0 nulls

df.describe()
#        total_bill     tip    size
# count      244.00  244.00  244.00
# mean        19.79    3.00    2.57
# std          8.90    1.38    0.95
# min          3.07    1.00    1.00
# 50%         17.80    2.90    2.00
# max         50.81   10.00    6.00

Sin valores nulos, rangos plausibles. La cuenta media es 19.79 dólares, la propina media 3 dólares. Buen punto de partida.

2. Variable derivada: porcentaje de propina

La propina absoluta no es comparable entre cuentas grandes y pequeñas. Crea una columna porcentaje de propina, más interpretable:

df = df.assign(tip_pct=lambda x: x["tip"] / x["total_bill"] * 100)

df["tip_pct"].describe()
# mean    16.08
# std      6.11
# min      3.56
# 50%     15.48
# max     71.03

La propina media es ~16 %. Un máximo de 71 % parece outlier, alguien dejó 5.16 sobre una cuenta de 7.25. Lo investigamos:

df.nlargest(3, "tip_pct")
#     total_bill   tip     sex smoker  day    time  size  tip_pct
# 172       7.25  5.15    Male     No  Sun  Dinner     2    71.03
# 178       9.60  4.00  Female    Yes  Sun  Dinner     2    41.67
# 67        3.07  1.00  Female    Yes  Sat  Dinner     1    32.57

Tres casos de propinas relativas altas, las tres en cuentas pequeñas. Probablemente comensales generosos o redondeo simbólico. Los dejamos, son datos reales, no errores.

3. ¿Qué influye en la propina?

Empezamos pintando la relación principal:

fig, ax = plt.subplots(figsize=(8, 5))
sns.scatterplot(data=df, x="total_bill", y="tip", hue="time",
                alpha=0.7, ax=ax)
sns.regplot(data=df, x="total_bill", y="tip", scatter=False,
            ax=ax, color="grey", line_kws={"linestyle": "--"})
ax.set_title("Propina vs cuenta total")
ax.set_xlabel("Cuenta total ($)")
ax.set_ylabel("Propina ($)")
plt.tight_layout()

La relación es claramente positiva, más cuenta, más propina. La línea de regresión global ayuda como referencia. Pintamos en color time (Lunch/Dinner) para detectar si hay un patrón.

A simple vista no se ve una separación clara entre comidas y cenas. Lo confirmamos con números:

df.groupby("time", observed=True).agg(
    cuenta_media=("total_bill", "mean"),
    propina_media=("tip", "mean"),
    pct_propina=("tip_pct", "mean"),
    n=("tip", "count"),
)
#         cuenta_media  propina_media  pct_propina    n
# time
# Lunch          17.17           2.73        16.41   68
# Dinner         20.80           3.10        15.95  176

Las cenas tienen cuentas mayores y propinas mayores en absoluto, pero el porcentaje es similar (16.4 % vs 16.0 %). El efecto absoluto viene del tamaño de la cuenta, no del momento del día.

4. ¿Día de la semana?

orden_dias = ["Thur", "Fri", "Sat", "Sun"]
df["day"] = pd.Categorical(df["day"], categories=orden_dias, ordered=True)

resumen_dia = df.groupby("day", observed=True).agg(
    cuenta_media=("total_bill", "mean"),
    pct_propina=("tip_pct", "mean"),
    n=("tip", "count"),
)
resumen_dia
#       cuenta_media  pct_propina   n
# day
# Thur         17.68        16.13  62
# Fri          17.15        16.99  19
# Sat          20.44        15.32  87
# Sun          21.41        16.69  76

Convertir day a Categorical ordenada sirve para que los gráficos respeten el orden semanal en vez de alfabético.

Los días más concurridos (Sat, Sun) tienen cuentas más grandes pero propinas relativas algo más bajas. El sábado es el que peor propina relativa deja. El viernes, con muestra pequeña (19), es el mejor, pero hay que tomarlo con cuidado por tamaño muestral.

Pintamos:

fig, ax = plt.subplots(figsize=(8, 5))
sns.boxplot(data=df, x="day", y="tip_pct", ax=ax, order=orden_dias)
sns.stripplot(data=df, x="day", y="tip_pct", ax=ax,
              order=orden_dias, color="black", alpha=0.3, size=3)

ax.set_title("Porcentaje de propina por día")
ax.set_xlabel("")
ax.set_ylabel("Propina (% de la cuenta)")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
plt.tight_layout()

El boxplot resume la distribución. El stripplot encima muestra los puntos crudos. Combinación útil cuando la N por grupo es modesta, se ve tanto la forma como cada caso.

5. ¿Tamaño del grupo influye?

df.groupby("size", observed=True).agg(
    cuenta_media=("total_bill", "mean"),
    pct_propina=("tip_pct", "mean"),
    n=("tip", "count"),
)
#       cuenta_media  pct_propina    n
# size
# 1             7.24        21.73    4
# 2            16.45        16.57  156
# 3            23.28        15.22   38
# 4            28.61        14.59   37
# 5            30.07        14.15    5
# 6            34.83        15.62    4

Patrón interesante: a mayor grupo, menor porcentaje de propina. Las cuentas en solitario dejan ~22 %, los grupos de 4 dejan ~15 %. La N de los grupos de 1, 5 y 6 es pequeña (5 o menos), así que el patrón fuerte vive entre 2-4 comensales. La hipótesis natural: en grupos grandes, la propina se “diluye” en una sola persona pagando.

Visualmente:

fig, ax = plt.subplots(figsize=(8, 5))
sns.barplot(data=df, x="size", y="tip_pct", ax=ax,
            errorbar=("ci", 95), color="#5F8575")
ax.set_title("Porcentaje de propina según tamaño del grupo")
ax.set_xlabel("Comensales en la mesa")
ax.set_ylabel("Propina (% de la cuenta)")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
plt.tight_layout()

errorbar=("ci", 95) añade intervalos de confianza al 95 %. La separación entre size=1 y size=4 es visible más allá del intervalo de confianza. La de size=5 y size=6 cae en intervalos muy anchos por la N pequeña, no concluyente.

6. ¿Sexo y fumadores?

df.groupby(["sex", "smoker"], observed=True).agg(
    pct_propina=("tip_pct", "mean"),
    n=("tip", "count"),
)
#                 pct_propina    n
# sex    smoker
# Female No             15.69   54
#        Yes            18.22   33
# Male   No             16.07   97
#        Yes            15.28   60

Las mujeres fumadoras dejan la propina relativa más alta del dataset (18.2 %). Diferencia notable con su contrapunto no fumadora (15.7 %). En hombres, el patrón es el contrario y más débil. Cuidado con interpretar: las muestras por celda son modestas, y “sexo × fumador” en los 90 captura sesgos demográficos del momento.

7. Resumen visual: múltiples paneles

Cierro el análisis con una figura síntesis. Cuatro paneles, uno por factor:

fig, axes = plt.subplots(2, 2, figsize=(12, 8))

# Cuenta vs propina
sns.scatterplot(data=df, x="total_bill", y="tip", ax=axes[0, 0], alpha=0.6)
axes[0, 0].set_title("Cuenta vs propina")

# Por día
sns.boxplot(data=df, x="day", y="tip_pct", ax=axes[0, 1], order=orden_dias)
axes[0, 1].set_title("Propina (%) por día")

# Por tamaño
sns.barplot(data=df, x="size", y="tip_pct", ax=axes[1, 0],
            errorbar=("ci", 95), color="#5F8575")
axes[1, 0].set_title("Propina (%) por tamaño")

# Por sexo y fumador
sns.boxplot(data=df, x="sex", y="tip_pct", hue="smoker", ax=axes[1, 1])
axes[1, 1].set_title("Propina (%) por sexo y fumador")

for ax in axes.flat:
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)

plt.tight_layout()
plt.savefig("eda_propinas.png", dpi=300, bbox_inches="tight")

Cuatro vistas en una página, con tipografía consistente y bordes mínimos. Es el formato que normalmente acompaña la conclusión escrita de un informe.

8. Conclusiones del análisis

Lo que aprendimos:

  • La propina absoluta sube con la cuenta, como cabía esperar. La pendiente media implica una propina cercana al 15-16 % de la cuenta.
  • El día no influye fuerte en la propina relativa. El sábado es ligeramente peor. El viernes, con muestra pequeña, parece mejor.
  • El tamaño del grupo sí importa: cuanto mayor el grupo, menor el porcentaje de propina. Patrón consistente entre 1-4 comensales.
  • Mujeres fumadoras se desvían al alza. El resto de combinaciones de sexo × fumador queda en torno al 15-16 %. Tamaños muestrales modestos.
  • No vemos diferencia material entre Lunch y Dinner una vez normalizado por cuenta.

Para profundizar, los siguientes pasos serían un modelo de regresión (lineal o GLM) con tip_pct como respuesta y los factores como predictores controlados, pero eso ya entra en territorio de modelado, no de EDA.

El flujo idiomático

Repasando el recorrido del caso, el patrón general se parece a esto:

  1. Cargar, info(), describe(), comprueba tipos, nulos, rangos.
  2. Crear variables derivadas que respondan a la pregunta de negocio. (tip_pct aquí.)
  3. Explorar con plots simples (scatter, hist) para detectar la relación dominante.
  4. Agrupar con groupby().agg() para resúmenes por factor.
  5. Validar las observaciones con visualizaciones más detalladas (box, bar con CI).
  6. Conclusión escrita acompañada de una figura síntesis con paneles.
  7. Guardar la figura a dpi=300 para informe.

Esto es el ritmo de un EDA bien hecho. Cada paso de la ruta cubre una parte. Este caso une todas.

Has terminado la ruta

Con esto cierras Python para análisis de datos. Has visto:

  • El entorno (Python + uv + Jupyter) y por qué importa.
  • NumPy como capa numérica subyacente.
  • pandas como herramienta tabular: Series, DataFrame, Index.
  • Lectura de datos en sus formatos habituales.
  • Selección y operaciones idiomáticas.
  • groupby y reshape.
  • Joins y combinaciones.
  • Fechas y series temporales.
  • Visualización con matplotlib + seaborn.
  • Un EDA real de principio a fin.

A partir de aquí, las direcciones naturales: modelado (statsmodels, scikit-learn), interactividad (plotly, Streamlit, Shiny for Python), big data (polars, duckdb). El núcleo de pandas que has cubierto se transfiere a todas ellas.

Cuando publiquemos el libro pandas para analistas (con guiños a R), se cubrirá todo esto con más casos, más comparaciones cuidadosas con dplyr/tidyr y patrones avanzados que aparecen en producción.