# 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 """House effects et cote de qualité des maisons de sondage. Pour chaque élection passée dont on connaît le résultat réel, on compare les sondages finaux (21 derniers jours de terrain) de chaque maison au résultat. Le biais moyen par parti devient le « house effect », rétréci vers 0 (shrinkage partiel — on reste incertain sur l'estimation du biais) et borné. La cote (weight_multiplier) module la variance d'observation dans le filtre : une maison historiquement précise pèse un peu plus, jamais démesurément. """ from __future__ import annotations from dataclasses import dataclass, field from datetime import date, timedelta import numpy as np from ..config import settings FINAL_WINDOW_DAYS = 21 @dataclass class PollsterProfile: name: str n_polls: int = 0 mae_pp: float | None = None house_effects: dict[str, float] = field(default_factory=dict) # pp, signé (sondage − réel) weight_multiplier: float = 1.0 detail: dict = field(default_factory=dict) def compute_profiles(polls: list[dict], elections: list[dict], parties: list[str] | None = None) -> dict[str, PollsterProfile]: """polls: [{pollster, field_end(date), shares{party: pct}}] elections: [{date(date), result{party: pct}}] — résultats réels connus.""" parties = [p for p in (parties or settings.parties) if p != "AUT"] errs: dict[str, list[dict[str, float]]] = {} for el in elections: window_start = el["date"] - timedelta(days=FINAL_WINDOW_DAYS) for poll in polls: fe = poll["field_end"] if not (window_start <= fe <= el["date"]): continue dev = {} for p in parties: if p in poll["shares"] and p in el["result"]: dev[p] = poll["shares"][p] - el["result"][p] if len(dev) >= 4: errs.setdefault(poll["pollster"], []).append(dev) profiles: dict[str, PollsterProfile] = {} all_maes = [] for name, devs in errs.items(): per_party = {p: float(np.mean([d[p] for d in devs if p in d])) for p in parties if any(p in d for d in devs)} mae = float(np.mean([abs(v) for d in devs for v in d.values()])) all_maes.append(mae) he = {p: float(np.clip(v * settings.house_effect_shrinkage, -settings.house_effect_max_pp, settings.house_effect_max_pp)) for p, v in per_party.items()} profiles[name] = PollsterProfile( name=name, n_polls=len(devs), mae_pp=round(mae, 2), house_effects=he, detail={"raw_bias_pp": {k: round(v, 2) for k, v in per_party.items()}, "final_polls_used": len(devs)}) median_mae = float(np.median(all_maes)) if all_maes else 2.0 for prof in profiles.values(): if prof.mae_pp and prof.mae_pp > 0: prof.weight_multiplier = float(np.clip(median_mae / prof.mae_pp, 0.6, 1.4)) return profiles def profile_for(profiles: dict[str, PollsterProfile], name: str) -> PollsterProfile: """Maison inconnue : aucun biais estimé, cote légèrement prudente.""" return profiles.get(name) or PollsterProfile( name=name, weight_multiplier=0.9, detail={"note": "aucune performance historique — biais supposé nul, incertitude accrue"}) def adjust_shares(shares: dict[str, float], profile: PollsterProfile) -> dict[str, float]: """Corrige partiellement le biais maison, puis renormalise à 100.""" adj = {p: max(0.1, v - profile.house_effects.get(p, 0.0)) for p, v in shares.items()} total = sum(adj.values()) return {p: v * 100.0 / total for p, v in adj.items()}