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%
4.0 KB · 91 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"""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