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%
3.8 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"""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