SPB Git forge

spb/qc-election

Public
20commits 1branches 0releases
4.9 MBsize
maindefault branch
20 days agolast push
Python 66.6% HTML 24.8% CSS 4.9% JavaScript 3.6%
8.5 KB · 188 lines python
Raw Blame History
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