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"""Prior de fondamentaux — le forecast ne repose plus uniquement sur les sondages.67Idée (Abramowitz, Hibbs, The Economist/538 appliqués au Québec) : longtemps avant8le scrutin, les sondages sont peu informatifs sur le résultat final; des variables9structurelles le sont davantage :1011 * la **satisfaction envers le gouvernement** (baromètres Léger/CROP) — le12 prédicteur historique le plus fort du vote du parti sortant au Québec;13 * le **vote à l'élection précédente** (persistance des coalitions);14 * l'**usure du pouvoir** (pénalité par mandat au-delà du premier).1516Le vote du parti sortant est estimé par une régression ridge sur les huit17générales de 1998 à 2022 (validation leave-one-out → variance honnête). Les18partis d'opposition reçoivent un prior de persistance (vote précédent, rappelé19vers leur moyenne de long terme). Le tout devient une distribution a priori en20espace log-ratio, combinée au posterior des sondages par pondération de21précision, avec un poids qui décroît vers zéro à l'approche du scrutin :2223 w(t) = min(w_max, (jours_restants / horizon)^1.3)2425À 18 mois, les fondamentaux comptent; la veille du vote, ils ne comptent26presque plus — exactement le comportement souhaité.27"""28from __future__ import annotations2930from dataclasses import dataclass, field31from datetime import date3233import numpy as np3435from ..config import settings36from .compositions import alr, close, inv_alr3738# ---------------------------------------------------------------------------39# Élections générales québécoises 1998-2022 — variables de fondamentaux.40# satisfaction : % « satisfaits du gouvernement » pré-électoral (Léger/CROP,41# reconstitution documentée — valeurs approximatives au point de pourcentage42# près, suffisant pour une régression à 8 observations).43# ---------------------------------------------------------------------------44HISTORY = [45 # (année, parti sortant, mandats, satisfaction, vote précédent du sortant, résultat du sortant)46 (1998, "PQ", 1, 52.0, 44.75, 42.87),47 (2003, "PQ", 2, 45.0, 42.87, 33.24),48 (2007, "PLQ", 1, 36.0, 45.99, 33.08),49 (2008, "PLQ", 2, 52.0, 33.08, 42.08),50 (2012, "PLQ", 3, 31.0, 42.08, 31.20),51 (2014, "PQ", 1, 36.0, 31.95, 25.38),52 (2018, "PLQ", 1, 41.0, 41.52, 24.82),53 (2022, "CAQ", 1, 54.0, 37.42, 40.98),54]5556# Moyennes de long terme (2008-2022) — force de rappel des priors d'opposition.57LONG_RUN_MEAN = {"CAQ": 30.0, "PLQ": 30.8, "PQ": 24.8, "QS": 9.8, "PCQ": 7.2, "AUT": 2.5}58PERSISTENCE = 0.65 # part du vote précédent dans le prior des non-sortants5960RIDGE_LAMBDA = 4.0 # shrinkage de la régression (8 observations seulement)616263@dataclass64class FundamentalsPrior:65 as_of: date66 incumbent: str67 mean_shares: np.ndarray # (6,) fractions, somme 168 x: np.ndarray # alr (5,)69 P: np.ndarray # covariance a priori alr (5,5)70 detail: dict = field(default_factory=dict)717273def _ridge_fit(exclude_year: int | None = None) -> tuple[np.ndarray, float, list[float]]:74 """Régression ridge « vote du sortant ~ vote précédent + satisfaction »,75 avec pénalité d'usure retranchée AVANT l'ajustement (paramètre fixé, pas76 estimé — 8 points ne supportent pas un 3e prédicteur).77 `exclude_year` : LOEO strict pour le replay historique (§53 — l'élection78 testée ne participe JAMAIS à l'ajustement de son propre prior).79 Retourne (coefficients [b0, b_prev, b_sat], RMSE leave-one-out, résidus LOO)."""80 pen = settings.fundamentals_term_penalty_pp81 hist = [h for h in HISTORY if h[0] != exclude_year]82 X = np.array([[1.0, prev, sat] for (_, _, _, sat, prev, _) in hist])83 y = np.array([res + pen * (terms - 1) for (_, _, terms, _, _, res) in hist])84 # centrage des prédicteurs (le ridge ne pénalise pas l'ordonnée à l'origine)85 mx = X[:, 1:].mean(axis=0)86 Xc = X.copy(); Xc[:, 1:] -= mx8788 def fit(Xf, yf):89 L = RIDGE_LAMBDA * np.eye(3); L[0, 0] = 0.090 return np.linalg.solve(Xf.T @ Xf + L, Xf.T @ yf)9192 beta = fit(Xc, y)93 loo = []94 for i in range(len(y)):95 m = np.ones(len(y), bool); m[i] = False96 b = fit(Xc[m], y[m])97 loo.append(float(y[i] - Xc[i] @ b))98 rmse = float(np.sqrt(np.mean(np.square(loo))))99 # recentrage pour usage direct sur (1, prev, sat)100 b0 = float(beta[0] - beta[1] * mx[0] - beta[2] * mx[1])101 return np.array([b0, beta[1], beta[2]]), rmse, [round(r, 2) for r in loo]102103104def compute_prior(as_of: date, incumbent: str = "CAQ", terms: int = 2,105 satisfaction: float | None = None,106 prev_result: dict[str, float] | None = None,107 exclude_year: int | None = None,108 parties: list[str] | None = None) -> FundamentalsPrior:109 """Construit le prior de fondamentaux pour l'élection cible.110111 prev_result : {party: %} de la dernière générale (défaut : 2022).112 satisfaction : % satisfaits du gouvernement (défaut : indicateur ingéré ou113 valeur de repli de configuration)."""114 ps = parties or settings.parties115 prev = prev_result or {"CAQ": 40.98, "PLQ": 14.37, "QS": 15.43,116 "PQ": 14.61, "PCQ": 12.91, "AUT": 1.70}117 sat = satisfaction if satisfaction is not None else settings.gov_satisfaction_fallback118119 beta, loo_rmse, loo = _ridge_fit(exclude_year=exclude_year)120 pen = settings.fundamentals_term_penalty_pp * (terms - 1)121 inc_vote = float(beta @ np.array([1.0, prev.get(incumbent, 35.0), sat])) - pen122 inc_vote = float(np.clip(inc_vote, 8.0, 55.0))123124 # Opposition : persistance + rappel vers la moyenne de long terme,125 # renormalisée sur l'espace restant (100 − sortant).126 raw = {}127 for p in ps:128 if p == incumbent:129 continue130 raw[p] = PERSISTENCE * prev.get(p, 2.0) + (1 - PERSISTENCE) * LONG_RUN_MEAN.get(p, 3.0)131 rest = 100.0 - inc_vote132 tot = sum(raw.values())133 shares = {p: v * rest / tot for p, v in raw.items()}134 shares[incumbent] = inc_vote135136 vec = close(np.array([shares[p] for p in ps]) / 100.0)137 x_f = alr(vec)138 # Prior diffus : LOO ±rmse pp sur le sortant → sd alr large et honnête.139 sd = max(settings.fundamentals_prior_sd_alr, loo_rmse / 100.0 / max(vec.min(), 0.05))140 P_f = np.eye(len(x_f)) * sd ** 2141142 return FundamentalsPrior(143 as_of=as_of, incumbent=incumbent, mean_shares=vec, x=x_f, P=P_f,144 detail={145 "regression": {"b0": round(float(beta[0]), 2),146 "b_vote_precedent": round(float(beta[1]), 3),147 "b_satisfaction": round(float(beta[2]), 3),148 "loo_rmse_pp": round(loo_rmse, 2), "loo_residus": loo,149 "n_elections": len(HISTORY) - (1 if exclude_year else 0),150 "exclude_year": exclude_year},151 "inputs": {"sortant": incumbent, "mandats": terms, "satisfaction": sat,152 "penalite_usure_pp": pen,153 "vote_precedent": {p: prev.get(p) for p in ps}},154 "prior_pct": {p: round(float(vec[i] * 100), 1)155 for i, p in enumerate(ps)},156 "sd_alr": round(sd, 3),157 })158159160def blend_weight(days_to_election: int) -> float:161 """Poids du prior selon l'horizon — nul le jour du vote, plafonné à w_max."""162 if days_to_election <= 0:163 return 0.0164 w = (days_to_election / settings.fundamentals_horizon_days) ** 1.3165 return float(min(settings.fundamentals_max_weight, w))166167168def blend(x_polls: np.ndarray, P_polls: np.ndarray, prior: FundamentalsPrior,169 days_to_election: int,170 parties: list[str] | None = None) -> tuple[np.ndarray, np.ndarray, dict]:171 """Combinaison bayésienne précision-pondérée : Λ = Λ_sondages + w·Λ_prior."""172 ps = parties or settings.parties173 w = blend_weight(days_to_election)174 M_ = len(x_polls)175 if w <= 0:176 return x_polls, P_polls, {"weight_schedule": 0.0, "precision_share": 0.0}177 Lp = np.linalg.inv(P_polls + 1e-10 * np.eye(M_))178 Lf = np.linalg.inv(prior.P) * w179 Lc = Lp + Lf180 Pc = np.linalg.inv(Lc)181 xc = Pc @ (Lp @ x_polls + Lf @ prior.x)182 share = float(np.trace(Lf) / np.trace(Lc))183 dpp = {p: round(float((inv_alr(xc)[i] - inv_alr(x_polls)[i]) * 100), 3)184 for i, p in enumerate(ps)}185 return xc, 0.5 * (Pc + Pc.T), {186 "weight_schedule": round(w, 4), "precision_share": round(share, 4),187 "delta_pp": dpp}188