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>
This commit is contained in:
Egutierrez
2026-06-29 03:34:01 +02:00
parent 02301aaed3
commit 7ac69ab4fb
33 changed files with 3995 additions and 51 deletions
@@ -9,6 +9,9 @@ metodos. Compone las funciones atomicas del registry; no reimplementa metricas.
"""
import math
from collections import Counter, defaultdict
from scipy.stats import chi2_contingency, f_oneway, pearsonr, spearmanr
from datascience import (
correlation_ratio,
@@ -19,6 +22,10 @@ from datascience import (
theils_u,
)
# Modulo hoja directo: no depende de que el paquete reexporte la funcion en su
# __init__ (lo integra el orquestador al cerrar el grupo eda).
from datascience.fdr_correction import fdr_correction
# Tipos que, para efectos de asociacion, se tratan como categoricos.
_CATEGORICAL_LIKE = {"categorical", "datetime", "boolean", "text"}
@@ -59,10 +66,83 @@ def _clean_numeric_pairs(xs: list, ys: list) -> tuple[list, list]:
return cx, cy
def _safe_pvalue(value) -> float | None:
"""Convierte un p-valor de scipy a float, devolviendo None si es NaN/invalido."""
if value is None:
return None
try:
pv = float(value)
except (TypeError, ValueError):
return None
if math.isnan(pv) or math.isinf(pv):
return None
return pv
def _pearson_pvalue(cx: list, cy: list) -> float | None:
"""p-valor del test de correlacion de Pearson (H0: r == 0). None si degenerado."""
if len(cx) < 3 or len(set(cx)) < 2 or len(set(cy)) < 2:
return None
try:
return _safe_pvalue(pearsonr(cx, cy).pvalue)
except Exception:
return None
def _spearman_pvalue(cx: list, cy: list) -> float | None:
"""p-valor del test de correlacion de Spearman (H0: rho == 0). None si degenerado."""
if len(cx) < 3 or len(set(cx)) < 2 or len(set(cy)) < 2:
return None
try:
return _safe_pvalue(spearmanr(cx, cy).pvalue)
except Exception:
return None
def _chi2_pvalue(a_vals: list, b_vals: list) -> float | None:
"""p-valor del test chi-cuadrado de independencia (cat-cat). None si degenerado."""
pairs = [(x, y) for x, y in zip(a_vals, b_vals) if x is not None and y is not None]
if len(pairs) < 2:
return None
rows = sorted({x for x, _ in pairs}, key=repr)
cols = sorted({y for _, y in pairs}, key=repr)
if len(rows) < 2 or len(cols) < 2:
return None
row_idx = {v: i for i, v in enumerate(rows)}
col_idx = {v: j for j, v in enumerate(cols)}
counts = Counter((row_idx[x], col_idx[y]) for x, y in pairs)
table = [
[counts.get((i, j), 0) for j in range(len(cols))]
for i in range(len(rows))
]
try:
return _safe_pvalue(chi2_contingency(table).pvalue)
except Exception:
return None
def _anova_pvalue(cat_vals: list, num_vals: list) -> float | None:
"""p-valor del ANOVA de una via (H0: misma media numerica por categoria). None si degenerado."""
groups: dict = defaultdict(list)
for c, x in zip(cat_vals, num_vals):
if c is None or not _is_num(x):
continue
groups[c].append(float(x))
valid = [g for g in groups.values() if len(g) >= 2]
if len(valid) < 2:
return None
try:
return _safe_pvalue(f_oneway(*valid).pvalue)
except Exception:
return None
def association_matrix(
columns: dict,
strong_threshold: float = 0.5,
top_n: int = 20,
alpha: float = 0.05,
fdr_method: str = "bh",
) -> dict:
"""Construye la matriz de asociacion de una tabla con tipos mezclados.
@@ -81,22 +161,48 @@ def association_matrix(
asociacion util). Es una funcion pura: no falla con dict vacio o una sola
columna (devuelve `pairs=[]`, `strong=[]`).
Ademas de la magnitud de la asociacion, cada par evaluado lleva un p-valor
del test de hipotesis adecuado a su metodo (Pearson/Spearman: test de
correlacion; Cramer's V: chi-cuadrado de independencia; correlation ratio:
ANOVA de una via; informacion mutua: sin test, p-valor None). Como se evaluan
todos los pares a la vez, esos p-valores se corrigen por comparaciones
multiples con `fdr_correction` (data-mining bias, Aronson cap. 6) y el
subconjunto `strong` se basa en la **significancia corregida**, no solo en
superar el umbral de magnitud: un par con magnitud alta pero p-valor ajustado
> alpha NO entra en `strong`.
Args:
columns: dict {nombre_columna: {"values": list, "type": str}} donde type
es uno de "numeric", "categorical", "datetime", "boolean", "text".
Los tipos datetime/boolean/text se tratan como categoricos.
strong_threshold: umbral en [0, 1]. Un par es "fuerte" si
abs(value) >= umbral o extra["mi"] >= umbral.
strong_threshold: umbral en [0, 1]. Condicion de magnitud para ser
"fuerte": abs(value) >= umbral o extra["mi"] >= umbral. Necesaria pero
ya no suficiente (ver alpha).
top_n: numero maximo de pares fuertes a devolver, ordenados por
relevancia (max(abs(value), mi)) descendente.
alpha: nivel de significancia tras la correccion FDR (default 0.05). Un
par con p-valor disponible solo es fuerte si ademas su p-valor
ajustado <= alpha.
fdr_method: metodo de correccion de comparaciones multiples,
"bh" (Benjamini-Hochberg, FDR; default) o "bonferroni" (FWER).
Returns:
dict con claves:
pairs: lista de todos los pares evaluados, cada uno
{a, b, a_type, b_type, method, value, extra}.
strong: subconjunto de pairs por encima del umbral, ordenado por
relevancia descendente y truncado a top_n.
{a, b, a_type, b_type, method, value, extra, p_value,
p_value_adjusted, significant}. `p_value` es el del test del
metodo principal (None si no aplica / degenerado);
`p_value_adjusted` el p-valor tras FDR; `significant` True si
p_value_adjusted <= alpha.
strong: subconjunto de pairs que cumplen magnitud >= umbral Y son
significativos tras la correccion (los pares sin test disponible
se admiten por magnitud), ordenado por relevancia descendente y
truncado a top_n.
methods_legend: dict {metodo: descripcion}.
n_tests: numero total de pares evaluados (== len(pairs)).
multiple_testing: dict {method, alpha, n_tests, n_rejected} con el
resumen de la correccion (n_tests aqui = p-valores validos
corregidos, puede ser < len(pairs) si algun par no tiene test).
"""
legend = {
"pearson": "num-num lineal (Pearson r), signo indica direccion, [-1, 1]",
@@ -168,20 +274,32 @@ def association_matrix(
s = spearman_corr(a_vals, b_vals)
extra["pearson"] = p
extra["spearman"] = s
value = p if abs(p) >= abs(s) else s
pearson_p = _pearson_pvalue(cx, cy)
spearman_p = _spearman_pvalue(cx, cy)
extra["pearson_p"] = pearson_p
extra["spearman_p"] = spearman_p
if abs(p) >= abs(s):
value = p
p_value = pearson_p
else:
value = s
p_value = spearman_p
elif (not a_numeric) and (not b_numeric):
method = "cramers_v"
value = cramers_v(a_vals, b_vals)
extra["u_ab"] = theils_u(a_vals, b_vals)
extra["u_ba"] = theils_u(b_vals, a_vals)
p_value = _chi2_pvalue(a_vals, b_vals)
else:
method = "correlation_ratio"
if a_numeric:
# a numerica, b categorica.
value = correlation_ratio(b_vals, a_vals)
p_value = _anova_pvalue(b_vals, a_vals)
else:
# a categorica, b numerica.
value = correlation_ratio(a_vals, b_vals)
p_value = _anova_pvalue(a_vals, b_vals)
pairs.append(
{
@@ -192,19 +310,55 @@ def association_matrix(
"method": method,
"value": value,
"extra": extra,
"p_value": p_value,
}
)
# Correccion de comparaciones multiples sobre los p-valores disponibles.
# Se pasa la lista completa (incluidos los None de pares sin test): la
# correccion devuelve un mapeo alineado 1:1 y los None no cuentan como prueba.
fdr = fdr_correction(
[pair["p_value"] for pair in pairs],
alpha=alpha,
method=fdr_method,
)
for pair, padj, rej in zip(
pairs, fdr["p_values_adjusted"], fdr["reject"]
):
pair["p_value_adjusted"] = padj
pair["significant"] = bool(rej)
def _relevance(pair: dict) -> float:
return max(abs(pair["value"]), pair["extra"].get("mi", 0.0))
strong = [
pair
for pair in pairs
if abs(pair["value"]) >= strong_threshold
or pair["extra"].get("mi", 0.0) >= strong_threshold
]
def _is_strong(pair: dict) -> bool:
# Condicion 1: magnitud por encima del umbral (necesaria).
magnitude_ok = (
abs(pair["value"]) >= strong_threshold
or pair["extra"].get("mi", 0.0) >= strong_threshold
)
if not magnitude_ok:
return False
# Condicion 2: significancia tras la correccion FDR. Los pares sin test
# disponible (p_value None, p.ej. informacion mutua o caso degenerado) se
# admiten por magnitud, ya que no hay p-valor que corregir.
if pair["p_value"] is None:
return True
return pair["significant"]
strong = [pair for pair in pairs if _is_strong(pair)]
strong.sort(key=_relevance, reverse=True)
strong = strong[:top_n]
return {"pairs": pairs, "strong": strong, "methods_legend": legend}
return {
"pairs": pairs,
"strong": strong,
"methods_legend": legend,
"n_tests": len(pairs),
"multiple_testing": {
"method": fdr_method,
"alpha": alpha,
"n_tests": fdr["n_tests"],
"n_rejected": fdr["n_rejected"],
},
}