# 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 """Prior de fondamentaux — le forecast ne repose plus uniquement sur les sondages. Idée (Abramowitz, Hibbs, The Economist/538 appliqués au Québec) : longtemps avant le scrutin, les sondages sont peu informatifs sur le résultat final; des variables structurelles le sont davantage : * la **satisfaction envers le gouvernement** (baromètres Léger/CROP) — le prédicteur historique le plus fort du vote du parti sortant au Québec; * le **vote à l'élection précédente** (persistance des coalitions); * l'**usure du pouvoir** (pénalité par mandat au-delà du premier). Le vote du parti sortant est estimé par une régression ridge sur les huit générales de 1998 à 2022 (validation leave-one-out → variance honnête). Les partis d'opposition reçoivent un prior de persistance (vote précédent, rappelé vers leur moyenne de long terme). Le tout devient une distribution a priori en espace log-ratio, combinée au posterior des sondages par pondération de précision, avec un poids qui décroît vers zéro à l'approche du scrutin : w(t) = min(w_max, (jours_restants / horizon)^1.3) À 18 mois, les fondamentaux comptent; la veille du vote, ils ne comptent presque plus — exactement le comportement souhaité. """ from __future__ import annotations from dataclasses import dataclass, field from datetime import date import numpy as np from ..config import settings from .compositions import alr, close, inv_alr # --------------------------------------------------------------------------- # Élections générales québécoises 1998-2022 — variables de fondamentaux. # satisfaction : % « satisfaits du gouvernement » pré-électoral (Léger/CROP, # reconstitution documentée — valeurs approximatives au point de pourcentage # près, suffisant pour une régression à 8 observations). # --------------------------------------------------------------------------- HISTORY = [ # (année, parti sortant, mandats, satisfaction, vote précédent du sortant, résultat du sortant) (1998, "PQ", 1, 52.0, 44.75, 42.87), (2003, "PQ", 2, 45.0, 42.87, 33.24), (2007, "PLQ", 1, 36.0, 45.99, 33.08), (2008, "PLQ", 2, 52.0, 33.08, 42.08), (2012, "PLQ", 3, 31.0, 42.08, 31.20), (2014, "PQ", 1, 36.0, 31.95, 25.38), (2018, "PLQ", 1, 41.0, 41.52, 24.82), (2022, "CAQ", 1, 54.0, 37.42, 40.98), ] # Moyennes de long terme (2008-2022) — force de rappel des priors d'opposition. LONG_RUN_MEAN = {"CAQ": 30.0, "PLQ": 30.8, "PQ": 24.8, "QS": 9.8, "PCQ": 7.2, "AUT": 2.5} PERSISTENCE = 0.65 # part du vote précédent dans le prior des non-sortants RIDGE_LAMBDA = 4.0 # shrinkage de la régression (8 observations seulement) @dataclass class FundamentalsPrior: as_of: date incumbent: str mean_shares: np.ndarray # (6,) fractions, somme 1 x: np.ndarray # alr (5,) P: np.ndarray # covariance a priori alr (5,5) detail: dict = field(default_factory=dict) def _ridge_fit(exclude_year: int | None = None) -> tuple[np.ndarray, float, list[float]]: """Régression ridge « vote du sortant ~ vote précédent + satisfaction », avec pénalité d'usure retranchée AVANT l'ajustement (paramètre fixé, pas estimé — 8 points ne supportent pas un 3e prédicteur). `exclude_year` : LOEO strict pour le replay historique (§53 — l'élection testée ne participe JAMAIS à l'ajustement de son propre prior). Retourne (coefficients [b0, b_prev, b_sat], RMSE leave-one-out, résidus LOO).""" pen = settings.fundamentals_term_penalty_pp hist = [h for h in HISTORY if h[0] != exclude_year] X = np.array([[1.0, prev, sat] for (_, _, _, sat, prev, _) in hist]) y = np.array([res + pen * (terms - 1) for (_, _, terms, _, _, res) in hist]) # centrage des prédicteurs (le ridge ne pénalise pas l'ordonnée à l'origine) mx = X[:, 1:].mean(axis=0) Xc = X.copy(); Xc[:, 1:] -= mx def fit(Xf, yf): L = RIDGE_LAMBDA * np.eye(3); L[0, 0] = 0.0 return np.linalg.solve(Xf.T @ Xf + L, Xf.T @ yf) beta = fit(Xc, y) loo = [] for i in range(len(y)): m = np.ones(len(y), bool); m[i] = False b = fit(Xc[m], y[m]) loo.append(float(y[i] - Xc[i] @ b)) rmse = float(np.sqrt(np.mean(np.square(loo)))) # recentrage pour usage direct sur (1, prev, sat) b0 = float(beta[0] - beta[1] * mx[0] - beta[2] * mx[1]) return np.array([b0, beta[1], beta[2]]), rmse, [round(r, 2) for r in loo] def compute_prior(as_of: date, incumbent: str = "CAQ", terms: int = 2, satisfaction: float | None = None, prev_result: dict[str, float] | None = None, exclude_year: int | None = None, parties: list[str] | None = None) -> FundamentalsPrior: """Construit le prior de fondamentaux pour l'élection cible. prev_result : {party: %} de la dernière générale (défaut : 2022). satisfaction : % satisfaits du gouvernement (défaut : indicateur ingéré ou valeur de repli de configuration).""" ps = parties or settings.parties prev = prev_result or {"CAQ": 40.98, "PLQ": 14.37, "QS": 15.43, "PQ": 14.61, "PCQ": 12.91, "AUT": 1.70} sat = satisfaction if satisfaction is not None else settings.gov_satisfaction_fallback beta, loo_rmse, loo = _ridge_fit(exclude_year=exclude_year) pen = settings.fundamentals_term_penalty_pp * (terms - 1) inc_vote = float(beta @ np.array([1.0, prev.get(incumbent, 35.0), sat])) - pen inc_vote = float(np.clip(inc_vote, 8.0, 55.0)) # Opposition : persistance + rappel vers la moyenne de long terme, # renormalisée sur l'espace restant (100 − sortant). raw = {} for p in ps: if p == incumbent: continue raw[p] = PERSISTENCE * prev.get(p, 2.0) + (1 - PERSISTENCE) * LONG_RUN_MEAN.get(p, 3.0) rest = 100.0 - inc_vote tot = sum(raw.values()) shares = {p: v * rest / tot for p, v in raw.items()} shares[incumbent] = inc_vote vec = close(np.array([shares[p] for p in ps]) / 100.0) x_f = alr(vec) # Prior diffus : LOO ±rmse pp sur le sortant → sd alr large et honnête. sd = max(settings.fundamentals_prior_sd_alr, loo_rmse / 100.0 / max(vec.min(), 0.05)) P_f = np.eye(len(x_f)) * sd ** 2 return FundamentalsPrior( as_of=as_of, incumbent=incumbent, mean_shares=vec, x=x_f, P=P_f, detail={ "regression": {"b0": round(float(beta[0]), 2), "b_vote_precedent": round(float(beta[1]), 3), "b_satisfaction": round(float(beta[2]), 3), "loo_rmse_pp": round(loo_rmse, 2), "loo_residus": loo, "n_elections": len(HISTORY) - (1 if exclude_year else 0), "exclude_year": exclude_year}, "inputs": {"sortant": incumbent, "mandats": terms, "satisfaction": sat, "penalite_usure_pp": pen, "vote_precedent": {p: prev.get(p) for p in ps}}, "prior_pct": {p: round(float(vec[i] * 100), 1) for i, p in enumerate(ps)}, "sd_alr": round(sd, 3), }) def blend_weight(days_to_election: int) -> float: """Poids du prior selon l'horizon — nul le jour du vote, plafonné à w_max.""" if days_to_election <= 0: return 0.0 w = (days_to_election / settings.fundamentals_horizon_days) ** 1.3 return float(min(settings.fundamentals_max_weight, w)) def blend(x_polls: np.ndarray, P_polls: np.ndarray, prior: FundamentalsPrior, days_to_election: int, parties: list[str] | None = None) -> tuple[np.ndarray, np.ndarray, dict]: """Combinaison bayésienne précision-pondérée : Λ = Λ_sondages + w·Λ_prior.""" ps = parties or settings.parties w = blend_weight(days_to_election) M_ = len(x_polls) if w <= 0: return x_polls, P_polls, {"weight_schedule": 0.0, "precision_share": 0.0} Lp = np.linalg.inv(P_polls + 1e-10 * np.eye(M_)) Lf = np.linalg.inv(prior.P) * w Lc = Lp + Lf Pc = np.linalg.inv(Lc) xc = Pc @ (Lp @ x_polls + Lf @ prior.x) share = float(np.trace(Lf) / np.trace(Lc)) dpp = {p: round(float((inv_alr(xc)[i] - inv_alr(x_polls)[i]) * 100), 3) for i, p in enumerate(ps)} return xc, 0.5 * (Pc + Pc.T), { "weight_schedule": round(w, 4), "precision_share": round(share, 4), "delta_pp": dpp}