""" Validación walk-forward del motor de proyección mensual. Para cada mes M del periodo evaluado, corre el procedimiento COMPLETO (46 configuraciones, selección del ganador por rolling origin y bandas empíricas) usando solo la serie hasta M-1, y compara la proyección a un mes con el valor registrado de M. Es la misma prueba que se haría si el sistema hubiese estado en producción desde el inicio del periodo. Uso: python analisis/backtest_proyeccion.py 2024-09 2026-09 """ from __future__ import annotations import sys import time from pathlib import Path import duckdb import pandas as pd RAIZ = Path(__file__).resolve().parent.parent sys.path.insert(0, str(RAIZ)) import forecast as fc # noqa: E402 def serie_homicidios() -> pd.Series: c = duckdb.connect(str(RAIZ / "sinadef.duckdb"), read_only=True) d = c.execute("""SELECT ANIO||'-'||MES m, COUNT(*) n FROM tbl_SINADEF WHERE MUERTE_VIOLENTA='HOMICIDIO' GROUP BY 1 ORDER BY 1""").fetchdf() s = pd.Series(d.n.values, index=pd.PeriodIndex(d.m, freq="M")).astype(float) return s[s.index >= pd.Period("2017-01", "M")] def main(desde: str, hasta: str): s = serie_homicidios() filas = [] t0 = time.time() for mes in pd.period_range(desde, hasta, freq="M"): hist = s[s.index < mes] r = fc.proyectar(hist, h=1) real = float(s.get(mes, float("nan"))) b = r.get("bandas") or {} fila = { "mes": str(mes), "real": real, "proyectado": r["central"][0], "p10": b.get("p10", [None])[0], "p90": b.get("p90", [None])[0], "modelo": r["mejor"]["modelo"], "mape_validacion": r["mejor"]["mape"], "naive_estacional": float(s[s.index == mes - 12].iloc[0]), } fila["error_%"] = round((fila["proyectado"] / real - 1) * 100, 1) fila["dentro_banda"] = bool(fila["p10"] is not None and fila["p10"] <= real <= fila["p90"]) fila["errores_bandas"] = r.get("errores_bandas", []) filas.append(fila) print(f" {mes}: real {real:.0f} · proy {fila['proyectado']:.0f} " f"[{fila['p10']}–{fila['p90']}] {fila['modelo']} ({time.time()-t0:.0f}s)", flush=True) d = pd.DataFrame(filas) # Los errores con que se armó cada rango van aparte: permiten evaluar otras # formas de construir el rango sin volver a correr los 46 modelos. d[["mes", "errores_bandas"]].to_json(RAIZ / "analisis" / "backtest_errores.json", orient="records") d = d.drop(columns="errores_bandas") d.to_csv(RAIZ / "analisis" / "backtest_proyeccion.csv", index=False) ae = (d.proyectado - d.real).abs() / d.real an = (d.naive_estacional - d.real).abs() / d.real print(f"\nMAPE motor: {ae.mean()*100:.1f}% MAPE naive estacional: {an.mean()*100:.1f}%") print(f"Cobertura banda 80%: {d.dentro_banda.mean()*100:.0f}% ({d.dentro_banda.sum()}/{len(d)})") if __name__ == "__main__": main(*(sys.argv[1:3] if len(sys.argv) > 2 else ("2024-09", "2026-09")))