eda-express / eda /profiler.py
aitziberluis's picture
Upload 18 files
b8d091a verified
Raw
History Blame Contribute Delete
10.1 kB
"""Cálculo del perfil exploratorio de un DataFrame.
Este módulo no sabe nada de Streamlit: recibe DataFrames y devuelve
estructuras simples (dicts y DataFrames). Eso permite probarlo sin interfaz
y reutilizarlo desde un notebook o un script.
"""
from __future__ import annotations
import io
import numpy as np
import pandas as pd
# Por encima de este número de filas, los gráficos usan una muestra para
# mantener la interfaz fluida (las estadísticas se calculan siempre con todo).
SAMPLE_THRESHOLD = 50_000
# ---------------------------------------------------------------------------
# Carga
# ---------------------------------------------------------------------------
def load_dataframe(data: bytes, filename: str) -> pd.DataFrame:
"""Carga CSV (detectando el separador) o Excel a partir de bytes."""
name = filename.lower()
if name.endswith((".xlsx", ".xls")):
return pd.read_excel(io.BytesIO(data))
# sep=None + engine="python" detecta ; , \t automáticamente — habitual
# en CSVs europeos exportados de Excel.
return pd.read_csv(io.BytesIO(data), sep=None, engine="python")
def numeric_columns(df: pd.DataFrame) -> list[str]:
return df.select_dtypes(include="number").columns.tolist()
def categorical_columns(df: pd.DataFrame) -> list[str]:
"""Todo lo que no es numérico ni fecha se trata como categórico.
Detectar por exclusión es deliberado: pandas 3 cambió el dtype de texto
de 'object' a 'str' (backend Arrow) y una lista blanca de dtypes se
quedaría corta según la versión instalada.
"""
numeric = set(numeric_columns(df))
return [
col
for col in df.columns
if col not in numeric and not pd.api.types.is_datetime64_any_dtype(df[col])
]
# ---------------------------------------------------------------------------
# Visión general
# ---------------------------------------------------------------------------
def overview(df: pd.DataFrame) -> dict:
return {
"filas": int(len(df)),
"columnas": int(df.shape[1]),
"celdas": int(df.size),
"memoria_mb": round(df.memory_usage(deep=True).sum() / 1024**2, 2),
"numericas": len(numeric_columns(df)),
"categoricas": len(categorical_columns(df)),
"otras": df.shape[1] - len(numeric_columns(df)) - len(categorical_columns(df)),
"faltantes_total": int(df.isna().sum().sum()),
"faltantes_pct": round(100 * df.isna().sum().sum() / max(df.size, 1), 2),
"filas_duplicadas": int(df.duplicated().sum()),
"duplicadas_pct": round(100 * df.duplicated().sum() / max(len(df), 1), 2),
}
def column_table(df: pd.DataFrame) -> pd.DataFrame:
"""Tabla resumen por columna: tipo, faltantes, únicos y valores de ejemplo."""
rows = []
for col in df.columns:
series = df[col]
sample = series.dropna().unique()[:3]
rows.append(
{
"columna": col,
"tipo": str(series.dtype),
"no_nulos": int(series.notna().sum()),
"faltantes_%": round(100 * series.isna().mean(), 1),
"unicos": int(series.nunique()),
"ejemplos": ", ".join(str(v)[:25] for v in sample),
}
)
return pd.DataFrame(rows)
# ---------------------------------------------------------------------------
# Faltantes
# ---------------------------------------------------------------------------
def missing_table(df: pd.DataFrame) -> pd.DataFrame:
missing = df.isna().sum()
missing = missing[missing > 0].sort_values(ascending=False)
return pd.DataFrame(
{
"columna": missing.index,
"faltantes": missing.values,
"porcentaje": (100 * missing / len(df)).round(1).values,
}
)
# ---------------------------------------------------------------------------
# Numéricas
# ---------------------------------------------------------------------------
def iqr_outlier_bounds(series: pd.Series) -> tuple[float, float]:
q1, q3 = series.quantile(0.25), series.quantile(0.75)
iqr = q3 - q1
return q1 - 1.5 * iqr, q3 + 1.5 * iqr
def numeric_table(df: pd.DataFrame) -> pd.DataFrame:
"""describe() ampliado con asimetría, curtosis y outliers por IQR."""
rows = []
for col in numeric_columns(df):
series = df[col].dropna()
if series.empty:
continue
low, high = iqr_outlier_bounds(series)
n_outliers = int(((series < low) | (series > high)).sum())
rows.append(
{
"columna": col,
"media": round(float(series.mean()), 3),
"mediana": round(float(series.median()), 3),
"desv_tipica": round(float(series.std()), 3) if len(series) > 1 else 0.0,
"minimo": round(float(series.min()), 3),
"maximo": round(float(series.max()), 3),
"asimetria": round(float(series.skew()), 2) if len(series) > 2 else 0.0,
"curtosis": round(float(series.kurtosis()), 2) if len(series) > 3 else 0.0,
"outliers_iqr": n_outliers,
"outliers_%": round(100 * n_outliers / len(series), 1),
}
)
return pd.DataFrame(rows)
# ---------------------------------------------------------------------------
# Categóricas
# ---------------------------------------------------------------------------
def categorical_table(df: pd.DataFrame) -> pd.DataFrame:
rows = []
for col in categorical_columns(df):
series = df[col].dropna().astype(str)
if series.empty:
continue
counts = series.value_counts()
rows.append(
{
"columna": col,
"unicos": int(series.nunique()),
"moda": str(counts.index[0])[:40],
"frecuencia_moda_%": round(100 * counts.iloc[0] / len(series), 1),
"ratio_cardinalidad": round(series.nunique() / len(series), 3),
}
)
return pd.DataFrame(rows)
def value_counts_for(df: pd.DataFrame, col: str, top: int = 15) -> pd.DataFrame:
counts = df[col].astype(str).value_counts(dropna=False).head(top)
return pd.DataFrame({"valor": counts.index, "frecuencia": counts.values})
# ---------------------------------------------------------------------------
# Correlaciones
# ---------------------------------------------------------------------------
def correlation_matrix(df: pd.DataFrame, method: str = "pearson") -> pd.DataFrame:
numeric = df[numeric_columns(df)]
if numeric.shape[1] < 2:
return pd.DataFrame()
return numeric.corr(method=method).round(3)
def top_correlations(corr: pd.DataFrame, n: int = 10) -> pd.DataFrame:
"""Pares de variables más correlacionados (en valor absoluto)."""
if corr.empty:
return pd.DataFrame()
pairs = []
cols = corr.columns
for i in range(len(cols)):
for j in range(i + 1, len(cols)):
value = corr.iloc[i, j]
if pd.notna(value):
pairs.append({"variable_1": cols[i], "variable_2": cols[j], "correlacion": value})
pairs.sort(key=lambda p: abs(p["correlacion"]), reverse=True)
return pd.DataFrame(pairs[:n])
# ---------------------------------------------------------------------------
# Análisis respecto a una columna objetivo
# ---------------------------------------------------------------------------
def target_kind(df: pd.DataFrame, target: str) -> str:
"""'categorico' si el objetivo tiene pocas clases; 'numerico' si es continuo."""
if target in categorical_columns(df) or df[target].nunique() <= 10:
return "categorico"
return "numerico"
def class_balance(df: pd.DataFrame, target: str) -> pd.DataFrame:
counts = df[target].astype(str).value_counts(dropna=False)
return pd.DataFrame(
{
"clase": counts.index,
"n": counts.values,
"porcentaje": (100 * counts / counts.sum()).round(1).values,
}
)
def correlations_with_target(df: pd.DataFrame, target: str) -> pd.DataFrame:
"""Correlación de cada numérica con el objetivo (si el objetivo es numérico
o binario codificable)."""
numeric = df[numeric_columns(df)]
target_series = df[target]
if not pd.api.types.is_numeric_dtype(target_series):
# Objetivo no numérico: solo es codificable si es binario.
classes = target_series.dropna().unique()
if len(classes) != 2:
return pd.DataFrame()
target_series = (target_series == classes[0]).astype(float)
if target in numeric.columns:
numeric = numeric.drop(columns=[target])
if numeric.empty:
return pd.DataFrame()
corr = numeric.corrwith(target_series).dropna().sort_values(key=abs, ascending=False)
return pd.DataFrame({"variable": corr.index, "correlacion_con_objetivo": corr.round(3).values})
# ---------------------------------------------------------------------------
# Resumen compacto para el LLM (solo agregados, nunca filas de datos)
# ---------------------------------------------------------------------------
def compact_summary(df: pd.DataFrame, alerts: list[dict] | None = None) -> dict:
summary = {
"vision_general": overview(df),
"columnas": column_table(df).to_dict(orient="records"),
"numericas": numeric_table(df).to_dict(orient="records"),
"categoricas": categorical_table(df).to_dict(orient="records"),
"top_correlaciones": top_correlations(correlation_matrix(df), 8).to_dict(orient="records"),
}
if alerts:
summary["alertas"] = [
{"nivel": a["nivel"], "columna": a["columna"], "mensaje": a["mensaje"]}
for a in alerts
]
return summary
def sample_for_plots(df: pd.DataFrame) -> pd.DataFrame:
"""Muestra aleatoria reproducible para no ahogar al navegador con gráficos."""
if len(df) <= SAMPLE_THRESHOLD:
return df
return df.sample(SAMPLE_THRESHOLD, random_state=42)