# 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 """Nowcast (« si l'élection avait lieu aujourd'hui ») et Forecast (jour du vote). Le forecast ajoute l'incertitude d'évolution future de l'opinion : la variance de la marche aléatoire s'accumule jusqu'au jour de l'élection, gonflée d'un facteur de volatilité de campagne (les campagnes bougent plus que les périodes calmes — estimé sur les cycles québécois passés). """ from __future__ import annotations from dataclasses import dataclass from datetime import date import numpy as np from ..config import settings from .compositions import inv_alr from .trend import M, TrendResult @dataclass class VoteDistribution: kind: str # "nowcast" | "forecast" as_of: date x: np.ndarray # moyenne de l'état (5,) P: np.ndarray # covariance (5,5) summary: dict # {party: {mean, p05, p25, p50, p75, p95, lo95, hi95}} def _summarize(x: np.ndarray, P: np.ndarray, rng: np.random.Generator, n: int = 20000, parties: list[str] | None = None) -> dict: ps = parties or settings.parties Mx = len(x) L = np.linalg.cholesky(P + 1e-10 * np.eye(Mx)) draws = inv_alr(x + rng.standard_normal((n, Mx)) @ L.T) * 100.0 out = {} for i, party in enumerate(ps): d = draws[:, i] out[party] = { "mean": round(float(d.mean()), 2), "p05": round(float(np.percentile(d, 5)), 2), "p25": round(float(np.percentile(d, 25)), 2), "p50": round(float(np.percentile(d, 50)), 2), "p75": round(float(np.percentile(d, 75)), 2), "p95": round(float(np.percentile(d, 95)), 2), "lo95": round(float(np.percentile(d, 2.5)), 2), "hi95": round(float(np.percentile(d, 97.5)), 2), } return out def nowcast(trend: TrendResult, as_of: date, rng: np.random.Generator | None = None, parties: list[str] | None = None) -> VoteDistribution: rng = rng or np.random.default_rng(1) return VoteDistribution("nowcast", as_of, trend.x, trend.P, _summarize(trend.x, trend.P, rng, parties=parties)) def forecast(trend: TrendResult, as_of: date, election_day: date, rng: np.random.Generator | None = None, drift_multiplier: float = 1.0, parties: list[str] | None = None, industry_sd: float | None = None) -> VoteDistribution: """drift_multiplier ∈ [1, 1,5] : turbulence détectée (pic d'attention web) → l'incertitude d'évolution future est gonflée, la moyenne jamais touchée. industry_sd : erreur systémique de sondage (défaut : config; le replay et le modèle d'erreur des sondeurs peuvent passer une valeur estimée LOEO).""" rng = rng or np.random.default_rng(2) Mx = len(trend.x) days = max(0, (election_day - as_of).days) drift_var = trend.q * days * settings.campaign_drift_inflation * drift_multiplier # + erreur systémique de sondage (corrélée entre partis via la composition) : # même la veille du vote, l'industrie entière peut se tromper de ~2-3 pp. sd = settings.industry_error_sd if industry_sd is None else industry_sd P_f = trend.P + (drift_var + sd ** 2) * np.eye(Mx) return VoteDistribution("forecast", as_of, trend.x, P_f, _summarize(trend.x, P_f, rng, parties=parties)) def distribution_from(kind: str, as_of: date, x: np.ndarray, P: np.ndarray, seed: int = 3, parties: list[str] | None = None) -> VoteDistribution: """Distribution à partir d'un état (x, P) arbitraire — utilisé par les couches v2 (blend fondamentaux, ajustement médias) et le replay.""" rng = np.random.default_rng(seed) return VoteDistribution(kind, as_of, x, P, _summarize(x, P, rng, parties=parties))