# 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 """Backtesting du modèle sur l'élection de 2022. À J-120, J-90, J-60, J-30, J-14, J-7 et J-1, on refait tourner le modèle avec UNIQUEMENT l'information disponible à cette date (sondages ≤ date; aucune correction maison, faute d'historique antérieur à 2022 — documenté), puis on compare aux résultats réels : MAE/RMSE du vote, couverture des intervalles, sièges, Brier score et calibration des probabilités de victoire locales. """ from __future__ import annotations import json from datetime import date, timedelta import numpy as np import pandas as pd from sqlalchemy.orm import Session from ..config import DATA_DIR, settings from .. import models as Mo from ..pipeline import load_polls from . import fundamentals as FUND from .forecast import distribution_from, forecast as fc from .simulate import SimulationInput, run_simulation from .trend import fit_trend ELECTION_2022 = date(2022, 10, 3) HORIZONS = [120, 90, 60, 30, 14, 7, 1] RESULT_2022 = {"CAQ": 40.98, "PLQ": 14.37, "QS": 15.43, "PQ": 14.61, "PCQ": 12.91, "AUT": 1.70} RESULT_2018 = {"CAQ": 37.42, "PLQ": 24.82, "QS": 16.10, "PQ": 17.06, "PCQ": 1.46, "AUT": 3.14} SEATS_2022 = {"CAQ": 90, "PLQ": 21, "QS": 11, "PQ": 3, "PCQ": 0, "AUT": 0} def _districts_2022() -> tuple[SimulationInput, dict[str, str]]: """Univers 2022 : 125 circonscriptions, référence = résultats 2018 reconstruits.""" from ..ingest.wikipedia import REGION_RIDINGS, _district_key, canon_district region_of = {} for reg, names in REGION_RIDINGS.items(): for n in names: region_of[_district_key(n)] = reg df = pd.read_csv(DATA_DIR / "riding_results_2022.csv") names, baselines, regions, winners = [], [], [], {} for _, r in df.iterrows(): names.append(r["district"]) b = [max(0.2, float(r.get(f"prev_{p}", 0.0) or 0.0)) for p in settings.parties] baselines.append(np.array(b) / sum(b)) regions.append(region_of.get(_district_key(canon_district(r["district"])), "Autre")) actual = {p: float(r[p]) for p in settings.parties if p in r} winners[r["district"]] = max(actual, key=actual.get) D = len(names) inp = SimulationInput( x_mean=np.zeros(5), P=np.eye(5), baseline_national=np.array([RESULT_2018[p] / 100.0 for p in settings.parties]), district_names=names, district_baselines=np.array(baselines), district_regions=regions, retirement_flags=np.zeros(D, dtype=int), incumbent_party_idx=np.full(D, -1), majority_seats=63) return inp, winners def run_backtest(db: Session, n_sims: int = 10000, save_runs: bool = True) -> dict: el22 = db.query(Mo.Election).filter(Mo.Election.election_date == ELECTION_2022).one() polls = load_polls(db, el22.id) inp, actual_winners = _districts_2022() parties = [p for p in settings.parties if p != "AUT"] horizons_report = [] all_probs, all_outcomes = [], [] # calibration des probabilités locales for h in HORIZONS: as_of = ELECTION_2022 - timedelta(days=h) trend = fit_trend(polls, {}, as_of) if trend is None: horizons_report.append({"horizon": h, "error": "sondages insuffisants"}) continue f_polls = fc(trend, as_of, ELECTION_2022) # v2 : blend avec le prior de fondamentaux, information ≤ 2022 seulement # (sortant CAQ, 1er mandat, satisfaction pré-électorale ~54 %, vote 2018). f = f_polls if settings.fundamentals_enabled: prior22 = FUND.compute_prior(as_of, incumbent="CAQ", terms=1, satisfaction=54.0, prev_result=RESULT_2018) x_b, P_b, _ = FUND.blend(f_polls.x, f_polls.P, prior22, (ELECTION_2022 - as_of).days) f = distribution_from("forecast", as_of, x_b, P_b) inp.x_mean, inp.P = f.x, f.P sim = run_simulation(inp, n_sims=n_sims) # --- métriques nationales errs = [f.summary[p]["mean"] - RESULT_2022[p] for p in parties] mae = float(np.mean(np.abs(errs))) rmse = float(np.sqrt(np.mean(np.square(errs)))) coverage = float(np.mean([ f.summary[p]["lo95"] <= RESULT_2022[p] <= f.summary[p]["hi95"] for p in parties])) mae_polls_only = float(np.mean( [abs(f_polls.summary[p]["mean"] - RESULT_2022[p]) for p in parties])) # --- sièges seat_mae = float(np.mean([abs(sim.seats[p]["mean"] - SEATS_2022[p]) for p in parties])) seat_cov = float(np.mean([ sim.seats[p]["p05"] <= SEATS_2022[p] <= sim.seats[p]["p95"] for p in parties])) # --- circonscriptions : Brier multiclasse + log loss briers, lls, correct = [], [], 0 for d in sim.districts: actual = actual_winners.get(d["district"]) if actual is None: continue b = sum((d["win_probs"].get(p, 0.0) - (1.0 if p == actual else 0.0)) ** 2 for p in settings.parties) briers.append(b) lls.append(-np.log(max(d["win_probs"].get(actual, 0.0), 1e-6))) if d["favorite"] == actual: correct += 1 for p in settings.parties: all_probs.append(d["win_probs"].get(p, 0.0)) all_outcomes.append(1.0 if p == actual else 0.0) rep = { "horizon": h, "as_of": as_of.isoformat(), "n_polls": trend.n_polls, "national": {"mae_pp": round(mae, 2), "rmse_pp": round(rmse, 2), "mae_pp_sondages_seuls": round(mae_polls_only, 2), "coverage_95": round(coverage, 3), "forecast": {p: f.summary[p]["mean"] for p in parties}, "actual": {p: RESULT_2022[p] for p in parties}}, "seats": {"mae": round(seat_mae, 1), "coverage_90": round(seat_cov, 3), "expected": {p: sim.seats[p]["mean"] for p in parties}, "actual": SEATS_2022, "prob_majority_caq": sim.seats["CAQ"]["prob_majority"]}, "districts": {"brier": round(float(np.mean(briers)), 4), "log_loss": round(float(np.mean(lls)), 4), "winner_accuracy": round(correct / len(briers), 4)}, } horizons_report.append(rep) if save_runs: run = Mo.ForecastRun( as_of=as_of, model_version=settings.model_version, n_polls_used=trend.n_polls, n_simulations=n_sims, is_backtest=True, label=f"Backtest 2022 J-{h}", national={"forecast": f.summary}, seats={"per_party": sim.seats}, diagnostics=rep) db.add(run) # --- calibration globale (bacs de 10 %) probs = np.array(all_probs); outs = np.array(all_outcomes) bins = [] for lo in np.arange(0, 1.0, 0.1): m = (probs >= lo) & (probs < lo + 0.1 if lo < 0.9 else probs <= 1.0) if m.sum() >= 5: bins.append({"bin": f"{int(lo*100)}–{int(lo*100)+10} %", "predicted": round(float(probs[m].mean()), 3), "observed": round(float(outs[m].mean()), 3), "n": int(m.sum())}) report = {"election": "Québec 2022", "generated": date.today().isoformat(), "model_version": settings.model_version, "note": ("Aucune correction maison appliquée en backtest (pas de " "données antérieures à 2018). v2 : le prior de fondamentaux " "(information ≤ 2022 : sortant CAQ, 1er mandat, satisfaction " "~54 %, vote 2018) est blendé comme en production; " "mae_pp_sondages_seuls permet la comparaison directe. " "Partielles et ajustement médias non rejouables pour 2022 " "(données indisponibles) — exclus du backtest."), "horizons": horizons_report, "calibration": bins} (DATA_DIR / "backtest_report.json").write_text( json.dumps(report, indent=2, ensure_ascii=False)) if save_runs: db.commit() return report