Spaces:
Sleeping
Sleeping
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. | |
| """) |