# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026 # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # https://www.qc-election.com """Cadre d'ablation (v3 « 127 », règle §22) — chaque couche doit prouver qu'elle ne dégrade pas la prévision. Rejoue le backtest 2022 pour une série de VARIANTES (couche retirée une à une) et compare les scores : une couche qui dégrade systématiquement Brier/EAM doit être retirée, reparamétrée ou reléguée au statut expérimental. Le rapport est archivé (`data/ablation_report.json`) et exposé par l'API — aucune couche ne peut modifier silencieusement le forecast. """ from __future__ import annotations import json from contextlib import contextmanager from datetime import date from sqlalchemy.orm import Session from ...config import DATA_DIR, settings # Variantes d'ablation : nom → surcharges de configuration temporaires. VARIANTS: dict[str, dict] = { "FULL": {}, "sans_fondamentaux": {"fundamentals_enabled": False}, "sans_partielles": {"byelections_enabled": False}, "sans_medias": {"media_nudge_enabled": False}, "sans_volatilite_sociale": {"enable_social_volatility": False}, "sondages_seuls": {"fundamentals_enabled": False, "byelections_enabled": False, "media_nudge_enabled": False, "enable_social_volatility": False}, } @contextmanager def _overrides(over: dict): """Applique temporairement des surcharges de settings (restaurées ensuite).""" saved = {k: getattr(settings, k) for k in over} for k, v in over.items(): object.__setattr__(settings, k, v) try: yield finally: for k, v in saved.items(): object.__setattr__(settings, k, v) def run_ablation(db: Session, n_sims: int = 6000, horizons: list[int] | None = None) -> dict: """Backtest 2022 par variante → tableau comparatif des scores. NB : les couches non rejouables en 2022 (partielles, médias, volatilité — données indisponibles) affichent des scores identiques au FULL : le cadre les journalise comme « non identifiées en 2022 » plutôt que de simuler un effet inexistant (règle §1.4, aucun data leakage).""" from ..backtest import run_backtest horizons_keep = horizons or [120, 60, 30, 7, 1] report: dict = {"generated": date.today().isoformat(), "model_version": settings.model_version, "n_sims": n_sims, "variants": {}} for name, over in VARIANTS.items(): with _overrides(over): bt = run_backtest(db, n_sims=n_sims, save_runs=False) rows = [] for h in bt["horizons"]: if h.get("horizon") in horizons_keep and "national" in h: rows.append({"horizon": h["horizon"], "mae_pp": h["national"]["mae_pp"], "coverage_95": h["national"]["coverage_95"], "seat_mae": h["seats"]["mae"], "brier": h["districts"]["brier"], "winner_accuracy": h["districts"]["winner_accuracy"]}) avg = {k: round(sum(r[k] for r in rows) / len(rows), 4) for k in ("mae_pp", "seat_mae", "brier", "winner_accuracy")} if rows else {} report["variants"][name] = {"overrides": over, "horizons": rows, "avg": avg} full = report["variants"].get("FULL", {}).get("avg", {}) for name, v in report["variants"].items(): if name == "FULL" or not v["avg"]: continue v["delta_vs_full"] = {k: round(v["avg"][k] - full.get(k, 0), 4) for k in v["avg"]} (DATA_DIR / "ablation_report.json").write_text( json.dumps(report, indent=1, ensure_ascii=False)) return report