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
@@ -0,0 +1,162 @@
"""Tests de estacionariedad de una serie temporal: ADF + KPSS (grupo eda).
Funcion pura y determinista que combina dos contrastes de estacionariedad con
hipotesis nulas opuestas y emite un veredicto de consenso. Motivada por la
necesidad (Hyndman "Forecasting", Hamilton "Time Series Analysis") de saber si
una serie es estacionaria ANTES de correlacionarla o modelarla: correlacionar
niveles no estacionarios produce correlacion espuria (Granger-Newbold 1974).
"""
from __future__ import annotations
import math
import warnings
from statsmodels.tsa.stattools import adfuller, kpss
def _clean(values: list) -> list[float]:
"""Conserva solo valores numericos finitos, descartando None/NaN/no-numericos.
Los booleanos se excluyen explicitamente: en Python ``bool`` es subclase de
``int``, pero tratar True/False como numeros en una serie temporal es casi
siempre un error de tipado.
"""
out: list[float] = []
for v in values:
if v is None or isinstance(v, bool):
continue
if not isinstance(v, (int, float)):
continue
x = float(v)
if math.isnan(x) or math.isinf(x):
continue
out.append(x)
return out
def adf_kpss_stationarity(values: list, alpha: float = 0.05) -> dict:
"""Evalua la estacionariedad de una serie combinando ADF y KPSS.
Aplica dos contrastes con hipotesis nulas opuestas:
- **ADF** (Augmented Dickey-Fuller): H0 = "la serie tiene raiz unitaria"
(es NO estacionaria). Si ``p < alpha`` se rechaza H0 -> evidencia de
estacionariedad.
- **KPSS** (Kwiatkowski-Phillips-Schmidt-Shin): H0 = "la serie es
estacionaria (en torno a una tendencia)". Si ``p < alpha`` se rechaza H0
-> evidencia de NO estacionariedad.
Combinar ambos da mas robustez que cualquiera por separado, porque sus
hipotesis nulas son contrarias. El veredicto de consenso sigue la
interpretacion estandar (Hyndman, "Forecasting: Principles and Practice"):
- ADF rechaza H0 **y** KPSS no rechaza H0 -> ``"stationary"``.
- ADF no rechaza H0 **y** KPSS rechaza H0 -> ``"non_stationary"``.
- Ambos coinciden en lo contrario o se contradicen -> ``"inconclusive"``
(a menudo indica serie diferenciable o estacionaria en tendencia).
Funcion pura y determinista: no hace I/O, no muta los inputs.
Args:
values: serie temporal de valores numericos en orden cronologico.
None/NaN/infinitos/no-numericos se descartan antes del test.
alpha: nivel de significancia para ambos contrastes (default 0.05).
Returns:
Con menos de 8 puntos validos (muestra insuficiente para un test de
raiz unitaria fiable) devuelve
``{"n": n, "note": "datos insuficientes", "verdict": None}``.
En otro caso un dict con::
{
"n": int,
"alpha": float,
"adf": {"stat": float, "p_value": float, "lags": int,
"stationary": bool, # rechaza H0 de raiz unitaria
"conclusion": str},
"kpss": {"stat": float, "p_value": float, "lags": int,
"stationary": bool, # NO rechaza H0 de estacionariedad
"conclusion": str,
"p_value_clipped": bool}, # p en limite de tabla KPSS
"verdict": "stationary" | "non_stationary" | "inconclusive",
"warning": str | None, # aviso de correlacion espuria si procede
}
``warning`` se rellena cuando el veredicto NO es ``"stationary"`` para
recordar que correlacionar/regresionar niveles no estacionarios produce
relaciones espurias; conviene pasar a retornos o diferencias.
"""
clean = _clean(values)
n = len(clean)
if n < 8:
return {"n": n, "note": "datos insuficientes", "verdict": None}
# ADF: H0 = raiz unitaria (no estacionaria). p < alpha => estacionaria.
adf_stat, adf_p, adf_lags, _adf_nobs, _adf_crit, _adf_icbest = adfuller(
clean, autolag="AIC"
)
adf_stationary = bool(adf_p < alpha)
adf = {
"stat": float(adf_stat),
"p_value": float(adf_p),
"lags": int(adf_lags),
"stationary": adf_stationary,
"conclusion": (
"rechaza H0 de raiz unitaria: evidencia de estacionariedad"
if adf_stationary
else "no rechaza H0 de raiz unitaria: posible no estacionaria"
),
}
# KPSS: H0 = estacionaria en torno a tendencia. p < alpha => NO estacionaria.
# statsmodels emite InterpolationWarning cuando el p-valor cae fuera de la
# tabla [0.01, 0.10]; lo capturamos para saber si quedo recortado.
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
kpss_stat, kpss_p, kpss_lags, _kpss_crit = kpss(
clean, regression="c", nlags="auto"
)
p_clipped = any("InterpolationWarning" in str(w.category) for w in caught) or any(
"p-value" in str(w.message).lower() for w in caught
)
kpss_stationary = bool(kpss_p >= alpha) # NO rechaza H0 => estacionaria
kpss_result = {
"stat": float(kpss_stat),
"p_value": float(kpss_p),
"lags": int(kpss_lags),
"stationary": kpss_stationary,
"conclusion": (
"no rechaza H0 de estacionariedad: evidencia de estacionariedad"
if kpss_stationary
else "rechaza H0 de estacionariedad: posible no estacionaria"
),
"p_value_clipped": bool(p_clipped),
}
# Consenso de los dos contrastes.
if adf_stationary and kpss_stationary:
verdict = "stationary"
elif (not adf_stationary) and (not kpss_stationary):
verdict = "non_stationary"
else:
verdict = "inconclusive"
warning: str | None = None
if verdict != "stationary":
warning = (
"serie no claramente estacionaria: correlacionar o regresionar sus "
"niveles puede dar relaciones espurias (Granger-Newbold). Considera "
"trabajar sobre retornos o diferencias (ver to_returns)."
)
return {
"n": n,
"alpha": float(alpha),
"adf": adf,
"kpss": kpss_result,
"verdict": verdict,
"warning": warning,
}