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:
@@ -0,0 +1,195 @@
|
||||
"""Descomposicion STL de una serie temporal en tendencia/estacional/resto (grupo eda).
|
||||
|
||||
Funcion pura y determinista que aplica STL (Seasonal-Trend decomposition using
|
||||
Loess, Cleveland et al. 1990) via statsmodels y reporta las tres componentes mas
|
||||
las medidas de fuerza de tendencia y de estacionalidad de Hyndman ("Forecasting:
|
||||
Principles and Practice", seccion de feature extraction). Util en EDA para
|
||||
entender que parte de la variacion de una serie es tendencia, ciclo estacional o
|
||||
ruido antes de modelar o desestacionalizar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
from statsmodels.tsa.seasonal import STL
|
||||
|
||||
|
||||
def _clean(values: list) -> list[float]:
|
||||
"""Conserva solo valores numericos finitos, descartando None/NaN/no-numericos."""
|
||||
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 _infer_period(arr: np.ndarray, max_period: int) -> int | None:
|
||||
"""Infiere el periodo estacional dominante via autocorrelacion.
|
||||
|
||||
Busca el retardo (entre 2 y ``max_period``) que maximiza la autocorrelacion
|
||||
de la serie. Devuelve None si no encuentra un pico claro (autocorrelacion
|
||||
maxima por debajo de un umbral pequeno).
|
||||
"""
|
||||
n = len(arr)
|
||||
if n < 6:
|
||||
return None
|
||||
x = arr - arr.mean()
|
||||
denom = float(np.dot(x, x))
|
||||
if denom == 0.0:
|
||||
return None
|
||||
best_lag = None
|
||||
best_corr = 0.0
|
||||
upper = min(max_period, n // 2)
|
||||
for lag in range(2, upper + 1):
|
||||
corr = float(np.dot(x[:-lag], x[lag:]) / denom)
|
||||
if corr > best_corr:
|
||||
best_corr = corr
|
||||
best_lag = lag
|
||||
if best_lag is None or best_corr < 0.2:
|
||||
return None
|
||||
return best_lag
|
||||
|
||||
|
||||
def _summarize(component: list[float], max_inline: int = 200) -> dict:
|
||||
"""Resume una componente: la incluye entera si es corta, si no estadisticos."""
|
||||
arr = np.asarray(component, dtype=float)
|
||||
summary = {
|
||||
"min": float(arr.min()),
|
||||
"max": float(arr.max()),
|
||||
"mean": float(arr.mean()),
|
||||
"std": float(arr.std(ddof=0)),
|
||||
}
|
||||
if len(component) <= max_inline:
|
||||
summary["values"] = [float(v) for v in component]
|
||||
else:
|
||||
summary["values"] = None
|
||||
summary["note"] = f"serie larga ({len(component)} puntos): solo estadisticos"
|
||||
return summary
|
||||
|
||||
|
||||
def stl_decompose(values: list, period: int = None, robust: bool = True) -> dict:
|
||||
"""Descompone una serie temporal en tendencia, estacional y resto via STL.
|
||||
|
||||
Aplica STL (Seasonal-Trend decomposition using Loess) sobre ``values`` y
|
||||
devuelve las tres componentes (resumidas si la serie es larga) junto a la
|
||||
fuerza de tendencia y la fuerza estacional de Hyndman::
|
||||
|
||||
F_trend = max(0, 1 - Var(resto) / Var(resto + tendencia))
|
||||
F_seasonal = max(0, 1 - Var(resto) / Var(resto + estacional))
|
||||
|
||||
Ambas en ``[0, 1]``: cercano a 1 indica una componente fuerte y bien
|
||||
definida; cercano a 0 indica que esa componente apenas existe.
|
||||
|
||||
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 de descomponer.
|
||||
period: periodo estacional (numero de observaciones por ciclo, p.ej. 12
|
||||
para datos mensuales con estacionalidad anual). Si es ``None`` se
|
||||
intenta inferir por autocorrelacion; si no se halla un periodo
|
||||
claro, se devuelve una nota.
|
||||
robust: si ``True`` (default) usa el ajuste robusto de STL, que reduce el
|
||||
efecto de outliers sobre tendencia y estacionalidad.
|
||||
|
||||
Returns:
|
||||
Con menos de ``2 * period`` puntos (o <8 si no hay periodo) devuelve un
|
||||
dict con ``note`` explicando por que no se pudo descomponer y
|
||||
``trend_strength``/``seasonal_strength`` en ``None``.
|
||||
|
||||
En otro caso un dict con::
|
||||
|
||||
{
|
||||
"n": int,
|
||||
"period": int, # periodo usado (inferido o dado)
|
||||
"period_inferred": bool, # True si se infirio automaticamente
|
||||
"robust": bool,
|
||||
"trend": {min,max,mean,std, values|note},
|
||||
"seasonal": {...},
|
||||
"resid": {...},
|
||||
"trend_strength": float, # F_trend de Hyndman en [0,1]
|
||||
"seasonal_strength": float, # F_seasonal de Hyndman en [0,1]
|
||||
}
|
||||
"""
|
||||
clean = _clean(values)
|
||||
n = len(clean)
|
||||
|
||||
if n < 8:
|
||||
return {
|
||||
"n": n,
|
||||
"note": "datos insuficientes",
|
||||
"trend_strength": None,
|
||||
"seasonal_strength": None,
|
||||
}
|
||||
|
||||
arr = np.asarray(clean, dtype=float)
|
||||
|
||||
inferred = False
|
||||
if period is None:
|
||||
period = _infer_period(arr, max_period=max(2, n // 2))
|
||||
inferred = True
|
||||
if period is None:
|
||||
return {
|
||||
"n": n,
|
||||
"note": "no se pudo inferir un periodo estacional; pasa period explicito",
|
||||
"trend_strength": None,
|
||||
"seasonal_strength": None,
|
||||
}
|
||||
|
||||
period = int(period)
|
||||
if period < 2:
|
||||
return {
|
||||
"n": n,
|
||||
"note": "period debe ser >= 2",
|
||||
"trend_strength": None,
|
||||
"seasonal_strength": None,
|
||||
}
|
||||
|
||||
# STL exige al menos dos ciclos completos.
|
||||
if n < 2 * period:
|
||||
return {
|
||||
"n": n,
|
||||
"period": period,
|
||||
"note": f"serie corta: STL necesita >= 2*period ({2 * period}) puntos",
|
||||
"trend_strength": None,
|
||||
"seasonal_strength": None,
|
||||
}
|
||||
|
||||
result = STL(arr, period=period, robust=robust).fit()
|
||||
trend = np.asarray(result.trend, dtype=float)
|
||||
seasonal = np.asarray(result.seasonal, dtype=float)
|
||||
resid = np.asarray(result.resid, dtype=float)
|
||||
|
||||
# Fuerza de tendencia y estacional (Hyndman). Var con ddof=0.
|
||||
var_resid = float(np.var(resid, ddof=0))
|
||||
var_resid_trend = float(np.var(resid + trend, ddof=0))
|
||||
var_resid_seasonal = float(np.var(resid + seasonal, ddof=0))
|
||||
|
||||
trend_strength = (
|
||||
max(0.0, 1.0 - var_resid / var_resid_trend) if var_resid_trend > 0 else 0.0
|
||||
)
|
||||
seasonal_strength = (
|
||||
max(0.0, 1.0 - var_resid / var_resid_seasonal)
|
||||
if var_resid_seasonal > 0
|
||||
else 0.0
|
||||
)
|
||||
|
||||
return {
|
||||
"n": n,
|
||||
"period": period,
|
||||
"period_inferred": bool(inferred),
|
||||
"robust": bool(robust),
|
||||
"trend": _summarize(trend.tolist()),
|
||||
"seasonal": _summarize(seasonal.tolist()),
|
||||
"resid": _summarize(resid.tolist()),
|
||||
"trend_strength": float(trend_strength),
|
||||
"seasonal_strength": float(seasonal_strength),
|
||||
}
|
||||
Reference in New Issue
Block a user