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"""Distribution prédictive du PROCHAIN sondage (§46-48).67À chaque run : P(prochain sondage | état du modèle) — l'état latent (nowcast)8plus le bruit typique d'un sondage (échantillonnage n≈900, erreur excédentaire,9maison inconnue). Quand un sondage arrive, sa SURPRISE est décomposée :1011 * z par parti vs l'intervalle prédit;12 * p-value prédictive globale (densité alr);13 * lecture : « bruit d'échantillonnage » (|z|max < 1,6), « à surveiller »14 (< 2,6), « mouvement réel probable ou sondage aberrant » (≥ 2,6) —15 un sondage extrême N'EST PAS automatiquement un mouvement (§48).16"""17from __future__ import annotations1819import numpy as np2021from ...config import settings22from ..compositions import alr, alr_obs_cov, close, inv_alr2324TYPICAL_N_EFF = 900.0252627def predictive(x: np.ndarray, P: np.ndarray,28 parties: list[str] | None = None,29 n_eff: float = TYPICAL_N_EFF,30 seed: int = 46) -> dict:31 """Distribution du prochain sondage publié : état ⊕ bruit d'observation."""32 ps = parties or settings.parties33 mean_shares = inv_alr(x)34 R = alr_obs_cov(mean_shares, n_eff,35 excess_pp=settings.excess_poll_sd_pp * 1.1)36 cov = P + R37 rng = np.random.default_rng(seed)38 L = np.linalg.cholesky(cov + 1e-10 * np.eye(len(x)))39 draws = inv_alr(x + rng.standard_normal((8000, len(x))) @ L.T) * 100.040 out = {}41 for i, p in enumerate(ps):42 d = draws[:, i]43 out[p] = {"p50": round(float(np.percentile(d, 50)), 1),44 "p10": round(float(np.percentile(d, 10)), 1),45 "p90": round(float(np.percentile(d, 90)), 1),46 "sd": round(float(d.std()), 2)}47 return {"parties": out, "n_eff_hypothese": n_eff,48 "x": [float(v) for v in x],49 "cov": [[float(v) for v in row] for row in cov],50 "note": ("Ce que le modèle s'attend à voir dans le prochain sondage "51 "publié (maison typique, n≈900) — sert à mesurer la "52 "SURPRISE de chaque nouveau sondage.")}535455def surprise(pred: dict, shares: dict[str, float],56 parties: list[str] | None = None) -> dict:57 """Surprise d'un sondage observé vs la distribution prédite (§47-48)."""58 ps = parties or settings.parties59 per, z_list = {}, []60 for p in ps:61 if p not in shares or p not in pred["parties"]:62 continue63 pp = pred["parties"][p]64 z = (shares[p] - pp["p50"]) / max(pp["sd"], 0.1)65 per[p] = {"observé": round(shares[p], 1), "attendu": pp["p50"],66 "z": round(float(z), 2)}67 z_list.append(abs(z))68 z_max = max(z_list) if z_list else 0.069 # densité prédictive alr du sondage observé70 x = np.array(pred["x"]); cov = np.array(pred["cov"])71 obs_vec = close(np.array([shares.get(p, 0.5) for p in ps]) / 100.0)72 diff = alr(obs_vec) - x73 sign, logdet = np.linalg.slogdet(cov)74 lpd = float(-0.5 * (len(x) * np.log(2 * np.pi) + logdet75 + diff @ np.linalg.inv(cov) @ diff))76 verdict = ("bruit d'échantillonnage" if z_max < 1.6 else77 "à surveiller" if z_max < 2.6 else78 "mouvement réel probable OU sondage aberrant")79 return {"per_party": per, "z_max": round(z_max, 2),80 "log_pred_density": round(lpd, 2), "verdict": verdict}81