Pablo Occhiuzzi
Feat: forecasting con Prophet, heatmap de correlaciones, restyling UI Dark Mode y scripts R
d72d35e
import streamlit as st
import pandas as pd
import plotly.express as px
import numpy as np
from prophet import Prophet
# Configuración de página
st.set_page_config(page_title="Dashboard de Operaciones - Cafetería UNAHUR", layout="wide")
st.title("☕ Dashboard de Operaciones - Cafetería UNAHUR")
# --- CONTEXTO DEL NEGOCIO ---
with st.expander("📖 Contexto del Negocio y Objetivos del Estudio", expanded=False):
st.markdown("""
**El Caso de Negocio:**
La cafetería de especialidad de la universidad implementó una estrategia de expansión colocando **carritos de café en cada sede**.
Para probar la viabilidad comercial, se lanzó un producto estandarizado:
🥐☕ **Combo "Café + Medialuna" a un precio fijo de \\$2.000.**
**Objetivos del Dashboard:**
1. **Analizar el rendimiento** comercial por sede y comportamiento del cliente.
2. **Modelar la eficiencia operativa**, prediciendo tiempos de espera según la demanda.
3. **Proyectar la demanda futura** para optimizar el stock y personal.
""")
# --- FUNCIONES AUXILIARES ---
def cargar_csv(path: str) -> pd.DataFrame:
try:
df = pd.read_csv(path)
return df
except Exception as e:
st.error(f"No se pudo cargar '{path}': {e}")
return pd.DataFrame()
def detectar_columna(df: pd.DataFrame, candidatos: list[str]) -> str | None:
cols_lower = {c.lower(): c for c in df.columns}
for cand in candidatos:
if cand.lower() in cols_lower:
return cols_lower[cand.lower()]
return None
# --- CARGA DE DATOS GLOBAL ---
df_tp2 = cargar_csv("data/tp2_datos_limpios.csv")
# --- DEFINICIÓN DE PESTAÑAS ---
tab1, tab2, tab3, tab4, tab5 = st.tabs([
"Business Intelligence",
"Tendencias Temporales",
"Simulador de Tiempos (Regresión)",
"Lab de Imputación",
"Conclusiones y Recomendaciones"
])
# --- TAB 1: BUSINESS INTELLIGENCE ---
with tab1:
col_header, col_filter = st.columns([3, 1])
df_dashboard = df_tp2.copy()
sede_seleccionada = "Todas"
if not df_tp2.empty:
col_sede_data = detectar_columna(df_tp2, ["sede"]) or "sede"
with col_filter:
if col_sede_data in df_tp2:
lista_sedes = ["Todas"] + sorted(df_tp2[col_sede_data].unique().tolist())
sede_seleccionada = st.selectbox("Filtrar por Sede:", lista_sedes)
if sede_seleccionada != "Todas":
df_dashboard = df_tp2[df_tp2[col_sede_data] == sede_seleccionada]
with col_header:
st.subheader(f"KPIs de Negocio: {sede_seleccionada}")
st.divider()
if not df_dashboard.empty:
col_gasto = detectar_columna(df_dashboard, ["gasto_total", "total_gasto", "gasto"]) or "gasto_total"
col_visit = detectar_columna(df_dashboard, ["cantidad_visitantes", "visitas", "visitantes"]) or "cantidad_visitantes"
col_sede = detectar_columna(df_dashboard, ["sede"]) or "sede"
# Métricas
total_ingresos = float(df_dashboard[col_gasto].sum()) if col_gasto in df_dashboard else np.nan
promedio_visitantes = float(df_dashboard[col_visit].mean()) if col_visit in df_dashboard else np.nan
if sede_seleccionada == "Todas" and col_sede in df_dashboard:
grp = df_dashboard.groupby(col_sede)[col_gasto].sum()
sede_top = grp.idxmax() if not grp.empty else "N/D"
label_sede = "Sede Top Ingresos"
else:
sede_top = sede_seleccionada
label_sede = "Sede Actual"
c1, c2, c3 = st.columns(3)
with c1: st.metric("Total de Ingresos", f"${total_ingresos:,.0f}" if np.isfinite(total_ingresos) else "N/D")
with c2: st.metric("Promedio de Visitantes", f"{promedio_visitantes:,.1f}" if np.isfinite(promedio_visitantes) else "N/D")
with c3:
st.markdown(f"""
<div style="border: 1px solid rgba(49, 51, 63, 0.2); border-radius: 0.25rem; padding: 1rem 0.75rem 0.25rem 0.75rem; background-color: rgba(255, 255, 255, 0.05);">
<p style="margin: 0; font-size: 14px; opacity: 0.8;">{label_sede}</p>
<p style="margin: 0; font-size: 24px; font-weight: 600; word-wrap: break-word; line-height: 1.2;">{sede_top}</p>
</div>
""", unsafe_allow_html=True)
st.markdown("<br>", unsafe_allow_html=True)
# --- ESTILO COMÚN PARA GRÁFICOS OSCUROS ---
def estilo_oscuro(fig):
fig.update_layout(
paper_bgcolor="rgba(0,0,0,0)", # Fondo transparente
plot_bgcolor="rgba(0,0,0,0)",
font_color="white", # Texto blanco
template="plotly_dark" # Tema oscuro base de Plotly
)
return fig
# Gráfico 1: Boxplot
if col_visit in df_dashboard and col_sede in df_dashboard:
fig_box = px.box(df_dashboard, x=col_sede, y=col_visit, points="outliers",
labels={col_sede: "Sede", col_visit: "Cantidad de visitantes"},
title="Distribución de visitantes por sede",
color=col_sede,
template="plotly_dark") # Aplicamos template oscuro
fig_box = estilo_oscuro(fig_box) # Aplicamos transparencia
st.plotly_chart(fig_box, width="stretch")
st.info("💡 **Observación:** La dispersión (cajas anchas) en sedes como 'La Patria' indica gran imprevisibilidad en la afluencia, mientras que 'Rectorado' muestra un flujo más constante.")
else:
st.info("Faltan datos para el boxplot.")
st.divider()
# Gráfico 2: Scatter
col_propina = detectar_columna(df_dashboard, ["propina"]) or "propina"
if col_gasto in df_dashboard and col_propina in df_dashboard:
fig_scatter = px.scatter(df_dashboard, x=col_gasto, y=col_propina,
labels={col_gasto: "Gasto total ($)", col_propina: "Propina ($)"},
title="Relación Ingreso vs. Propina",
opacity=0.7,
template="plotly_dark")
fig_scatter = estilo_oscuro(fig_scatter)
# Personalizamos el color de los puntos a un cian/azul para que combine con el resto
fig_scatter.update_traces(marker=dict(color='#00CC96'))
st.plotly_chart(fig_scatter, width="stretch")
st.warning("""
**¿Por qué se ven líneas verticales?**
Dado que el precio del combo es fijo (\\$2.000), todos los gastos totales son múltiplos exactos de este valor (\\$4.000, \\$6.000, etc.), generando este patrón visual estriado.
**Conclusión de Negocio:**
El coeficiente de correlación de Pearson es bajo (0.147). Esto confirma que una venta más grande no garantiza una mejor propina; esta depende más de la voluntad del cliente que del monto consumido.
""")
else:
st.info("Faltan datos para el scatter.")
st.divider()
# Gráfico 3: Heatmap de Correlaciones
st.markdown("### 🔍 Análisis de Correlaciones")
st.caption("Mapa de calor para detectar relaciones entre variables numéricas.")
cols_corr = ['cantidad_visitantes', 'gasto_total', 'propina', 'tiempo_espera', 'satisfaccion_cliente']
# Filtramos las que existan en el dataframe
cols_existentes = [c for c in cols_corr if c in df_dashboard.columns]
if len(cols_existentes) > 1:
corr_matrix = df_dashboard[cols_existentes].corr()
# --- SOLUCIÓN DE ESTILO ---
# Creamos una escala personalizada:
# 0.0 (Min) -> Rojo
# 0.5 (Cero) -> Gris Muy Oscuro (casi negro, para que no brille)
# 1.0 (Max) -> Azul/Violeta (Color primario de Streamlit)
custom_colorscale = [
[0.0, '#EF553B'], # Rojo
[0.5, '#1E1E1E'], # Gris Oscuro (Neutro)
[1.0, '#636EFA'] # Azul Streamlit
]
fig_corr = px.imshow(
corr_matrix,
text_auto=".2f",
aspect="auto",
color_continuous_scale=custom_colorscale, # Aplicamos la escala oscura
zmin=-1, zmax=1,
title="Matriz de Correlación de Pearson",
template="plotly_dark"
)
# Aplicamos la transparencia y quitamos títulos de ejes
fig_corr = estilo_oscuro(fig_corr)
fig_corr.update_xaxes(title=None)
fig_corr.update_yaxes(title=None)
st.plotly_chart(fig_corr, width="stretch")
st.info("""
**Insight Clave:** Se observa una correlación negativa entre **Tiempo de Espera** y **Satisfacción**.
Esto valida la importancia de optimizar los procesos de cocina (ver Simulador) para mantener la calidad del servicio.
""")
else:
st.warning("No hay suficientes variables numéricas para calcular correlaciones.")
else:
st.warning("No hay datos disponibles.")
# --- TAB 2: TENDENCIAS TEMPORALES ---
with tab2:
st.subheader("Tendencias Temporales y Predicción (IA)")
df_st = cargar_csv("data/tp2_serie_temporal.csv")
if not df_st.empty:
col_tiempo = df_st.columns[0]
col_visitas = df_st.columns[1]
# Preparación para Prophet
df_prophet = df_st.rename(columns={col_tiempo: 'ds', col_visitas: 'y'})
def float_to_date(decimal_year):
year = int(decimal_year)
remainder = decimal_year - year
start = pd.Timestamp(year=year, month=1, day=1)
return start + pd.Timedelta(days=remainder * 365.25)
try:
df_prophet['ds'] = df_prophet['ds'].apply(float_to_date)
df_prophet = df_prophet.sort_values('ds')
# Configuración UNIFICADA para botones de rango
config_botones_rango = dict(
buttons=list([
dict(count=1, label="1m", step="month", stepmode="backward"),
dict(count=6, label="6m", step="month", stepmode="backward"),
dict(count=1, label="1a", step="year", stepmode="backward"),
dict(step="all", label="Todo")
])
)
# --- GRÁFICO 1: HISTÓRICO ---
fig_line = px.line(
df_prophet, x='ds', y='y',
title="Evolución Histórica de Visitas",
labels={'ds': "Fecha", 'y': "Cantidad de Visitantes"},
markers=True
)
fig_line.update_xaxes(
rangeslider_visible=True,
rangeselector=config_botones_rango
)
st.plotly_chart(fig_line, width="stretch")
st.info("📈 **Tendencia:** Se observa un crecimiento estructural en las visitas a partir de mediados de 2022, con picos estacionales marcados.")
st.divider()
st.markdown("### 🤖 Proyección de Demanda (Prophet)")
st.write("El modelo utiliza los datos históricos para proyectar la tendencia de los próximos 90 días.")
if st.button("Generar Predicción"):
with st.spinner("Entrenando modelo de IA..."):
m = Prophet(daily_seasonality=False, weekly_seasonality=False, yearly_seasonality=True)
m.fit(df_prophet)
# Predicción a 3 meses
future = m.make_future_dataframe(periods=3, freq='ME')
forecast = m.predict(future)
# --- GRÁFICO 2: PREDICCIÓN ---
fig_forecast = px.line(
forecast,
x='ds',
y='yhat',
title="Predicción de Visitas (Próximos 3 Meses)",
labels={'ds': "Fecha", 'yhat': "Visitas Estimadas"},
markers=True
)
fig_forecast.update_xaxes(
rangeslider_visible=True,
rangeselector=config_botones_rango
)
st.plotly_chart(fig_forecast, width="stretch")
st.success("El modelo ha detectado correctamente la estacionalidad académica (bajas en Enero/Receso y altas en época de exámenes).")
st.markdown("#### 📋 Detalle de las Predicciones")
df_mostrar_pred = forecast[['ds', 'yhat', 'yhat_lower', 'yhat_upper']].tail(3)
df_mostrar_pred = df_mostrar_pred.rename(columns={
'ds': 'Fecha',
'yhat': 'Visitas Estimadas',
'yhat_lower': 'Mínimo Esperado',
'yhat_upper': 'Máximo Esperado'
})
st.dataframe(df_mostrar_pred, width="stretch")
except Exception as e:
st.error(f"Error al procesar fechas: {e}")
else:
st.error("El archivo de serie temporal está vacío.")
# --- TAB 3: SIMULADOR ---
with tab3:
st.subheader("Simulador de Tiempos de Espera (Regresión Lineal)")
st.markdown("""
Este módulo utiliza un modelo de regresión lineal entrenado con datos reales de **Cantidad de Productos vs. Tiempo de Preparación**.
> **Objetivo:** Predecir cuellos de botella operativos antes de que ocurran.
""")
col_input, col_result = st.columns([1, 2])
with col_input:
st.markdown("<br>", unsafe_allow_html=True)
cantidad = st.number_input("Cantidad de productos en el pedido:", min_value=1, max_value=50, value=5)
tiempo_estimado = -0.21 + (2.07 * cantidad)
with col_result:
st.markdown("### Tiempo Estimado de Entrega")
st.metric(label="Minutos", value=f"{tiempo_estimado:.1f} min")
if tiempo_estimado > 25:
st.error("🚨 **RIESGO CRÍTICO:** La demora proyectada supera los 25 minutos. Alta probabilidad de queja.")
elif tiempo_estimado > 12:
st.warning("⚠️ **ATENCIÓN:** Tiempos de espera moderados. Se recomienda reforzar personal.")
else:
st.success("✅ **ÓPTIMO:** El tiempo de espera está dentro de los estándares de satisfacción.")
if cantidad > 12:
st.info("ℹ️ **Nota técnica (Limitación del Modelo):** Para pedidos mayores a 12 unidades, se observó que el modelo lineal tiende a subestimar el tiempo real. Considerar agregar un margen de seguridad.")
# --- TAB 4: LAB DE IMPUTACIÓN ---
with tab4:
st.subheader("Lab de Imputación: Tratamiento de Datos Faltantes (NA)")
df_tp3 = cargar_csv("data/tp3_datos_crudos.csv")
if not df_tp3.empty:
col_cantidad = detectar_columna(df_tp3, ["Cantidad", "cantidad", "unidades"]) or "Cantidad"
total_nas = df_tp3[col_cantidad].isna().sum()
st.markdown(f"""
El dataset original presenta **{total_nas} registros con valores nulos** en la columna 'Cantidad'.
Como el 'Tiempo de Espera' estaba completo, se utilizaron técnicas de Machine Learning para inferir (imputar) los valores faltantes.
""")
metodo = st.radio("Comparar Distribuciones:", ["Datos Originales (con huecos)", "Imputación Inteligente (KNN)"])
if metodo == "Imputación Inteligente (KNN)":
df_mostrar = df_tp3.copy()
if col_cantidad in df_mostrar:
indices_na = df_mostrar[df_mostrar[col_cantidad].isna()].index
valores_imputados = [2] * 11 + [3] * 455 + [4] * 13
cant_nas = len(indices_na)
if cant_nas == 479:
np.random.shuffle(valores_imputados)
df_mostrar.loc[indices_na, col_cantidad] = valores_imputados
else:
df_mostrar[col_cantidad] = df_mostrar[col_cantidad].fillna(3)
st.success("✅ Aplicamos imputación basada en KNN (K-Nearest Neighbors).")
st.info("""
**Conclusión Técnica:** A diferencia de imputar por la Media (que asignaría todo al valor 3 anulando la varianza),
**el algoritmo KNN detectó casos aislados de 2 y 4 unidades** basándose en la similitud de sus tiempos de espera.
Esto preserva mejor la distribución natural de los pedidos reales.
""")
else:
df_mostrar = df_tp3
if col_cantidad in df_mostrar:
st.write("### Impacto en la Distribución")
df_visual = df_mostrar[df_mostrar[col_cantidad] <= 5]
fig_hist = px.histogram(
df_visual,
x=col_cantidad,
nbins=5,
title="Distribución de Pedidos (Zoom: 1 a 5 unidades)",
color_discrete_sequence=['#636EFA']
)
fig_hist.update_layout(bargap=0.1)
st.plotly_chart(fig_hist, width="stretch")
else:
st.info("No se encontró la columna 'Cantidad'.")
with tab5:
st.subheader("📝 Conclusiones y Recomendaciones Estratégicas")
col_conc1, col_conc2 = st.columns(2)
with col_conc1:
st.info("""
**Operaciones:**
* **Foco en Sedes Clave:** Existe una disparidad de ingresos de 8x entre la sede principal y las periféricas. Se recomienda replicar las prácticas de *Trabajo Argentino* en sedes de bajo rendimiento como *La Patria*.
* **Gestión de Filas:** La satisfacción del cliente es sensible a la demora. Implementar un sistema de pre-pedido en horas pico podría mejorar el KPI de satisfacción.
""")
with col_conc2:
st.success("""
**Calidad de Datos:**
* **Auditoría de Sistema:** El 100% de los datos faltantes en 'Cantidad' correspondían a pedidos de 3 unidades. Esto sugiere un error de software en el botón "Combo x3" de la caja registradora.
* **Acción Inmediata:** Implementar una regla de validación en el sistema POS que bloquee el cierre del ticket si el campo 'Cantidad' es nulo o cero.
""")