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%
5.2 KB · 118 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"""Scoring de prévisions électorales (v3 « 127 » — validation §20/§38).67Toutes les métriques standard au même endroit, réutilisées par le backtest,8le replay historique, l'ablation et le tournoi de modèles :9MAE/RMSE du vote, couverture d'intervalles multi-niveaux, Brier multiclasse,10log score, exactitude des vainqueurs, sharpness et biais signé.11"""12from __future__ import annotations1314import numpy as np151617def vote_errors(forecast: dict[str, float], actual: dict[str, float],18                parties: list[str]) -> dict:19    errs = [forecast[p] - actual[p] for p in parties if p in forecast and p in actual]20    return {"mae_pp": round(float(np.mean(np.abs(errs))), 3),21            "rmse_pp": round(float(np.sqrt(np.mean(np.square(errs)))), 3),22            "bias_pp": {p: round(forecast[p] - actual[p], 2)23                        for p in parties if p in forecast and p in actual}}242526def interval_coverage(summary: dict, actual: dict[str, float],27                      parties: list[str]) -> dict:28    """Couverture des intervalles 50/80/95 % (résumé {party:{p05..p95,lo95,hi95}})."""29    out = {}30    for level, lo_k, hi_k in [("50", "p25", "p75"), ("90", "p05", "p95"),31                              ("95", "lo95", "hi95")]:32        hits = [1.0 if summary[p].get(lo_k) is not None33                and summary[p][lo_k] <= actual[p] <= summary[p][hi_k] else 0.034                for p in parties if p in summary and p in actual35                and summary[p].get(lo_k) is not None]36        if hits:37            out[f"coverage_{level}"] = round(float(np.mean(hits)), 3)38    return out394041def winner_scores(district_probs: list[dict], actual_winners: dict[str, str],42                  parties: list[str]) -> dict:43    """district_probs: [{district, win_probs{party:p}, favorite}]."""44    briers, lls, correct, n = [], [], 0, 045    for d in district_probs:46        actual = actual_winners.get(d["district"])47        if actual is None:48            continue49        n += 150        wp = d["win_probs"]51        briers.append(sum((wp.get(p, 0.0) - (1.0 if p == actual else 0.0)) ** 252                          for p in parties))53        lls.append(-np.log(max(wp.get(actual, 0.0), 1e-6)))54        if d.get("favorite") == actual:55            correct += 156    if not n:57        return {}58    return {"brier": round(float(np.mean(briers)), 4),59            "log_loss": round(float(np.mean(lls)), 4),60            "winner_accuracy": round(correct / n, 4), "n_districts": n}616263def sharpness(summary: dict, parties: list[str]) -> float | None:64    """Largeur moyenne des IC 95 % (pp) — une prévision calibrée ET étroite65    domine; à calibration égale, plus petit = mieux."""66    widths = [summary[p]["hi95"] - summary[p]["lo95"]67              for p in parties if p in summary and "hi95" in summary[p]]68    return round(float(np.mean(widths)), 2) if widths else None697071def calibration_bins(probs: np.ndarray, outcomes: np.ndarray,72                     width: float = 0.1, min_n: int = 5) -> list[dict]:73    """Diagramme de fiabilité (bacs de largeur `width`)."""74    bins = []75    for lo in np.arange(0.0, 1.0, width):76        hi = lo + width77        m = (probs >= lo) & ((probs < hi) if hi < 1.0 else (probs <= 1.0))78        if m.sum() >= min_n:79            bins.append({"bin": f"{int(lo * 100)}–{int(hi * 100)} %",80                         "predicted": round(float(probs[m].mean()), 3),81                         "observed": round(float(outcomes[m].mean()), 3),82                         "n": int(m.sum())})83    return bins848586def crps_from_draws(draws: np.ndarray, actual: np.ndarray) -> float:87    """CRPS moyen par parti à partir d'échantillons du posterior (en pp).88    CRPS = E|X − y| − ½·E|X − X'| (estimateur par paires décalées)."""89    n = len(draws)90    term1 = np.abs(draws - actual[None, :]).mean(axis=0)91    perm = np.roll(np.arange(n), n // 2)92    term2 = 0.5 * np.abs(draws - draws[perm]).mean(axis=0)93    return round(float((term1 - term2).mean()), 3)949596def log_score_alr(x: np.ndarray, P: np.ndarray, actual_alr: np.ndarray) -> float:97    """Log-densité prédictive de la composition réelle sous le posterior alr98    (gaussienne multivariée) — plus haut = mieux."""99    Mx = len(x)100    diff = actual_alr - x101    Pi = P + 1e-10 * np.eye(Mx)102    sign, logdet = np.linalg.slogdet(Pi)103    return round(float(-0.5 * (Mx * np.log(2 * np.pi) + logdet104                               + diff @ np.linalg.inv(Pi) @ diff)), 3)105106107def score_snapshot(forecast_summary: dict, districts: list[dict],108                   actual_national: dict[str, float],109                   actual_winners: dict[str, str],110                   parties: list[str]) -> dict:111    """Tableau de bord complet d'un snapshot vs les résultats réels."""112    fc_mean = {p: forecast_summary[p]["mean"] for p in parties113               if p in forecast_summary}114    return {**vote_errors(fc_mean, actual_national, parties),115            **interval_coverage(forecast_summary, actual_national, parties),116            **winner_scores(districts, actual_winners, parties),117            "sharpness_pp": sharpness(forecast_summary, parties)}118