spb/qc-election
Public
1<!DOCTYPE html>2<!--3 QC Élection Forecast — Plateforme de prévision électorale du Québec 20264 Auteur : Simon-Pierre Boucher5 Contact : contact@spboucher.ai6 https://www.qc-election.com7-->8<html lang="fr">9<head>10<script>document.documentElement.dataset.theme=localStorage.getItem("qce-theme")||"dark";</script>11<meta charset="utf-8">12<meta name="viewport" content="width=device-width, initial-scale=1">13<title>Backtesting & calibration — QC Élection Forecast</title>14<link rel="stylesheet" href="/assets/style.css">15<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>16</head>17<body>18<header class="site"></header>19<div class="wrap">20 <div class="hero">21 <h1>Backtesting et calibration</h1>22 <p class="sub">Le modèle rejoué sur l'élection de 2022 avec uniquement l'information disponible à chaque date. La calibration prime sur la « bonne prédiction » ponctuelle.</p>23 </div>2425 <div class="grid two">26 <div class="card">27 <h3>Calibration des probabilités de victoire locales</h3>28 <div class="chart" id="calib"></div>29 <p class="chart-note">Chaque point : un bac de probabilité prédite (toutes circonscriptions × partis × horizons). Un modèle bien calibré suit la diagonale : parmi les cas annoncés à ~70 %, ~70 % se réalisent.</p>30 </div>31 <div class="card">32 <h3>Erreur du vote national selon l'horizon</h3>33 <div class="chart" id="mae"></div>34 <p class="chart-note">EAM (erreur absolue moyenne) des parts de vote prévues vs le résultat réel de 2022, en points de pourcentage.</p>35 </div>36 </div>3738 <h2 class="section">Détail par horizon</h2>39 <div class="card" style="overflow-x:auto"><table class="data" id="detail"></table></div>40 <p class="section-sub" id="bt-note" style="margin-top:10px"></p>41</div>42<footer class="site"></footer>4344<script src="/assets/app.js"></script>45<script>46(async () => {47 await initShell("/backtest");48 let rep;49 try { rep = await getJSON(`${API}/backtest`); }50 catch (e) {51 document.getElementById("detail").innerHTML =52 "<tr><td>Backtest non encore exécuté — lancez-le depuis l'administration.</td></tr>";53 return;54 }55 document.getElementById("bt-note").textContent = rep.note;5657 const cc = baseChart(document.getElementById("calib"));58 cc.setOption({59 grid: { left: 56, right: 20, top: 36, bottom: 44 },60 tooltip: { ...TOOLTIP, trigger: "item",61 formatter: pt => pt.data.bin ? `${pt.data.bin}<br>prédit ${fmtPct(pt.value[0])} · observé ${fmtPct(pt.value[1])}<br>n=${pt.data.n}` : "" },62 xAxis: { type: "value", min: 0, max: 1, name: "probabilité prédite", nameLocation: "middle",63 nameGap: 28, nameTextStyle: { color: MUTED }, ...AXIS_STYLE,64 axisLabel: { ...AXIS_STYLE.axisLabel, formatter: v => (v * 100) + " %" } },65 yAxis: { type: "value", min: 0, max: 1, name: "fréquence observée",66 nameTextStyle: { color: MUTED }, ...AXIS_STYLE,67 axisLabel: { ...AXIS_STYLE.axisLabel, formatter: v => (v * 100) + " %" } },68 series: [69 { type: "line", data: [[0, 0], [1, 1]], symbol: "none", silent: true,70 lineStyle: { color: GRID_COLOR, type: "dashed", width: 1 } },71 { type: "scatter", color: "#8fa3c8",72 symbolSize: (value, params) => 8 + Math.min(14, Math.sqrt(params.data.n) / 4),73 data: rep.calibration.map(b => ({ value: [b.predicted, b.observed], bin: b.bin, n: b.n })) },74 ],75 });7677 const hs = rep.horizons.filter(h => !h.error);78 const mc = baseChart(document.getElementById("mae"));79 mc.setOption({80 grid: { left: 44, right: 20, top: 20, bottom: 44 },81 tooltip: { ...TOOLTIP },82 xAxis: { type: "category", data: hs.map(h => "J−" + h.horizon), ...AXIS_STYLE,83 splitLine: { show: false }, name: "horizon avant le scrutin", nameLocation: "middle",84 nameGap: 30, nameTextStyle: { color: MUTED } },85 yAxis: { type: "value", ...AXIS_STYLE, name: "pp", nameTextStyle: { color: MUTED } },86 series: [{ name: "EAM vote", type: "bar", barWidth: 22, color: "#8fa3c8",87 itemStyle: { borderRadius: [4, 4, 0, 0] },88 label: { show: true, position: "top", color: INK2, fontSize: 11 },89 data: hs.map(h => h.national.mae_pp) }],90 });9192 document.getElementById("detail").innerHTML =93 `<tr><th>Horizon</th><th>Sondages</th><th>EAM vote (pp)</th><th>Couverture IC 95 %</th>94 <th>EAM sièges</th><th>Couverture sièges</th><th>Brier</th><th>Log loss</th>95 <th>Vainqueurs locaux corrects</th></tr>` +96 hs.map(h => `<tr>97 <td><b>J−${h.horizon}</b> <span style="color:var(--muted)">(${h.as_of})</span></td>98 <td>${h.n_polls}</td><td>${h.national.mae_pp}</td>99 <td>${fmtPct(h.national.coverage_95)}</td>100 <td>${h.seats.mae}</td><td>${fmtPct(h.seats.coverage_90)}</td>101 <td>${h.districts.brier}</td><td>${h.districts.log_loss}</td>102 <td>${fmtPct(h.districts.winner_accuracy)}</td></tr>`).join("");103})();104</script>105</body>106</html>107