# 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 """Distribution prédictive du PROCHAIN sondage (§46-48). À chaque run : P(prochain sondage | état du modèle) — l'état latent (nowcast) plus le bruit typique d'un sondage (échantillonnage n≈900, erreur excédentaire, maison inconnue). Quand un sondage arrive, sa SURPRISE est décomposée : * z par parti vs l'intervalle prédit; * p-value prédictive globale (densité alr); * lecture : « bruit d'échantillonnage » (|z|max < 1,6), « à surveiller » (< 2,6), « mouvement réel probable ou sondage aberrant » (≥ 2,6) — un sondage extrême N'EST PAS automatiquement un mouvement (§48). """ from __future__ import annotations import numpy as np from ...config import settings from ..compositions import alr, alr_obs_cov, close, inv_alr TYPICAL_N_EFF = 900.0 def predictive(x: np.ndarray, P: np.ndarray, parties: list[str] | None = None, n_eff: float = TYPICAL_N_EFF, seed: int = 46) -> dict: """Distribution du prochain sondage publié : état ⊕ bruit d'observation.""" ps = parties or settings.parties mean_shares = inv_alr(x) R = alr_obs_cov(mean_shares, n_eff, excess_pp=settings.excess_poll_sd_pp * 1.1) cov = P + R rng = np.random.default_rng(seed) L = np.linalg.cholesky(cov + 1e-10 * np.eye(len(x))) draws = inv_alr(x + rng.standard_normal((8000, len(x))) @ L.T) * 100.0 out = {} for i, p in enumerate(ps): d = draws[:, i] out[p] = {"p50": round(float(np.percentile(d, 50)), 1), "p10": round(float(np.percentile(d, 10)), 1), "p90": round(float(np.percentile(d, 90)), 1), "sd": round(float(d.std()), 2)} return {"parties": out, "n_eff_hypothese": n_eff, "x": [float(v) for v in x], "cov": [[float(v) for v in row] for row in cov], "note": ("Ce que le modèle s'attend à voir dans le prochain sondage " "publié (maison typique, n≈900) — sert à mesurer la " "SURPRISE de chaque nouveau sondage.")} def surprise(pred: dict, shares: dict[str, float], parties: list[str] | None = None) -> dict: """Surprise d'un sondage observé vs la distribution prédite (§47-48).""" ps = parties or settings.parties per, z_list = {}, [] for p in ps: if p not in shares or p not in pred["parties"]: continue pp = pred["parties"][p] z = (shares[p] - pp["p50"]) / max(pp["sd"], 0.1) per[p] = {"observé": round(shares[p], 1), "attendu": pp["p50"], "z": round(float(z), 2)} z_list.append(abs(z)) z_max = max(z_list) if z_list else 0.0 # densité prédictive alr du sondage observé x = np.array(pred["x"]); cov = np.array(pred["cov"]) obs_vec = close(np.array([shares.get(p, 0.5) for p in ps]) / 100.0) diff = alr(obs_vec) - x sign, logdet = np.linalg.slogdet(cov) lpd = float(-0.5 * (len(x) * np.log(2 * np.pi) + logdet + diff @ np.linalg.inv(cov) @ diff)) verdict = ("bruit d'échantillonnage" if z_max < 1.6 else "à surveiller" if z_max < 2.6 else "mouvement réel probable OU sondage aberrant") return {"per_party": per, "z_max": round(z_max, 2), "log_pred_density": round(lpd, 2), "verdict": verdict}