SPB Git

spb/qc-election

Public

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>
Simon-Pierre Boucher committed (Aug 30, 2026) parent 37f4ccc Browse files

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
Menu

Theme