File size: 11,433 Bytes
b8d091a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
"""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)