"""EDA Express — análisis exploratorio automático de datasets. Sube un CSV o Excel (o usa un dataset de ejemplo) y obtén: visión general, calidad de datos, distribuciones, correlaciones, análisis respecto a una variable objetivo, alertas accionables, conclusiones narrativas con LLM (opcional) e informe descargable. Ejecutar en local: streamlit run app.py """ import os import pandas as pd import plotly.express as px import streamlit as st from eda import alerts as alerts_mod from eda import insights as insights_mod from eda import profiler, report st.set_page_config(page_title="EDA Express", page_icon=None, layout="wide") DATASETS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "datasets") EXAMPLES = { "Titanic (pasajeros y supervivencia)": "titanic.csv", "Pingüinos de Palmer (medidas por especie)": "penguins.csv", } ALERT_STYLE = { "critico": ("Crítico", "#c0392b"), "aviso": ("Aviso", "#d68910"), "info": ("Info", "#2471a3"), } # --------------------------------------------------------------------------- # Carga de datos (cacheada: cambiar de pestaña no recalcula nada) # --------------------------------------------------------------------------- @st.cache_data(show_spinner=False) def load_uploaded(data: bytes, filename: str) -> pd.DataFrame: return profiler.load_dataframe(data, filename) @st.cache_data(show_spinner=False) def load_example(filename: str) -> pd.DataFrame: with open(os.path.join(DATASETS_DIR, filename), "rb") as f: return profiler.load_dataframe(f.read(), filename) # --------------------------------------------------------------------------- # Barra lateral: origen de datos y opciones # --------------------------------------------------------------------------- st.sidebar.title("EDA Express") st.sidebar.caption("Análisis exploratorio automático") uploaded = st.sidebar.file_uploader("Sube un CSV o Excel", type=["csv", "xlsx", "xls"]) example_choice = st.sidebar.selectbox( "... o elige un dataset de ejemplo", ["(ninguno)"] + list(EXAMPLES.keys()), ) df = None dataset_name = None if uploaded is not None: try: df = load_uploaded(uploaded.getvalue(), uploaded.name) dataset_name = uploaded.name except Exception as e: st.sidebar.error(f"No se pudo leer el archivo: {e}") elif example_choice != "(ninguno)": df = load_example(EXAMPLES[example_choice]) dataset_name = EXAMPLES[example_choice] if df is None: st.title("EDA Express") st.markdown( "Sube un dataset en la barra lateral (o elige un ejemplo) y obtén un " "análisis exploratorio completo en segundos: calidad de datos, " "distribuciones, correlaciones, alertas accionables e informe descargable." ) st.markdown( "- **Todo se calcula en tu sesión** — los datos no salen del servidor.\n" "- Las **conclusiones narrativas con LLM** son opcionales y solo envían " "estadísticas agregadas, nunca filas.\n" "- Formatos: CSV (detecta `,` `;` y tabulador) y Excel." ) st.stop() if df.empty or df.shape[1] == 0: st.error("El archivo se leyó pero no contiene datos.") st.stop() target = st.sidebar.selectbox( "Columna objetivo (opcional)", ["(ninguna)"] + df.columns.tolist(), help="Si la eliges, se añade análisis de balance de clases y correlación con el objetivo.", ) target = None if target == "(ninguna)" else target st.sidebar.markdown("---") llm_on = st.sidebar.toggle( "Conclusiones con LLM", value=False, help="Requiere GROQ_API_KEY. Envía solo estadísticas agregadas, nunca los datos.", disabled=not insights_mod.llm_available(), ) if not insights_mod.llm_available(): st.sidebar.caption("Sin GROQ_API_KEY: conclusiones LLM desactivadas. El resto funciona igual.") # --------------------------------------------------------------------------- # Cálculo del perfil # --------------------------------------------------------------------------- ov = profiler.overview(df) all_alerts = alerts_mod.run_all_checks(df, target) plot_df = profiler.sample_for_plots(df) st.title(f"Análisis de {dataset_name}") if len(plot_df) < len(df): st.caption( f"Dataset grande: los gráficos usan una muestra de {len(plot_df):,} filas; " "las estadísticas están calculadas con todas." ) col1, col2, col3, col4, col5 = st.columns(5) col1.metric("Filas", f"{ov['filas']:,}") col2.metric("Columnas", ov["columnas"]) col3.metric("Faltantes", f"{ov['faltantes_pct']}%") col4.metric("Duplicadas", f"{ov['duplicadas_pct']}%") n_criticas = sum(1 for a in all_alerts if a["nivel"] == "critico") col5.metric("Alertas críticas", n_criticas) tabs = st.tabs( ["Alertas", "Columnas", "Faltantes", "Numéricas", "Categóricas", "Correlaciones", "Objetivo", "Conclusiones", "Informe"] ) # --- Alertas --------------------------------------------------------------- with tabs[0]: if not all_alerts: st.success("Sin alertas: el dataset pasa todas las comprobaciones de calidad.") for a in all_alerts: label, color = ALERT_STYLE[a["nivel"]] st.markdown( f"
" f"{label} · {a['columna']}
" f"{a['mensaje']}
{a['recomendacion']}
", unsafe_allow_html=True, ) # --- Columnas ---------------------------------------------------------------- with tabs[1]: st.dataframe(profiler.column_table(df), width="stretch", hide_index=True) with st.expander("Primeras 20 filas del dataset"): st.dataframe(df.head(20), width="stretch") # --- Faltantes --------------------------------------------------------------- with tabs[2]: missing = profiler.missing_table(df) if missing.empty: st.success("No hay valores faltantes.") else: fig = px.bar( missing, x="porcentaje", y="columna", orientation="h", labels={"porcentaje": "% faltante", "columna": ""}, title="Porcentaje de valores faltantes por columna", ) fig.update_layout(yaxis={"categoryorder": "total ascending"}) st.plotly_chart(fig, width="stretch") st.dataframe(missing, width="stretch", hide_index=True) # --- Numéricas --------------------------------------------------------------- with tabs[3]: numeric_cols = profiler.numeric_columns(df) if not numeric_cols: st.info("El dataset no tiene columnas numéricas.") else: st.dataframe(profiler.numeric_table(df), width="stretch", hide_index=True) selected = st.selectbox("Distribución de", numeric_cols) c1, c2 = st.columns(2) with c1: fig = px.histogram(plot_df, x=selected, nbins=40, title=f"Histograma de {selected}") st.plotly_chart(fig, width="stretch") with c2: fig = px.box(plot_df, y=selected, title=f"Caja y bigotes de {selected}") st.plotly_chart(fig, width="stretch") # --- Categóricas ------------------------------------------------------------- with tabs[4]: cat_cols = profiler.categorical_columns(df) if not cat_cols: st.info("El dataset no tiene columnas categóricas.") else: st.dataframe(profiler.categorical_table(df), width="stretch", hide_index=True) selected = st.selectbox("Frecuencias de", cat_cols) counts = profiler.value_counts_for(df, selected) fig = px.bar( counts, x="frecuencia", y="valor", orientation="h", title=f"Valores más frecuentes de {selected}", ) fig.update_layout(yaxis={"categoryorder": "total ascending"}) st.plotly_chart(fig, width="stretch") # --- Correlaciones ----------------------------------------------------------- with tabs[5]: corr = profiler.correlation_matrix(df) if corr.empty: st.info("Hacen falta al menos 2 columnas numéricas para correlacionar.") else: fig = px.imshow( corr, text_auto=True, color_continuous_scale="RdBu_r", zmin=-1, zmax=1, title="Matriz de correlación (Pearson)", aspect="auto", ) st.plotly_chart(fig, width="stretch") st.markdown("**Pares más correlacionados:**") st.dataframe(profiler.top_correlations(corr), width="stretch", hide_index=True) # --- Objetivo ---------------------------------------------------------------- with tabs[6]: if not target: st.info("Elige una columna objetivo en la barra lateral para activar este análisis.") else: kind = profiler.target_kind(df, target) if kind == "categorico": balance = profiler.class_balance(df, target) c1, c2 = st.columns([1, 1]) with c1: fig = px.pie(balance, names="clase", values="n", title=f"Balance de clases de {target}") st.plotly_chart(fig, width="stretch") with c2: st.dataframe(balance, width="stretch", hide_index=True) numeric_cols = [c for c in profiler.numeric_columns(df) if c != target] if numeric_cols: sel = st.selectbox("Distribución por clase de", numeric_cols) fig = px.box(plot_df, x=target, y=sel, title=f"{sel} según {target}") st.plotly_chart(fig, width="stretch") else: fig = px.histogram(plot_df, x=target, nbins=40, title=f"Distribución del objetivo {target}") st.plotly_chart(fig, width="stretch") corr_target = profiler.correlations_with_target(df, target) if not corr_target.empty: st.markdown("**Correlación de cada variable numérica con el objetivo:**") st.dataframe(corr_target, width="stretch", hide_index=True) # --- Conclusiones LLM ---------------------------------------------------------- with tabs[7]: if not llm_on: st.info( "Activa 'Conclusiones con LLM' en la barra lateral (requiere GROQ_API_KEY). " "Solo se envían estadísticas agregadas al LLM, nunca tus datos." ) else: if st.button("Generar conclusiones", type="primary"): with st.spinner("El LLM está leyendo las estadísticas..."): try: summary = profiler.compact_summary(df, all_alerts) st.session_state["insights"] = insights_mod.generate_insights(summary) except RuntimeError as e: st.error(str(e)) if "insights" in st.session_state: st.markdown(st.session_state["insights"]) # --- Informe ------------------------------------------------------------------- with tabs[8]: st.markdown( "Informe completo en Markdown: visión general, alertas con recomendaciones " "y todas las tablas del análisis. Ábrelo en cualquier editor o conviértelo a PDF." ) insights_text = st.session_state.get("insights") if llm_on else None report_md = report.build_report(df, dataset_name, all_alerts, target, insights_text) st.download_button( "Descargar informe (.md)", report_md, file_name=f"informe_{dataset_name.rsplit('.', 1)[0]}.md", mime="text/markdown", ) with st.expander("Vista previa del informe"): st.markdown(report_md)