Caso completo: EDA publicable
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 4Lo 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.00Sin 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.03La 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.57Tres 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 176Las 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 76Convertir 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 4Patró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 60Las 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:
- Cargar,
info(),describe(), comprueba tipos, nulos, rangos. - Crear variables derivadas que respondan a la pregunta de negocio. (
tip_pctaquí.) - Explorar con plots simples (scatter, hist) para detectar la relación dominante.
- Agrupar con
groupby().agg()para resúmenes por factor. - Validar las observaciones con visualizaciones más detalladas (box, bar con CI).
- Conclusión escrita acompañada de una figura síntesis con paneles.
- Guardar la figura a
dpi=300para 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.
groupbyy 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.