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%
3.5 KB · 81 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"""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