QC Signal (refonte frontend complète) + distribution prédictive du prochain sondage
Frontend — design system « QC Signal » (sous-agent, 11 pages × 4 viewports, zéro erreur JS, zéro débordement, light+dark dédiés) : - Tokens complets (palettes, radius 8-24, spacing 4/8, ombres discrètes), échelle typo (metric-xl 64px, tabular-nums partout), navbar + fleur SVG, FORECAST STRIP sticky sur toutes les pages (P(1er) publiée + Δ veille) - Accueil recomposé : verdict éditorial → MAIN OUTCOME (métrique héroïque, intervalles VISUELS 24 ───●─── 86) → HÉMICYCLE SVG 127 sièges (marqueur majorité 64, hover IC) → OUTCOME BAR (majorité/minoritaire/autre) → cartes partis compactes data-first (sparklines, deltas neutres jamais vert/rouge) → mouvement + pivots - /aujourdhui briefing éditorial, /carte 75/25, tables sticky-header, composants deltaHTML/rangeHTML/emptyState/hemicycleSVG, skeletons - docs/DESIGN_SYSTEM.md + docs/UI_AUDIT.md (18 dettes retirées) Backend : - national/next_poll.py (§46-48) : P(prochain sondage | modèle) publiée à chaque run + SURPRISE de chaque nouveau sondage (z par parti, densité prédictive, verdict bruit/à surveiller/mouvement-ou-aberrant) mesurée contre la prédiction STOCKÉE du run précédent (jamais rétro-ajustée) - data/replay_report.json committé (LOEO 6 élections) - 33/33 tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
19 changed files +4,330 −1,637
added
backend/app/modeling/national/next_poll.py
+80 −0
@@ -0,0 +1,80 @@ | ||
| 1 | +# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026 | |
| 2 | +# Auteur : Simon-Pierre Boucher | |
| 3 | +# Contact : contact@spboucher.ai | |
| 4 | +# https://www.qc-election.com | |
| 5 | +"""Distribution prédictive du PROCHAIN sondage (§46-48). | |
| 6 | + | |
| 7 | +À chaque run : P(prochain sondage | état du modèle) — l'état latent (nowcast) | |
| 8 | +plus le bruit typique d'un sondage (échantillonnage n≈900, erreur excédentaire, | |
| 9 | +maison inconnue). Quand un sondage arrive, sa SURPRISE est décomposée : | |
| 10 | + | |
| 11 | + * z par parti vs l'intervalle prédit; | |
| 12 | + * p-value prédictive globale (densité alr); | |
| 13 | + * lecture : « bruit d'échantillonnage » (|z|max < 1,6), « à surveiller » | |
| 14 | + (< 2,6), « mouvement réel probable ou sondage aberrant » (≥ 2,6) — | |
| 15 | + un sondage extrême N'EST PAS automatiquement un mouvement (§48). | |
| 16 | +""" | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import numpy as np | |
| 20 | + | |
| 21 | +from ...config import settings | |
| 22 | +from ..compositions import alr, alr_obs_cov, close, inv_alr | |
| 23 | + | |
| 24 | +TYPICAL_N_EFF = 900.0 | |
| 25 | + | |
| 26 | + | |
| 27 | +def predictive(x: np.ndarray, P: np.ndarray, | |
| 28 | + parties: list[str] | None = None, | |
| 29 | + n_eff: float = TYPICAL_N_EFF, | |
| 30 | + seed: int = 46) -> dict: | |
| 31 | + """Distribution du prochain sondage publié : état ⊕ bruit d'observation.""" | |
| 32 | + ps = parties or settings.parties | |
| 33 | + mean_shares = inv_alr(x) | |
| 34 | + R = alr_obs_cov(mean_shares, n_eff, | |
| 35 | + excess_pp=settings.excess_poll_sd_pp * 1.1) | |
| 36 | + cov = P + R | |
| 37 | + rng = np.random.default_rng(seed) | |
| 38 | + L = np.linalg.cholesky(cov + 1e-10 * np.eye(len(x))) | |
| 39 | + draws = inv_alr(x + rng.standard_normal((8000, len(x))) @ L.T) * 100.0 | |
| 40 | + out = {} | |
| 41 | + for i, p in enumerate(ps): | |
| 42 | + d = draws[:, i] | |
| 43 | + out[p] = {"p50": round(float(np.percentile(d, 50)), 1), | |
| 44 | + "p10": round(float(np.percentile(d, 10)), 1), | |
| 45 | + "p90": round(float(np.percentile(d, 90)), 1), | |
| 46 | + "sd": round(float(d.std()), 2)} | |
| 47 | + return {"parties": out, "n_eff_hypothese": n_eff, | |
| 48 | + "x": [float(v) for v in x], | |
| 49 | + "cov": [[float(v) for v in row] for row in cov], | |
| 50 | + "note": ("Ce que le modèle s'attend à voir dans le prochain sondage " | |
| 51 | + "publié (maison typique, n≈900) — sert à mesurer la " | |
| 52 | + "SURPRISE de chaque nouveau sondage.")} | |
| 53 | + | |
| 54 | + | |
| 55 | +def surprise(pred: dict, shares: dict[str, float], | |
| 56 | + parties: list[str] | None = None) -> dict: | |
| 57 | + """Surprise d'un sondage observé vs la distribution prédite (§47-48).""" | |
| 58 | + ps = parties or settings.parties | |
| 59 | + per, z_list = {}, [] | |
| 60 | + for p in ps: | |
| 61 | + if p not in shares or p not in pred["parties"]: | |
| 62 | + continue | |
| 63 | + pp = pred["parties"][p] | |
| 64 | + z = (shares[p] - pp["p50"]) / max(pp["sd"], 0.1) | |
| 65 | + per[p] = {"observé": round(shares[p], 1), "attendu": pp["p50"], | |
| 66 | + "z": round(float(z), 2)} | |
| 67 | + z_list.append(abs(z)) | |
| 68 | + z_max = max(z_list) if z_list else 0.0 | |
| 69 | + # densité prédictive alr du sondage observé | |
| 70 | + x = np.array(pred["x"]); cov = np.array(pred["cov"]) | |
| 71 | + obs_vec = close(np.array([shares.get(p, 0.5) for p in ps]) / 100.0) | |
| 72 | + diff = alr(obs_vec) - x | |
| 73 | + sign, logdet = np.linalg.slogdet(cov) | |
| 74 | + lpd = float(-0.5 * (len(x) * np.log(2 * np.pi) + logdet | |
| 75 | + + diff @ np.linalg.inv(cov) @ diff)) | |
| 76 | + verdict = ("bruit d'échantillonnage" if z_max < 1.6 else | |
| 77 | + "à surveiller" if z_max < 2.6 else | |
| 78 | + "mouvement réel probable OU sondage aberrant") | |
| 79 | + return {"per_party": per, "z_max": round(z_max, 2), | |
| 80 | + "log_pred_density": round(lpd, 2), "verdict": verdict} | |
modified
backend/app/pipeline.py
+29 −0
@@ -248,10 +248,39 @@ def run_forecast(db: Session, as_of: date | None = None, label: str | None = Non | ||
| 248 | 248 | except Exception: |
| 249 | 249 | battlegrounds = None |
| 250 | 250 | |
| 251 | + # --- distribution prédictive du prochain sondage + surprises (§46-48) --- | |
| 252 | + next_poll, poll_surprises = None, [] | |
| 253 | + if not is_backtest: | |
| 254 | + try: | |
| 255 | + from .modeling.national.next_poll import predictive, surprise | |
| 256 | + next_poll = predictive(now_d.x, now_d.P) | |
| 257 | + # surprise des sondages arrivés depuis le run précédent, mesurée | |
| 258 | + # contre la prédiction STOCKÉE par ce run-là (jamais rétro-ajustée) | |
| 259 | + prev = (db.query(Mo.ForecastRun) | |
| 260 | + .filter(Mo.ForecastRun.is_backtest.is_(False)) | |
| 261 | + .order_by(Mo.ForecastRun.run_at.desc()).first()) | |
| 262 | + prev_pred = ((prev.national.get("beyond") or {}).get("next_poll") | |
| 263 | + if prev else None) | |
| 264 | + if prev and prev_pred: | |
| 265 | + fresh = (db.query(Mo.Poll) | |
| 266 | + .filter(Mo.Poll.election_id == target.id, | |
| 267 | + Mo.Poll.accessed_at > prev.run_at, | |
| 268 | + Mo.Poll.excluded.is_(False)).all()) | |
| 269 | + for poll in fresh[:6]: | |
| 270 | + shares = {r.party: r.normalized_value for r in poll.results} | |
| 271 | + s = surprise(prev_pred, shares) | |
| 272 | + poll_surprises.append({"pollster": poll.pollster.name, | |
| 273 | + "field_end": poll.field_end.isoformat(), | |
| 274 | + **s}) | |
| 275 | + except Exception: | |
| 276 | + next_poll = None | |
| 277 | + | |
| 251 | 278 | beyond = { |
| 252 | 279 | "decomposition": decomposition, |
| 253 | 280 | "web_attention": {k: v for k, v in attention.items() if k != "sparkline_dates"}, |
| 254 | 281 | "volatility": volatility, |
| 282 | + "next_poll": next_poll, | |
| 283 | + "poll_surprises": poll_surprises, | |
| 255 | 284 | "fundamentals": fund_diag, |
| 256 | 285 | "byelections_used": [{"pollster": b["pollster"], |
| 257 | 286 | "field_end": b["field_end"].isoformat(), |
modified
backend/data/polls_2022.csv
+910 −910
Large diff (1,821 lines) is not loaded by default to keep the page fast.Load diff
modified
backend/data/polls_2026.csv
+412 −412
Large diff (825 lines) is not loaded by default to keep the page fast.Load diff
added
backend/data/replay_report.json
+1839 −0
Large diff (1,840 lines) is not loaded by default to keep the page fast.Load diff
added
docs/DESIGN_SYSTEM.md
+145 −0
Large diff (145 lines) is not loaded by default to keep the page fast.Load diff
added
docs/UI_AUDIT.md
+80 −0
Large diff (80 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/admin.html
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/assets/app.js
+225 −23
Large diff (321 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/assets/style.css
+329 −162
Large diff (680 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/aujourdhui.html
+64 −52
Large diff (217 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/backtest.html
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/carte.html
+8 −5
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/circonscriptions.html
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/index.html
+185 −53
Large diff (325 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/intelligence.html
+4 −5
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/signaux.html
+13 −8
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/simulateur.html
+2 −2
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/sondages.html
+2 −2
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff