# 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 """Historical Replay Engine (§52-54) — la validation PRINCIPALE du modèle. Rejoue les élections 2007, 2008, 2012, 2014, 2018 (national) et 2022 (national + circonscriptions, via le backtest existant) en LEAVE-ONE-ELECTION-OUT strict : * house effects appris uniquement sur les AUTRES élections; * prior de fondamentaux ajusté avec `exclude_year` (l'élection testée ne participe jamais à son propre prior); * σ_industrie estimé sur les AUTRES élections (`national/pollster_error.py`); * rolling origin J-120 → J-1 : seulement l'information ≤ date. Scores propres (§55) : EAM/RMSE du vote, couverture 50/90/95, CRPS, log score, sharpness, biais signé. Rapport : `data/replay_report.json` + `/api/replay`. """ from __future__ import annotations import json from datetime import date, timedelta import numpy as np from ...config import DATA_DIR, settings from ..compositions import alr, close, inv_alr from ..forecast import forecast as fc from ..fundamentals import HISTORY, blend, compute_prior from ..house_effects import compute_profiles from ..trend import fit_trend from .scoring import (crps_from_draws, interval_coverage, log_score_alr, sharpness, vote_errors) HORIZONS = [120, 90, 60, 45, 30, 21, 14, 7, 3, 1] def _era_prev_result(year: int) -> dict[str, float] | None: """Vote de l'élection PRÉCÉDENTE, exprimé dans l'ère de partis de `year`.""" from ...ingest.wikipedia_historical import ELECTIONS PREV = {2007: {"PLQ": 45.99, "PQ": 33.24, "ADQ": 18.18, "QS": 1.06, "AUT": 1.73}, 2008: {"PLQ": 33.08, "PQ": 28.35, "ADQ": 30.84, "QS": 3.64, "AUT": 4.09}, 2012: {"PLQ": 42.08, "PQ": 35.17, "CAQ": 16.37, "QS": 3.78, "AUT": 2.60}, 2014: {"PLQ": 31.20, "PQ": 31.95, "CAQ": 27.05, "QS": 6.03, "AUT": 3.77}, 2018: {"PLQ": 41.52, "PQ": 25.38, "CAQ": 23.05, "QS": 7.63, "AUT": 2.42}} return PREV.get(year) def _loeo_profiles(test_year: int, parties: list[str]): """House effects appris sur toutes les élections rejouables ≠ test_year (dans l'ère de partis du test — seuls les partis communs contribuent).""" from ...ingest.wikipedia_historical import ELECTIONS, load_polls, to_model_polls polls, elections = [], [] for yr, meta in ELECTIONS.items(): if yr == test_year: continue elections.append({"date": meta["date"], "result": meta["result"]}) polls.extend(to_model_polls(load_polls(yr))) return compute_profiles(polls, elections, parties=parties) def _baselines_at(polls: list[dict], as_of: date, parties: list[str], actual: dict) -> dict: """Règles simples contre lesquelles le modèle DOIT gagner (§calibration) : B1 = dernier sondage publié; B2 = moyenne simple des 14 derniers jours (repli : 3 derniers sondages).""" hist = sorted((p for p in polls if p["field_end"] <= as_of), key=lambda p: p["field_end"]) if not hist: return {} def norm(sh): tot = sum(sh.get(p, 0.0) for p in parties) or 100.0 return {p: sh.get(p, 0.0) * 100.0 / tot for p in parties} last = norm(hist[-1]["shares"]) recent = [p for p in hist if (as_of - p["field_end"]).days <= 14] or hist[-3:] avg = {pt: float(np.mean([norm(p["shares"])[pt] for p in recent])) for pt in parties} return { "dernier_sondage": vote_errors(last, actual, parties)["mae_pp"], "moyenne_14j": vote_errors(avg, actual, parties)["mae_pp"], } def replay_election(year: int, use_fundamentals: bool = True, use_loeo_house: bool = True, industry_sd: float | None = None, horizons: list[int] | None = None) -> dict: """Rejoue une élection à tous les horizons; retourne les scores.""" from ...ingest.wikipedia_historical import ELECTIONS, load_polls, to_model_polls meta = ELECTIONS[year] parties = meta["parties"] eday = meta["date"] actual = meta["result"] actual_vec = close(np.array([actual[p] for p in parties]) / 100.0) polls = to_model_polls(load_polls(year)) profiles = _loeo_profiles(year, parties) if use_loeo_house else {} prev = _era_prev_result(year) rows = [] rng = np.random.default_rng(year) for h in (horizons or HORIZONS): as_of = eday - timedelta(days=h) trend = fit_trend(polls, profiles, as_of, parties=parties) if trend is None: rows.append({"horizon": h, "error": "sondages insuffisants"}) continue f = fc(trend, as_of, eday, parties=parties, industry_sd=industry_sd) if use_fundamentals and prev is not None: hist_row = next((r for r in HISTORY if r[0] == year), None) if hist_row: _, inc, terms, sat, _, _ = hist_row prior = compute_prior(as_of, incumbent=inc, terms=terms, satisfaction=sat, prev_result=prev, exclude_year=year, parties=parties) x_b, P_b, _ = blend(f.x, f.P, prior, h, parties=parties) from ..forecast import distribution_from f = distribution_from("forecast", as_of, x_b, P_b, parties=parties) # tirages pour CRPS Mx = len(f.x) L = np.linalg.cholesky(f.P + 1e-10 * np.eye(Mx)) draws = inv_alr(f.x + rng.standard_normal((4000, Mx)) @ L.T) * 100.0 fc_mean = {p: f.summary[p]["mean"] for p in parties} rows.append({ "horizon": h, "as_of": as_of.isoformat(), "n_polls": trend.n_polls, **vote_errors(fc_mean, actual, parties), **interval_coverage(f.summary, actual, parties), "crps_pp": crps_from_draws(draws, actual_vec * 100.0), "log_score": log_score_alr(f.x, f.P, alr(actual_vec)), "sharpness_pp": sharpness(f.summary, parties), "forecast": {p: round(fc_mean[p], 1) for p in parties}, "baselines_mae_pp": _baselines_at(polls, as_of, parties, actual), }) ok = [r for r in rows if "error" not in r] agg = {} if ok: for k in ("mae_pp", "rmse_pp", "crps_pp", "log_score", "sharpness_pp"): agg[k] = round(float(np.mean([r[k] for r in ok])), 3) for k in ("coverage_50", "coverage_90", "coverage_95"): vals = [r[k] for r in ok if k in r] if vals: agg[k] = round(float(np.mean(vals)), 3) for b in ("dernier_sondage", "moyenne_14j"): vals = [r["baselines_mae_pp"][b] for r in ok if r.get("baselines_mae_pp", {}).get(b) is not None] if vals: agg[f"baseline_{b}_mae_pp"] = round(float(np.mean(vals)), 3) return {"year": year, "parties": parties, "actual": actual, "horizons": rows, "avg": agg} def run_replay(use_fundamentals: bool = True, industry_from_loeo: bool = True) -> dict: """Replay complet multi-élections (national). 2022 reste couvert par le backtest circonscriptions existant — les deux rapports se complètent.""" from ...ingest.wikipedia_historical import ELECTIONS from ..national.pollster_error import industry_sigma_loeo report = {"generated": date.today().isoformat(), "model_version": settings.model_version, "protocol": ("LOEO strict : house effects, fondamentaux et " "σ_industrie appris sur les autres élections; " "rolling origin J-120→J-1."), "elections": {}} for yr in sorted(ELECTIONS): sd = industry_sigma_loeo(yr) if industry_from_loeo else None rep = replay_election(yr, use_fundamentals=use_fundamentals, industry_sd=sd) rep["industry_sd_loeo"] = round(sd, 4) if sd is not None else None report["elections"][str(yr)] = rep # agrégat global (moyenne des moyennes par élection) aggs = [e["avg"] for e in report["elections"].values() if e["avg"]] if aggs: report["overall"] = {k: round(float(np.mean([a[k] for a in aggs if k in a])), 3) for k in aggs[0]} (DATA_DIR / "replay_report.json").write_text( json.dumps(report, indent=1, ensure_ascii=False)) return report