eda-express / eda /alerts.py
aitziberluis's picture
Upload 9 files
d7f258e verified
Raw
History Blame Contribute Delete
8.35 kB
"""Motor de alertas de calidad de datos.
Cada regla examina el DataFrame y emite alertas con tres niveles:
- critico: probablemente rompe cualquier análisis o modelo
- aviso: requiere una decisión consciente antes de seguir
- info: conviene saberlo, pero no bloquea
Cada alerta lleva una recomendación concreta, no solo el diagnóstico:
decir "columna X tiene 80% de faltantes" sin decir qué hacer con ella
es dejar el trabajo a medias.
"""
from __future__ import annotations
import pandas as pd
from .profiler import (
categorical_columns,
dates_stored_as_text,
duplicate_columns,
iqr_outlier_bounds,
numeric_columns,
numeric_stored_as_text,
)
MISSING_WARNING = 20.0 # % de faltantes que merece aviso
MISSING_CRITICAL = 60.0 # % de faltantes probablemente irrecuperable
HIGH_CARDINALITY = 0.5 # ratio únicos/filas sospechoso en categóricas
OUTLIER_WARNING = 5.0 # % de outliers IQR que merece revisión
SKEW_WARNING = 2.0 # |asimetría| a partir de la cual avisar
CORRELATION_WARNING = 0.95 # |r| que sugiere variables redundantes
IMBALANCE_WARNING = 10.0 # % de la clase minoritaria
def _alert(nivel: str, columna: str, mensaje: str, recomendacion: str) -> dict:
return {
"nivel": nivel,
"columna": columna,
"mensaje": mensaje,
"recomendacion": recomendacion,
}
def check_missing(df: pd.DataFrame) -> list[dict]:
alerts = []
for col in df.columns:
pct = 100 * df[col].isna().mean()
if pct >= MISSING_CRITICAL:
alerts.append(_alert(
"critico", col,
f"{pct:.0f}% de valores faltantes",
"Con más del 60% ausente, imputar suele inventar datos: valora descartar la columna o tratarla como indicador binario de presencia.",
))
elif pct >= MISSING_WARNING:
alerts.append(_alert(
"aviso", col,
f"{pct:.0f}% de valores faltantes",
"Decide una estrategia explícita: imputación (mediana/moda), categoría 'desconocido', o descarte justificado.",
))
return alerts
def check_constant(df: pd.DataFrame) -> list[dict]:
alerts = []
for col in df.columns:
if df[col].nunique(dropna=True) <= 1:
alerts.append(_alert(
"critico", col,
"Columna constante (un único valor)",
"No aporta información: elimínala antes de modelar.",
))
return alerts
def check_id_like(df: pd.DataFrame) -> list[dict]:
alerts = []
for col in df.columns:
n = len(df)
if n == 0:
continue
if df[col].nunique() == n and str(df[col].dtype) != "float64":
alerts.append(_alert(
"aviso", col,
"Todos los valores son únicos — parece un identificador",
"Los IDs no deben entrar a un modelo (memorizarlos es sobreajuste garantizado). Úsala solo como índice.",
))
return alerts
def check_high_cardinality(df: pd.DataFrame) -> list[dict]:
alerts = []
n = len(df)
for col in categorical_columns(df):
uniques = df[col].nunique()
if n > 0 and uniques > 50 and uniques / n >= HIGH_CARDINALITY:
alerts.append(_alert(
"aviso", col,
f"Cardinalidad muy alta ({uniques} valores distintos)",
"One-hot dispararía la dimensionalidad: considera agrupar categorías raras, hashing o target encoding.",
))
return alerts
def check_duplicates(df: pd.DataFrame) -> list[dict]:
dup = int(df.duplicated().sum())
if dup == 0:
return []
pct = 100 * dup / len(df)
nivel = "aviso" if pct >= 5 else "info"
return [_alert(
nivel, "(dataset)",
f"{dup} filas duplicadas ({pct:.1f}%)",
"Comprueba si son medidas repetidas legítimas o errores de captura; si son errores, elimínalas antes de todo lo demás.",
)]
def check_outliers(df: pd.DataFrame) -> list[dict]:
alerts = []
for col in numeric_columns(df):
series = df[col].dropna()
if len(series) < 10:
continue
low, high = iqr_outlier_bounds(series)
pct = 100 * ((series < low) | (series > high)).mean()
if pct >= OUTLIER_WARNING:
alerts.append(_alert(
"aviso", col,
f"{pct:.1f}% de outliers según IQR",
"Investiga si son errores de medida o valores reales extremos: la respuesta cambia el tratamiento (corregir vs. transformar/robustecer).",
))
return alerts
def check_skewness(df: pd.DataFrame) -> list[dict]:
alerts = []
for col in numeric_columns(df):
series = df[col].dropna()
if len(series) < 10:
continue
skew = float(series.skew())
if abs(skew) >= SKEW_WARNING:
alerts.append(_alert(
"info", col,
f"Distribución muy asimétrica (asimetría {skew:.1f})",
"Si vas a usar modelos lineales, una transformación log o Box-Cox puede ayudar; a los de árboles les da igual.",
))
return alerts
def check_redundant_pairs(df: pd.DataFrame) -> list[dict]:
numeric = df[numeric_columns(df)]
if numeric.shape[1] < 2:
return []
corr = numeric.corr()
alerts = []
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) and abs(value) >= CORRELATION_WARNING:
alerts.append(_alert(
"aviso", f"{cols[i]} ~ {cols[j]}",
f"Correlación {value:.2f}: prácticamente redundantes",
"Mantén una de las dos o combínalas; la multicolinealidad desestabiliza los modelos lineales y estorba en la interpretación.",
))
return alerts
def check_imbalance(df: pd.DataFrame, target: str | None) -> list[dict]:
if not target or target not in df.columns:
return []
counts = df[target].value_counts(dropna=True)
if not (2 <= len(counts) <= 20):
return []
minority_pct = 100 * counts.iloc[-1] / counts.sum()
if minority_pct < IMBALANCE_WARNING:
return [_alert(
"aviso", target,
f"Clases desbalanceadas: la minoritaria ('{counts.index[-1]}') es solo el {minority_pct:.1f}%",
"La accuracy engañará: usa F1/AUC-PR, estratifica los splits y valora sobremuestreo o pesos de clase.",
)]
return []
def check_numeric_as_text(df: pd.DataFrame) -> list[dict]:
return [
_alert(
"aviso", col,
"Parece numérica pero está guardada como texto",
"Conviértela con pd.to_numeric (ojo a decimales con coma): como texto no entra en correlaciones ni estadísticos.",
)
for col in numeric_stored_as_text(df)
]
def check_dates_as_text(df: pd.DataFrame) -> list[dict]:
return [
_alert(
"aviso", col,
"Parece una fecha pero está guardada como texto",
"Conviértela con pd.to_datetime para poder ordenar, agrupar por periodo y detectar huecos temporales.",
)
for col in dates_stored_as_text(df)
]
def check_duplicate_columns(df: pd.DataFrame) -> list[dict]:
return [
_alert(
"aviso", f"{a} = {b}",
"Dos columnas con contenido idéntico",
"Es la misma información dos veces: quédate con una y documenta el alias.",
)
for a, b in duplicate_columns(df)
]
def run_all_checks(df: pd.DataFrame, target: str | None = None) -> list[dict]:
"""Ejecuta todas las reglas y devuelve las alertas ordenadas por gravedad."""
alerts = (
check_constant(df)
+ check_missing(df)
+ check_id_like(df)
+ check_high_cardinality(df)
+ check_duplicates(df)
+ check_duplicate_columns(df)
+ check_numeric_as_text(df)
+ check_dates_as_text(df)
+ check_outliers(df)
+ check_skewness(df)
+ check_redundant_pairs(df)
+ check_imbalance(df, target)
)
order = {"critico": 0, "aviso": 1, "info": 2}
alerts.sort(key=lambda a: order[a["nivel"]])
return alerts