# 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 """Modèle dynamique d'erreur des sondeurs (§6-§10) — v1 multi-élections. Sépare explicitement, à partir des 6 élections rejouables (2007-2022) : * **erreur d'industrie** `industry_error[élection, parti]` : biais PARTAGÉ des sondages finaux (fenêtre 21 j) vs le résultat — 2022 n'était pas un accident : chaque élection a son erreur commune. σ_industrie devient une quantité ESTIMÉE (espace alr, LOEO : jamais sur l'élection testée) au lieu du 0,12 calibré à la main sur 2018/2022 (STATISTICAL_DEBT §industry). * **house effects hiérarchiques** : h[maison] global (rétréci par le nombre d'élections observées n/(n+k)) + déviation par élection (τ) — un sondeur vu 1 fois garde une GRANDE incertitude au lieu d'un simple malus. Un plancher conservateur (`floor`) protège contre la sous-estimation quand l'historique est court (§60 : priors conservateurs si données faibles).""" from __future__ import annotations from datetime import timedelta import numpy as np from ...config import settings from ..compositions import alr, close FINAL_WINDOW_DAYS = 21 SIGMA_FLOOR = 0.08 # plancher conservateur (alr) — jamais en dessous SIGMA_CAP = 0.25 # plafond de sécurité SHRINK_K = 2.0 # h_global rétréci par n/(n+k) def _final_avg_shares(polls: list[dict], eday, parties: list[str]) -> np.ndarray | None: """Moyenne des sondages des 21 derniers jours (fractions, ordre `parties`).""" window = [p for p in polls if (eday - timedelta(days=FINAL_WINDOW_DAYS)) <= p["field_end"] <= eday] if len(window) < 2: return None rows = [] for p in window: v = np.array([p["shares"].get(pt, 0.0) for pt in parties]) if v[:len(parties) - 1].sum() > 60: rows.append(close(v / max(v.sum(), 1e-9))) return np.mean(rows, axis=0) if len(rows) >= 2 else None _CACHE: dict = {} def industry_errors() -> dict[int, dict]: """Erreur d'industrie par élection : alr(moyenne finale) − alr(résultat). (Mise en cache processus — relit les CSV une seule fois.)""" if "errs" in _CACHE: return _CACHE["errs"] from ...ingest.wikipedia_historical import ELECTIONS, load_polls, to_model_polls out: dict[int, dict] = {} for yr, meta in ELECTIONS.items(): parties = meta["parties"] polls = to_model_polls(load_polls(yr)) avg = _final_avg_shares(polls, meta["date"], parties) if avg is None: continue actual = close(np.array([meta["result"][p] for p in parties]) / 100.0) delta_alr = alr(avg) - alr(actual) delta_pp = (avg - actual) * 100.0 # composantes « principales » : le log-ratio d'un parti à 3 % explose # pour 1 pp d'erreur — l'estimation de σ ne retient que les composantes # dont le parti pèse ≥ 5 % (la référence alr est toujours principale). main = [float(delta_alr[i - 1]) for i in range(1, len(parties)) if actual[i] >= 0.05] out[yr] = {"parties": parties, "delta_alr": [round(float(v), 4) for v in delta_alr], "main_delta_alr": [round(v, 4) for v in main], "delta_pp": {p: round(float(delta_pp[i]), 2) for i, p in enumerate(parties)}, "rms_alr_main": round(float(np.sqrt(np.mean( np.square(main)))), 4) if main else None} _CACHE["errs"] = out return out def industry_sigma_loeo(test_year: int | None = None) -> float: """σ_industrie (alr) estimé sur les élections ≠ test_year : RMS des erreurs d'industrie composante par composante, borné [plancher, plafond].""" errs = industry_errors() vals = [v for yr, e in errs.items() if yr != test_year for v in e["main_delta_alr"]] if len(vals) < 6: return settings.industry_error_sd sigma = float(np.sqrt(np.mean(np.square(vals)))) return float(np.clip(sigma, SIGMA_FLOOR, SIGMA_CAP)) def hierarchical_house_effects() -> dict: """h[maison, parti] : moyenne inter-élections rétrécie n/(n+k), avec la dispersion inter-élections τ (les maisons vues 1 fois restent incertaines).""" from ...ingest.wikipedia_historical import ELECTIONS, load_polls, to_model_polls per: dict[str, dict[str, list[float]]] = {} for yr, meta in ELECTIONS.items(): parties = [p for p in meta["parties"] if p != "AUT"] eday = meta["date"] for p in to_model_polls(load_polls(yr)): if not ((eday - timedelta(days=FINAL_WINDOW_DAYS)) <= p["field_end"] <= eday): continue for pt in parties: if pt in p["shares"] and pt in meta["result"]: per.setdefault(p["pollster"], {}).setdefault( f"{yr}:{pt}", []).append( p["shares"][pt] - meta["result"][pt]) out = {} for house, cells in per.items(): by_party: dict[str, list[float]] = {} elections = set() for key, devs in cells.items(): yr, pt = key.split(":") elections.add(yr) by_party.setdefault(pt, []).append(float(np.mean(devs))) n_e = len(elections) shrink = n_e / (n_e + SHRINK_K) h = {pt: round(float(np.mean(v)) * shrink, 2) for pt, v in by_party.items()} tau = round(float(np.mean([np.std(v) for v in by_party.values() if len(v) >= 2]) if any( len(v) >= 2 for v in by_party.values()) else 3.0), 2) out[house] = {"n_elections": n_e, "shrink": round(shrink, 2), "house_effect_pp": h, "tau_pp": tau} return out def report() -> dict: errs = industry_errors() return { "industry_errors": {str(k): v for k, v in errs.items()}, "industry_sigma_alr": { "all": round(industry_sigma_loeo(None), 4), "loeo": {str(yr): round(industry_sigma_loeo(yr), 4) for yr in errs}, "config_manual": settings.industry_error_sd, "floor": SIGMA_FLOOR, "cap": SIGMA_CAP}, "house_effects_hierarchical": hierarchical_house_effects(), "note": ("Erreur d'industrie = biais PARTAGÉ des sondages finaux; " "σ estimé LOEO remplace le 0,12 manuel dans le replay; la " "production l'adopte via use_empirical_industry_error."), }