Metadata-Version: 2.4
Name: forecast-arena
Version: 1.0.0
Summary: Time series models that fight each other. Inspired by Hyndman & Athanasopoulos, Forecasting: Principles and Practice (fpp3).
Author: Zurishadday
License: MIT
Project-URL: Homepage, https://github.com/Zurishadday/forecast-arena
Project-URL: Documentation, https://Zurishadday.github.io/forecast-arena
Project-URL: Changelog, https://github.com/Zurishadday/forecast-arena/blob/main/CHANGELOG.md
Keywords: forecasting,time-series,arima,ets,xgboost,fpp3,changepoint,hierarchical,prediction-intervals
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: Information Analysis
Classifier: Natural Language :: Spanish
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.23
Requires-Dist: pandas>=1.5
Requires-Dist: scipy>=1.9
Requires-Dist: statsmodels>=0.14
Requires-Dist: scikit-learn>=1.2
Requires-Dist: pmdarima>=2.0
Requires-Dist: joblib>=1.2
Provides-Extra: xgb
Requires-Dist: xgboost>=1.7; extra == "xgb"
Provides-Extra: plots
Requires-Dist: matplotlib>=3.7; extra == "plots"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: xgboost>=1.7; extra == "dev"
Requires-Dist: matplotlib>=3.7; extra == "dev"
Dynamic: license-file

# forecast-arena 🥊

