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"""House effects et cote de qualité des maisons de sondage.67Pour chaque élection passée dont on connaît le résultat réel, on compare les8sondages finaux (21 derniers jours de terrain) de chaque maison au résultat.9Le biais moyen par parti devient le « house effect », rétréci vers 010(shrinkage partiel — on reste incertain sur l'estimation du biais) et borné.1112La cote (weight_multiplier) module la variance d'observation dans le filtre :13une maison historiquement précise pèse un peu plus, jamais démesurément.14"""15from __future__ import annotations1617from dataclasses import dataclass, field18from datetime import date, timedelta1920import numpy as np2122from ..config import settings2324FINAL_WINDOW_DAYS = 21252627@dataclass28class PollsterProfile:29 name: str30 n_polls: int = 031 mae_pp: float | None = None32 house_effects: dict[str, float] = field(default_factory=dict) # pp, signé (sondage − réel)33 weight_multiplier: float = 1.034 detail: dict = field(default_factory=dict)353637def compute_profiles(polls: list[dict], elections: list[dict],38 parties: list[str] | None = None) -> dict[str, PollsterProfile]:39 """polls: [{pollster, field_end(date), shares{party: pct}}]40 elections: [{date(date), result{party: pct}}] — résultats réels connus."""41 parties = [p for p in (parties or settings.parties) if p != "AUT"]42 errs: dict[str, list[dict[str, float]]] = {}43 for el in elections:44 window_start = el["date"] - timedelta(days=FINAL_WINDOW_DAYS)45 for poll in polls:46 fe = poll["field_end"]47 if not (window_start <= fe <= el["date"]):48 continue49 dev = {}50 for p in parties:51 if p in poll["shares"] and p in el["result"]:52 dev[p] = poll["shares"][p] - el["result"][p]53 if len(dev) >= 4:54 errs.setdefault(poll["pollster"], []).append(dev)5556 profiles: dict[str, PollsterProfile] = {}57 all_maes = []58 for name, devs in errs.items():59 per_party = {p: float(np.mean([d[p] for d in devs if p in d])) for p in parties60 if any(p in d for d in devs)}61 mae = float(np.mean([abs(v) for d in devs for v in d.values()]))62 all_maes.append(mae)63 he = {p: float(np.clip(v * settings.house_effect_shrinkage,64 -settings.house_effect_max_pp,65 settings.house_effect_max_pp))66 for p, v in per_party.items()}67 profiles[name] = PollsterProfile(68 name=name, n_polls=len(devs), mae_pp=round(mae, 2), house_effects=he,69 detail={"raw_bias_pp": {k: round(v, 2) for k, v in per_party.items()},70 "final_polls_used": len(devs)})7172 median_mae = float(np.median(all_maes)) if all_maes else 2.073 for prof in profiles.values():74 if prof.mae_pp and prof.mae_pp > 0:75 prof.weight_multiplier = float(np.clip(median_mae / prof.mae_pp, 0.6, 1.4))76 return profiles777879def profile_for(profiles: dict[str, PollsterProfile], name: str) -> PollsterProfile:80 """Maison inconnue : aucun biais estimé, cote légèrement prudente."""81 return profiles.get(name) or PollsterProfile(82 name=name, weight_multiplier=0.9,83 detail={"note": "aucune performance historique — biais supposé nul, incertitude accrue"})848586def adjust_shares(shares: dict[str, float], profile: PollsterProfile) -> dict[str, float]:87 """Corrige partiellement le biais maison, puis renormalise à 100."""88 adj = {p: max(0.1, v - profile.house_effects.get(p, 0.0)) for p, v in shares.items()}89 total = sum(adj.values())90 return {p: v * 100.0 / total for p, v in adj.items()}91