"""Genera las advertencias que recuerdan que un EDA es EXPLORATORIO, no confirmatorio. Funcion pura y determinista: dict (TableProfile del grupo ``eda``) entra, dict con una lista de caveats sale. No hace I/O, no muta el input, no lanza excepciones. Doctrina (Tukey, *EDA* 1977; Aronson; López de Prado 2018): el análisis exploratorio sirve para GENERAR hipótesis, no para confirmarlas. Lo que se ve mirando todo el dataset a la vez —correlaciones, clusters, "significancias", outliers— es un punto de partida, no una conclusión: hay que validarlo fuera de muestra con un análisis dirigido. Esta función inspecciona qué contiene el perfil y devuelve solo las advertencias que aplican a lo que realmente se ha calculado (si hay correlaciones → caveat de causalidad; si hay modelos → caveat de overfitting; etc.), además de una advertencia general que siempre acompaña a un EDA. """ from __future__ import annotations # Umbrales para disparar caveats dependientes de magnitud. _SMALL_SAMPLE_ROWS = 30 # n_rows por debajo de esto -> baja potencia. _HIGH_MISSING_FRACTION = 0.2 # null_cell_pct (fracción) por encima -> sesgo MNAR. def _to_float(v): """Parsea a float; None si es None/bool/no parseable (NaN incluido).""" if v is None or isinstance(v, bool): return None try: f = float(v) except (TypeError, ValueError): return None if f != f: # NaN return None return f def _correlation_pairs(profile: dict) -> list: """Extrae la lista de pares de correlación del perfil, tolerando varios shapes. ``correlations`` puede ser una lista de pares o un dict con ``pairs`` / ``strongest``. Devuelve siempre una lista (vacía si no hay nada usable). """ correlations = profile.get("correlations") if not correlations: return [] if isinstance(correlations, dict): pairs = correlations.get("pairs") or correlations.get("strongest") or [] else: pairs = correlations return list(pairs) if isinstance(pairs, (list, tuple)) else [] def _has_models(profile: dict) -> bool: """True si el perfil contiene un bloque de modelos multivariantes ajustados.""" models = profile.get("models") if not isinstance(models, dict): return False return any(models.get(k) for k in ("pca", "kmeans", "outliers")) def _has_pvalues(profile: dict) -> bool: """True si el perfil contiene p-values (tests de normalidad o de tendencia).""" models = profile.get("models") if isinstance(models, dict) and models.get("normality"): return True # Tests de tendencia adjuntados por columna (trend_slope) también traen p-value. for col in profile.get("columns") or []: if isinstance(col, dict) and isinstance(col.get("trend"), dict): if col["trend"].get("p_value") is not None: return True return False def _has_outliers(profile: dict) -> bool: """True si se han detectado outliers (multivariantes o por columna numérica).""" models = profile.get("models") if isinstance(models, dict) and models.get("outliers"): return True for col in profile.get("columns") or []: if not isinstance(col, dict): continue num = col.get("numeric") if isinstance(num, dict): n_out = _to_float(num.get("n_outliers")) opct = _to_float(num.get("outlier_pct")) if (n_out is not None and n_out > 0) or (opct is not None and opct > 0): return True return False def exploratory_caveats(profile: dict) -> dict: """Devuelve las advertencias de que el EDA es exploratorio según lo que contiene. Inspecciona un TableProfile (dict del grupo ``eda``) y arma la lista de caveats relevantes. Una advertencia general (la naturaleza exploratoria del EDA) se incluye SIEMPRE; el resto solo se añaden cuando el perfil contiene aquello a lo que aplican: - correlaciones presentes -> correlación ≠ causalidad. - modelos / correlaciones -> riesgo de overfitting in-sample (validar OOS). - p-values (normalidad/tendencia) -> no son confirmación sin corregir / IID. - ≥2 pares de correlación -> comparaciones múltiples (falsos positivos). - outliers detectados -> no implican errores. - n_rows pequeño -> baja potencia, estimaciones inestables. - muchos faltantes -> posible sesgo si no son aleatorios (MNAR). Es pura, determinista y no lanza excepciones. Un perfil vacío o ``None`` devuelve solo el caveat general con una nota. Args: profile: TableProfile dict del grupo ``eda``. Se lee todo defensivamente con ``.get(...)`` porque casi cualquier fase puede faltar. Returns: dict con: - ``n``: número de caveats devueltos (int). - ``caveats``: lista de dicts ``{"id", "topic", "message", "reference"}``, empezando por el general ``exploratory_nature``. - ``note``: cadena vacía en el caso normal; mensaje cuando el perfil está vacío y solo se devuelve la advertencia general. """ if not isinstance(profile, dict): profile = {} caveats: list = [] # Caveat general: SIEMPRE presente. El EDA genera hipótesis, no conclusiones. caveats.append({ "id": "exploratory_nature", "topic": "naturaleza exploratoria", "message": ( "El EDA genera HIPÓTESIS, no conclusiones. Cada patrón que veas aquí es un " "punto de partida para confirmarlo con un análisis dirigido sobre datos " "nuevos, no una verdad ya establecida." ), "reference": "Tukey (1977), Exploratory Data Analysis; Aronson", }) if not profile: return { "n": len(caveats), "caveats": caveats, "note": "perfil vacío: solo se devuelve la advertencia general", } corr_pairs = _correlation_pairs(profile) has_corr = len(corr_pairs) > 0 has_models = _has_models(profile) # Correlación ≠ causalidad. if has_corr: caveats.append({ "id": "correlation_not_causation", "topic": "correlación vs causalidad", "message": ( "Las correlaciones son asociaciones, no relaciones causales. Una " "correlación fuerte puede venir de una variable de confusión o del " "azar; valídala out-of-sample o con un diseño experimental antes de " "actuar sobre ella." ), "reference": "Tukey (1977), EDA", }) # Overfitting in-sample: cualquier patrón ajustado sobre todo el dataset. if has_models or has_corr: caveats.append({ "id": "in_sample_overfitting", "topic": "overfitting in-sample", "message": ( "Los patrones (modelos, clusters, correlaciones) se han extraído sobre " "TODO el dataset. Lo aprendido in-sample puede no replicar fuera de " "muestra (overfitting / selección por backtest). Valida con holdout o " "walk-forward antes de confiar en ellos." ), "reference": "López de Prado (2018), Advances in Financial Machine Learning", }) # p-values: no son confirmación sin corregir multiplicidad / sobre datos no-IID. if _has_pvalues(profile): caveats.append({ "id": "p_values_not_confirmation", "topic": "p-values", "message": ( "Los p-values sin corregir por comparaciones múltiples, o calculados " "sobre datos no-IID (series temporales, datos agrupados), no son " "confirmación. Trata cualquier 'significancia' vista en exploración " "como provisional." ), "reference": "Tukey (1977), EDA", }) # Comparaciones múltiples: cuantos más pares/columnas miras, más falsos positivos. if len(corr_pairs) >= 2: caveats.append({ "id": "multiple_comparisons", "topic": "comparaciones múltiples", "message": ( "Al examinar muchos pares/columnas a la vez, algunos parecerán " "'significativos' solo por azar (problema de comparaciones múltiples). " "Cuantas más combinaciones miras, más falsos positivos esperas." ), "reference": "López de Prado (2018), AFML", }) # Outliers detectados no implican errores. if _has_outliers(profile): caveats.append({ "id": "outliers_not_errors", "topic": "outliers", "message": ( "Los outliers detectados son puntos estadísticamente atípicos, NO " "necesariamente errores. Pueden ser el dato más interesante (fraude, " "evento raro). Investígalos antes de eliminarlos." ), "reference": "Tukey (1977), EDA", }) # Muestra pequeña: baja potencia, estimaciones inestables. n_rows = _to_float(profile.get("n_rows")) if n_rows is not None and n_rows < _SMALL_SAMPLE_ROWS: caveats.append({ "id": "small_sample", "topic": "muestra pequeña", "message": ( f"Pocas filas (n={int(n_rows)}): la potencia estadística es baja y las " "estimaciones (media, correlación, forma de la distribución) son " "inestables. Los patrones pueden cambiar con más datos." ), "reference": "Tukey (1977), EDA", }) # Datos faltantes: posible sesgo si no son aleatorios (MNAR). null_frac = _to_float(profile.get("null_cell_pct")) all_null_cols = profile.get("all_null_cols") or [] if (null_frac is not None and null_frac > _HIGH_MISSING_FRACTION) or all_null_cols: caveats.append({ "id": "missing_data_bias", "topic": "datos faltantes", "message": ( "Hay un volumen notable de datos faltantes. Si los ausentes no son " "aleatorios (MNAR), los estadísticos calculados sobre lo presente " "están sesgados; no extrapoles sin entender por qué faltan." ), "reference": "Tukey (1977), EDA", }) return {"n": len(caveats), "caveats": caveats, "note": ""}