eda-express / app.py
aitziberluis's picture
Upload 18 files
b8d091a verified
Raw
History Blame Contribute Delete
11.4 kB
"""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"<div style='border-left: 4px solid {color}; padding: 0.4em 0.8em; margin-bottom: 0.6em;'>"
f"<strong style='color:{color}'>{label}</strong> · <strong>{a['columna']}</strong><br>"
f"{a['mensaje']}<br><em>{a['recomendacion']}</em></div>",
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)