spb/qc-election
Public
Python 66.6%
HTML 24.8%
CSS 4.9%
JavaScript 3.6%
1# QC Élection Forecast — Plateforme de prévision électorale du Québec 20262# Auteur : Simon-Pierre Boucher3# Contact : contact@spboucher.ai4# https://www.qc-election.com5"""Cadre d'ablation (v3 « 127 », règle §22) — chaque couche doit prouver6qu'elle ne dégrade pas la prévision.78Rejoue le backtest 2022 pour une série de VARIANTES (couche retirée une à une)9et compare les scores : une couche qui dégrade systématiquement Brier/EAM doit10être retirée, reparamétrée ou reléguée au statut expérimental. Le rapport est11archivé (`data/ablation_report.json`) et exposé par l'API — aucune couche ne12peut modifier silencieusement le forecast.13"""14from __future__ import annotations1516import json17from contextlib import contextmanager18from datetime import date1920from sqlalchemy.orm import Session2122from ...config import DATA_DIR, settings2324# Variantes d'ablation : nom → surcharges de configuration temporaires.25VARIANTS: dict[str, dict] = {26 "FULL": {},27 "sans_fondamentaux": {"fundamentals_enabled": False},28 "sans_partielles": {"byelections_enabled": False},29 "sans_medias": {"media_nudge_enabled": False},30 "sans_volatilite_sociale": {"enable_social_volatility": False},31 "sondages_seuls": {"fundamentals_enabled": False,32 "byelections_enabled": False,33 "media_nudge_enabled": False,34 "enable_social_volatility": False},35}363738@contextmanager39def _overrides(over: dict):40 """Applique temporairement des surcharges de settings (restaurées ensuite)."""41 saved = {k: getattr(settings, k) for k in over}42 for k, v in over.items():43 object.__setattr__(settings, k, v)44 try:45 yield46 finally:47 for k, v in saved.items():48 object.__setattr__(settings, k, v)495051def run_ablation(db: Session, n_sims: int = 6000,52 horizons: list[int] | None = None) -> dict:53 """Backtest 2022 par variante → tableau comparatif des scores.54 NB : les couches non rejouables en 2022 (partielles, médias, volatilité —55 données indisponibles) affichent des scores identiques au FULL : le cadre56 les journalise comme « non identifiées en 2022 » plutôt que de simuler un57 effet inexistant (règle §1.4, aucun data leakage)."""58 from ..backtest import run_backtest59 horizons_keep = horizons or [120, 60, 30, 7, 1]60 report: dict = {"generated": date.today().isoformat(),61 "model_version": settings.model_version,62 "n_sims": n_sims, "variants": {}}63 for name, over in VARIANTS.items():64 with _overrides(over):65 bt = run_backtest(db, n_sims=n_sims, save_runs=False)66 rows = []67 for h in bt["horizons"]:68 if h.get("horizon") in horizons_keep and "national" in h:69 rows.append({"horizon": h["horizon"],70 "mae_pp": h["national"]["mae_pp"],71 "coverage_95": h["national"]["coverage_95"],72 "seat_mae": h["seats"]["mae"],73 "brier": h["districts"]["brier"],74 "winner_accuracy": h["districts"]["winner_accuracy"]})75 avg = {k: round(sum(r[k] for r in rows) / len(rows), 4)76 for k in ("mae_pp", "seat_mae", "brier", "winner_accuracy")} if rows else {}77 report["variants"][name] = {"overrides": over, "horizons": rows,78 "avg": avg}79 full = report["variants"].get("FULL", {}).get("avg", {})80 for name, v in report["variants"].items():81 if name == "FULL" or not v["avg"]:82 continue83 v["delta_vs_full"] = {k: round(v["avg"][k] - full.get(k, 0), 4)84 for k in v["avg"]}85 (DATA_DIR / "ablation_report.json").write_text(86 json.dumps(report, indent=1, ensure_ascii=False))87 return report88