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"""Nowcast (« si l'élection avait lieu aujourd'hui ») et Forecast (jour du vote).67Le forecast ajoute l'incertitude d'évolution future de l'opinion : la variance8de la marche aléatoire s'accumule jusqu'au jour de l'élection, gonflée d'un9facteur de volatilité de campagne (les campagnes bougent plus que les périodes10calmes — estimé sur les cycles québécois passés).11"""12from __future__ import annotations1314from dataclasses import dataclass15from datetime import date1617import numpy as np1819from ..config import settings20from .compositions import inv_alr21from .trend import M, TrendResult222324@dataclass25class VoteDistribution:26 kind: str # "nowcast" | "forecast"27 as_of: date28 x: np.ndarray # moyenne de l'état (5,)29 P: np.ndarray # covariance (5,5)30 summary: dict # {party: {mean, p05, p25, p50, p75, p95, lo95, hi95}}313233def _summarize(x: np.ndarray, P: np.ndarray, rng: np.random.Generator,34 n: int = 20000, parties: list[str] | None = None) -> dict:35 ps = parties or settings.parties36 Mx = len(x)37 L = np.linalg.cholesky(P + 1e-10 * np.eye(Mx))38 draws = inv_alr(x + rng.standard_normal((n, Mx)) @ L.T) * 100.039 out = {}40 for i, party in enumerate(ps):41 d = draws[:, i]42 out[party] = {43 "mean": round(float(d.mean()), 2),44 "p05": round(float(np.percentile(d, 5)), 2),45 "p25": round(float(np.percentile(d, 25)), 2),46 "p50": round(float(np.percentile(d, 50)), 2),47 "p75": round(float(np.percentile(d, 75)), 2),48 "p95": round(float(np.percentile(d, 95)), 2),49 "lo95": round(float(np.percentile(d, 2.5)), 2),50 "hi95": round(float(np.percentile(d, 97.5)), 2),51 }52 return out535455def nowcast(trend: TrendResult, as_of: date,56 rng: np.random.Generator | None = None,57 parties: list[str] | None = None) -> VoteDistribution:58 rng = rng or np.random.default_rng(1)59 return VoteDistribution("nowcast", as_of, trend.x, trend.P,60 _summarize(trend.x, trend.P, rng, parties=parties))616263def forecast(trend: TrendResult, as_of: date, election_day: date,64 rng: np.random.Generator | None = None,65 drift_multiplier: float = 1.0,66 parties: list[str] | None = None,67 industry_sd: float | None = None) -> VoteDistribution:68 """drift_multiplier ∈ [1, 1,5] : turbulence détectée (pic d'attention web)69 → l'incertitude d'évolution future est gonflée, la moyenne jamais touchée.70 industry_sd : erreur systémique de sondage (défaut : config; le replay et71 le modèle d'erreur des sondeurs peuvent passer une valeur estimée LOEO)."""72 rng = rng or np.random.default_rng(2)73 Mx = len(trend.x)74 days = max(0, (election_day - as_of).days)75 drift_var = trend.q * days * settings.campaign_drift_inflation * drift_multiplier76 # + erreur systémique de sondage (corrélée entre partis via la composition) :77 # même la veille du vote, l'industrie entière peut se tromper de ~2-3 pp.78 sd = settings.industry_error_sd if industry_sd is None else industry_sd79 P_f = trend.P + (drift_var + sd ** 2) * np.eye(Mx)80 return VoteDistribution("forecast", as_of, trend.x, P_f,81 _summarize(trend.x, P_f, rng, parties=parties))828384def distribution_from(kind: str, as_of: date, x: np.ndarray, P: np.ndarray,85 seed: int = 3,86 parties: list[str] | None = None) -> VoteDistribution:87 """Distribution à partir d'un état (x, P) arbitraire — utilisé par les88 couches v2 (blend fondamentaux, ajustement médias) et le replay."""89 rng = np.random.default_rng(seed)90 return VoteDistribution(kind, as_of, x, P, _summarize(x, P, rng, parties=parties))91