Files
fn_registry/python/functions/datascience/exploratory_caveats.py
T
Egutierrez 7ac69ab4fb feat(eda): series temporales + rigor anti-data-mining + PDF movil + /eda + benchmark issues
Bloque del grupo eda (sesion ausente EDA-benchmark):
- 8 funciones nuevas: adf_kpss_stationarity, acf_pacf, stl_decompose, to_returns,
  fdr_correction, suggest_reexpression, exploratory_caveats, render_eda_pdf
- integracion: profile_table (run_series, emit_pdf), association_matrix (FDR Benjamini-Hochberg),
  render_eda_markdown (secciones series/reexpresion/caveats)
- slash commands /eda y /capitulos
- issues 0173-0177: mejoras del /eda derivadas del benchmark sobre 12 datasets reales
  (outlier_pct x100, periodo estacional, FK inference, render models, tipos id-like)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 03:34:01 +02:00

247 lines
10 KiB
Python

"""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": ""}