[![tests](https://github.com/TU_USUARIO/forecast-arena/actions/workflows/tests.yml/badge.svg)](https://github.com/TU_USUARIO/forecast-arena/actions/workflows/tests.yml)
[![PyPI](https://img.shields.io/pypi/v/forecast-arena)](https://pypi.org/project/forecast-arena/)
[![docs](https://img.shields.io/badge/docs-online-blueviolet)](https://TU_USUARIO.github.io/forecast-arena)
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)

Modelos de series de tiempo que **se pelean entre ellos**. El de menor error gana el derecho a pronosticar.

📖 **Documentación completa**: https://Zurishadday.github.io/forecast-arena

Inspirado en Hyndman & Athanasopoulos, *Forecasting: Principles and Practice* ([fpp3](https://otexts.com/fpp3/)).

## Instalación

```bash
pip install forecast-arena              # núcleo
pip install "forecast-arena[xgb]"       # + XGBoost (recomendado)
pip install "forecast-arena[plots]"     # + matplotlib para result.plot()
```

## Uso en 5 líneas

```python
import pandas as pd
from forecast_arena import Arena

y = pd.read_csv("ventas.csv", index_col=0, parse_dates=True).squeeze()

arena = Arena(season_length=12)
result = arena.compete(y, test_size=6, h=12, n_windows=3)

print(result.leaderboard)   # MAE, RMSE, MAPE, sMAPE, MASE por modelo
print(result.champion)      # p. ej. 'ETS'
print(result.forecast)      # pronóstico de 12 pasos del campeón
```

## Los luchadores

| Nombre    | Modelo                                      | fpp3     |
|-----------|---------------------------------------------|----------|
| `naive`   | Repite el último valor                      | cap. 5.2 |
| `snaive`  | Naive estacional                            | cap. 5.2 |
| `drift`   | Extrapolación lineal primera→última obs.    | cap. 5.2 |
| `sarima`  | auto_arima (pmdarima) autoconfigurado       | cap. 9   |
| `ets`     | Suavizamiento exponencial                   | cap. 8   |
| `xgb`     | STL + tendencia por regresión + boosting recursivo | cap. 3.6 |
| `theta`   | Método Theta (ganador de M3)                | —        |
| `stlf`    | STL + ETS                                   | cap. 3.6 |
| `harmonic`| Fourier + ARIMA en errores (multi-estacional) | cap. 12.1 |
| `croston` | Croston/SBA para demanda intermitente (no default) | — |
| `Ensemble`| Media de todos los anteriores (compite también) | —    |

Los benchmarks naive **siempre están en la pelea** — fpp3 insiste: si tu modelo
sofisticado no vence al naive (MASE < 1), no sirve.

## Cómo pelean

1. **Perfil de la serie** (`SeriesProfile`): fuerzas de tendencia y estacionalidad
   vía STL (fpp3 cap. 4.3), con guarda anti-estacionalidad-espuria (la ACF debe
   confirmar el lag estacional). El perfil autoconfigura SARIMA, ETS y el híbrido.
2. **Backtesting rolling-origin** (fpp3 cap. 5.10): `n_windows` ventanas de
   `test_size` pasos que avanzan hacia el presente. Un modelo que falla en una
   ventana no detiene la pelea.
3. **Leaderboard** en escala original (aunque uses Box-Cox), ordenado por la
   métrica que elijas (`metric="MAE"` default; también `"MASE"`, `"RMSE"`...).
4. El **campeón reentrena con toda la serie** y pronostica `h` pasos.

## Opciones

```python
Arena(
    models=["snaive", "ets", "sarima"],  # elige a los luchadores
    season_length=12,       # 12 mensual, 7 diario, 4 trimestral...
    metric="MASE",          # métrica que corona al campeón
    handle_outliers=True,   # winsoriza si generalized ESD detecta anomalías
    box_cox=True,           # transforma (métricas siguen en escala original)
    include_ensemble=True,
)
```

También puedes meter tu propio luchador: cualquier subclase de `BaseForecaster`
con `_fit(y)` y `_predict(h)`:

```python
from forecast_arena import BaseForecaster

class MiModelo(BaseForecaster):
    name = "MiModelo"
    def _fit(self, y): ...
    def _predict(self, h): ...

Arena(models=["snaive", MiModelo()]).compete(y)
```

## Resultado en AirPassengers (144 meses, 3 ventanas, Box-Cox por Guerrero)

```
            MAE   RMSE  MAPE%  sMAPE%  MASE
ETS       17.67  20.76   4.28    4.20  0.59
XGB       18.92  22.79   4.82    4.65  0.63
Sarima    24.13  27.24   5.90    5.89  0.82
Ensemble  31.98  40.03   6.90    7.20  1.08
SNaive    35.92  38.99   8.06    8.52  1.21
Drift     60.97  83.26  12.82   14.13  2.05
Naive     73.22  97.53  15.28   17.41  2.47
```

## Detección de quiebres estructurales (v0.7)

Si tu serie "cambió de vida" — subida de precios, competidor nuevo,
pandemia — los modelos entrenan sobre un pasado que ya no describe el
presente. La Arena ahora lo detecta **automáticamente** con PELT (Killick
et al. 2012, el algoritmo estándar de changepoint detection), implementado
internamente:

```python
result = Arena().compete(y, test_size=12, h=12)   # detección activa por default
result.changepoints
#  pos      fecha   tipo  antes  despues  cambio
#   70 2021-04-01  nivel   98.9    145.2    46.2
```

Distingue **saltos de nivel** (la serie brincó a otra media) de **cambios de
tendencia** (la pendiente cambió), los marca en `result.plot()`, y ofrece
tres reacciones vía `changepoints=`:

- `"report"` (default): detecta y reporta; tú decides.
- `"trim"`: entrena solo con el régimen actual (post-quiebre), con guarda de
  historia mínima.
- `"dummy"`: convierte cada quiebre en variable de intervención (escalón
  0→1, fpp3 cap. 10.4) que entra como exógena — el modelo usa TODA la
  historia pero sabiendo que hubo un antes y un después. Suele ser la mejor
  opción. Componible con tus propias exógenas.
- `None`: apagado. Sensibilidad ajustable con `detect_changepoints(y, penalty_scale=)`.

Calibración medida: 0 falsos positivos en 60 series limpias (ruido,
tendencia y estacionalidad puras), 30/30 saltos de nivel localizados con
error 0, 30/30 cambios de tendencia con error medio de 2 observaciones.

## Auditoría de intervalos, jerárquico y más (v0.6)

**Auditoría de intervalos** (fpp3 cap. 5.9) — las bandas ya no son una
promesa sin verificar: en cada ventana del backtesting se mide la
**cobertura empírica** (¿la banda del 95% cubre de verdad el 95%?) y el
**Winkler score** (premia bandas angostas *que sí cumplen*):

```python
result.interval_report
#         coverage_80  winkler_80  coverage_95  winkler_95
# ETS           0.750      10.725        0.917      13.045
# XGB           0.667      18.535        0.833      28.052
```

**Pronóstico jerárquico** (fpp3 cap. 11) — cientos de series que suman
(SKU → categoría → total) con reconciliación para que las cifras cuadren
exactamente en todos los niveles:

```python
from forecast_arena import forecast_hierarchy
tree = {"total": ["norte", "centro"], "norte": ["mty", "chih"], "centro": ["cdmx", "gdl"]}
hr = forecast_hierarchy(df_hojas, tree, test_size=6, h=12, method="ols")
hr.reconciled   # las sumas cuadran exacto
hr.summary      # campeón y métricas por nodo
```

Métodos: `ols` (default, combina información de todos los niveles),
`bottom_up`, `top_down`. También API batch sin jerarquía:
`arena.compete_many({nombre: serie})` + `Arena.summarize(resultados)`.

**Efectos de calendario** (fpp3 cap. 10.2) — `calendar_features` ahora
incluye `trading_days` (días hábiles por periodo) y `is_easter` (Semana
Santa, la fiesta móvil que salta entre marzo y abril y contamina cualquier
estacionalidad mensual fija).

**Refinamientos** — la incertidumbre de las exógenas en cascada ahora SÍ se
propaga a los intervalos (método delta: se perturba cada exógena ±σ de su
holdout y se mide el efecto); `K="auto"` en Harmonic elige armónicos por
AICc; `bias_adjust=True` entrega la media (en vez de la mediana) al
destransformar Box-Cox (cap. 5.6) — útil cuando los pronósticos deben sumar.

**Producción** — `result.save(path)` / `forecast_arena.load(path)` (el
campeón cargado pronostica sin reentrenar), `random_state=` reproducible de
punta a punta, progreso por ventana con `verbose=True`, y suite de tortura
con series patológicas (constantes, m=365, escalas de 1e12, 3000+ puntos)
con guardas estilo fable (ETS/SARIMA degradan a no estacionales si m>24).

## Luchadores nuevos y calidad de vida (v0.5)

**Tres luchadores más en el ring:**

| Nombre | Modelo | Cuándo brilla |
|---|---|---|
| `theta` | Método Theta (ganador de la competencia M3) | Casi siempre; simple y difícil de vencer. En el lineup default. |
| `stlf` | STL + ETS (fpp3 cap. 3.6) | Estacionalidad estable con tendencia caprichosa. En el lineup default. |
| `croston` | Croston / SBA | **Demanda intermitente** (muchos ceros, SKUs de baja rotación). Especialista: pídelo explícitamente. |

```python
Arena(models=["croston", "naive"]).compete(demanda_intermitente, test_size=8, h=4)
```

**Calidad de vida:**

- **`result.plot(last=60)`** — histórico + pronóstico + bandas 80/95%
  (requiere matplotlib: `pip install "forecast-arena[plots]"`).
- **Ensemble ponderado** — `ensemble_weights="inverse_error"`: pesos
  proporcionales al inverso de la desviación de los residuales honestos de
  cada modelo, en lugar de media simple.
- **Manejo de huecos** — fechas faltantes y NaN internos se detectan
  (reconstruyendo la malla temporal aunque el hueco rompa la inferencia de
  frecuencia), se imputan por interpolación lineal y **se avisa**; con
  `impute=None` la Arena exige serie completa. NaN en los extremos solo se
  recortan.
- **Paralelización** — `n_jobs=-1` entrena a los luchadores en paralelo por
  ventana (joblib); resultados idénticos al secuencial.

## Intervalos, diagnóstico y estacionalidad múltiple (v0.4)

Las tres piezas metodológicas grandes de fpp3, explicadas a fondo en
[docs/METODOLOGIA.md](docs/METODOLOGIA.md):

```python
result = Arena().compete(y, test_size=12, h=6)

result.forecast_intervals
#             forecast  lower_80  upper_80  lower_95  upper_95
# 1961-01-01     444.1     414.4     461.5     389.6     473.7
# ...

result.residual_diagnostics["verdict"]
# "Residuales consistentes con ruido blanco (Ljung-Box p=0.886) y sin
#  sesgo. El modelo extrajo la estructura disponible."

# Estacionalidad múltiple (diaria con ciclo semanal + mensual):
Arena(season_length=[7, 30]).compete(y_diaria, test_size=14, h=14)
# agrega automáticamente al luchador Harmonic (Fourier + ARIMA en errores)
```

- **Intervalos de predicción** (cap. 5.5): cada modelo con su método —
  fórmulas cerradas (naive), analíticos (SARIMA/ETS/Harmonic) y simulación
  bootstrap con residuales honestos de holdout para el híbrido ML. Con
  Box-Cox se destransforman exactamente (quedan asimétricos, como debe ser).
- **Diagnóstico de residuales** (cap. 5.4): Ljung-Box + chequeo de sesgo
  sobre el campeón, con veredicto legible.
- **Estacionalidad múltiple** (cap. 12.1): regresión armónica dinámica vía
  el luchador `Harmonic`, activado solo al pasar `season_length=[m1, m2]`.

## Detección automática (v0.3): periodicidad y transformación

Ya no necesitas decirle nada a la Arena — pero puedes:

```python
result = Arena().compete(y, test_size=12, h=12)   # cero configuración

print(result.season_length)    # 12
print(result.season_source)    # "mensual, por frecuencia del índice"
print(result.transformation)   # "Box-Cox λ=-0.29 — serie multiplicativa
                               #  (la varianza crece con el nivel)"
```

**Periodicidad** (`season_length="auto"`, default): primero por la frecuencia
del índice de fechas (mensual→12, trimestral→4, semanal→52, diaria→7,
hábiles→5, horaria→24, anual→1); si no hay fechas o no se infiere, por
periodograma (FFT) sobre la serie sin tendencia, confirmado con ACF
significativa al 1% y prominencia del pico espectral (sin falsos positivos
en ruido). Si nada confirma → m=1 y todos los modelos degradan limpiamente
a sus versiones no estacionales.

**Transformación** (`transform="auto"`, default): detecta si la serie es
multiplicativa midiendo si la dispersión crece con el nivel (Spearman entre
media y desviación estándar por bloques de un ciclo). Si lo es, el λ de
Box-Cox se elige por el **método de Guerrero** (el de fable/fpp3, cap. 3.1);
con λ≈0 aplica logaritmo por interpretabilidad. Si es aditiva, no toca nada.
El resultado siempre te dice qué hizo y por qué. Las métricas del
leaderboard siguen reportándose en la escala original.

Opciones manuales: `transform="boxcox"` (Guerrero forzado), `"log"`, `None`,
y `season_length=12` fijo. El flag `box_cox=True/False` de v0.1 sigue
funcionando.

## Variables exógenas (v0.2) — fpp3 cap. 10

Tres modos, según qué tanto sabes del futuro de tus regresores:

```python
# 1. CONOCIDAS (ex-post): calendario, promociones planeadas, precios fijados.
result = arena.compete(y, h=6, X=X_hist, X_future=X_conocidas)

# 2. DESCONOCIDAS (ex-ante): cada columna se pronostica en su propia
#    mini-arena (naive/snaive/drift/ETS) y el campeón alimenta a y.
result = arena.compete(y, h=6, X=X_hist, forecast_X=True)

# 3. MIXTO: las columnas en X_future van directas; el resto, en cascada.
result = arena.compete(y, h=6, X=X_hist, X_future=X_promos, forecast_X=True)
```

Quién usa las exógenas: `sarima` (se vuelve SARIMAX) y `xgb` (features junto
a los lags). `ets`, `naive`, `snaive` y `drift` las ignoran pero **siguen
compitiendo** — si tus exógenas no aportan, un modelo ciego ganará el
leaderboard y te enteras solito.

**Backtesting honesto**: en cada ventana, las exógenas desconocidas se
pronostican usando solo datos hasta el origen — nunca sus valores reales del
tramo de test. El leaderboard refleja el error real de producción, error de
cascada incluido. Las conocidas sí usan sus valores reales (en producción
también los tendrás: son deterministas o planeadas).

El resultado incluye:

- `result.exog_report`: campeón y MASE en holdout por exógena pronosticada.
  MASE > 1 delata una exógena "ruidosa" cuyo error contamina el pronóstico —
  candidata a salir.
- `result.X_future_used`: la tabla exacta (dadas + pronosticadas) que
  alimentó el pronóstico final.

Helpers para exógenas de calendario (siempre conocidas):

```python
from forecast_arena import fourier_terms, calendar_features
X_cal = calendar_features(y.index)               # mes, trimestre, diciembre...
X_fourier = fourier_terms(y.index, period=12, K=3)  # armónicos (cap. 10.5)
```

## Cambios vs. el `Forecasting.py` original

- **Bugs corregidos**: typo `star_Q` ignorado por auto_arima; `.values` sobre
  ndarray en la rama sin estacionalidad; Box-Cox truena con ceros/negativos
  (ahora shift automático reversible); comparación R² in-sample donde el
  polinomio nunca podía perder (ahora se compara en cola de validación);
  estacionalidad espuria de STL en ruido con pocos ciclos (guarda ACF);
  el diagnóstico de estacionalidad usaba period=12 fijo, degradando
  silenciosamente series trimestrales/semanales (v0.3).
- **Dependencias eliminadas**: `skforecast` (su `ForecasterAutoreg` fue
  deprecado; el forecaster recursivo está implementado internamente) y
  `PyAstronomy` (generalized ESD implementado con scipy).
- **Nuevo**: benchmarks naive/snaive/drift, MASE/sMAPE/RMSE, backtesting
  rolling-origin multiventana, perfil de serie reutilizable, manejo de series
  cortas sin crashes, API extensible, suite de 16 tests.
- **Typos de API corregidos**: `Tranformation`→`BoxCoxTransformer`,
  `perfomance`→`score_table`.

## Tests

```bash
pip install -e ".[dev]" && pytest
```

## Limitaciones conocidas / roadmap

- Los intervalos del Ensemble son el promedio de los de sus miembros
  (equivale a asumir correlación perfecta: la combinación conservadora).
- La propagación de incertidumbre de exógenas usa método delta (primer
  orden); simulación completa sería más exacta para efectos no lineales.
- La reconciliación jerárquica no incluye MinT con covarianza muestral
  (solo OLS, su caso identidad-ponderado).
- La cascada pronostica cada exógena de forma independiente (sin correlaciones entre ellas).
- El Ensemble es media simple; ponderar por inverso del error sería mejorable.
- Publicación en PyPI, CI y documentación web pendientes para v1.0.
