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