spb/qc-election
Public
Python 66.6%
HTML 24.8%
CSS 4.9%
JavaScript 3.6%
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>Au-delà des sondages — QC Élection Forecast</title>14<meta name="description" content="Décomposition du forecast v2 : sondages, votes réels des partielles, prior de fondamentaux, ajustement médias et ensemble avec les marchés prédictifs.">15<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">16<link rel="icon" type="image/png" sizes="64x64" href="/assets/img/favicon-64.png">17<link rel="apple-touch-icon" href="/assets/img/apple-touch-icon.png">18<meta property="og:site_name" content="QC Élection Forecast">19<meta property="og:type" content="website">20<meta property="og:title" content="Au-delà des sondages — QC Élection Forecast">21<meta property="og:description" content="Décomposition du forecast v2 : sondages, votes réels des partielles, prior de fondamentaux, ajustement médias et ensemble avec les marchés prédictifs.">22<meta property="og:image" content="https://www.qc-election.com/assets/img/og.png">23<meta property="og:image:width" content="1200">24<meta property="og:image:height" content="630">25<meta property="og:locale" content="fr_CA">26<meta name="twitter:card" content="summary_large_image">27<meta name="twitter:title" content="Au-delà des sondages — QC Élection Forecast">28<meta name="twitter:image" content="https://www.qc-election.com/assets/img/og.png">29<meta name="theme-color" content="#0a0d14">30<link rel="stylesheet" href="/assets/style.css">31<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>32<style>33 .layer-card { position: relative; }34 .layer-card .lnum { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; }35 .layer-card .lidx { display: inline-flex; align-items: center; justify-content: center;36 width: 21px; height: 21px; margin-right: 8px; border-radius: 7px;37 background: color-mix(in srgb, var(--accent-strong) 16%, transparent);38 color: var(--accent); font-size: .72rem; font-weight: 800;39 font-variant-numeric: tabular-nums; vertical-align: -3px; }40 .layer-card .ltag { display:inline-block; margin-top:6px; font-size:11px;41 color:var(--muted); }42 .gauge-wrap { display:flex; align-items:center; gap:18px; flex-wrap:wrap; }43 .sat-num { font-size: 44px; font-weight: 800; line-height: 1; }44 .verified { color: var(--good); font-weight: 600; }45 .unverified { color: var(--muted); }46</style>47</head>48<body>49<header class="site"></header>50<div class="wrap">51 <div class="hero">52 <h1>Au-delà des sondages</h1>53 <p class="sub">Le forecast v2 ne repose plus uniquement sur les sondages : il combine54 <b>cinq couches d'inférence</b>, chacune à poids explicite, borné et traçable.55 <span class="chip badge-aux" id="ver-chip">modèle v2</span></p>56 <p class="disclaimer">Chaque couche est débrayable, documentée dans la57 <a href="/methodologie">méthodologie</a> et validée par backtest lorsque les données58 historiques le permettent.</p>59 </div>6061 <h2 class="section">Les cinq couches du forecast</h2>62 <div class="grid parties" id="layers"><div class="loading">Chargement…</div></div>6364 <h2 class="section">Décomposition — ce que chaque couche change</h2>65 <p class="section-sub">On reconstruit le forecast couche par couche : sondages seuls,66 puis + votes réels des partielles, + prior de fondamentaux, + ajustement médias.67 La dernière colonne intègre l'ensemble avec les marchés (probabilités seulement).</p>68 <div class="card" style="overflow-x:auto"><table class="data" id="decomp-table"></table>69 <p class="chart-note" id="decomp-note"></p></div>70 <div class="card">71 <h3>Δ points de vote apportés par chaque couche (vs sondages seuls)</h3>72 <div class="chart" id="decomp-chart"></div>73 <p class="chart-note">À l'approche du scrutin, les sondages dominent naturellement :74 les couches complémentaires pèsent surtout loin du vote, et servent de garde-fous.</p>75 </div>7677 <div class="grid two">78 <div class="card">79 <h3>Prior de fondamentaux</h3>80 <p class="section-sub">Régression sur les 8 générales de 1998 à 2022 :81 vote du sortant ~ vote précédent + satisfaction, pénalité d'usure par mandat.</p>82 <div class="gauge-wrap">83 <div>84 <div class="sat-num" id="sat-val">—</div>85 <div class="ltag">satisfaits du gouvernement</div>86 </div>87 <div style="flex:1;min-width:220px">88 <div class="kv"><span>Source</span><b id="sat-src">—</b></div>89 <div class="kv"><span>Méthode</span><b id="sat-method">—</b></div>90 <div class="kv"><span>Erreur LOO de la régression</span><b id="fund-loo">—</b></div>91 <div class="kv"><span>Poids actuel dans le forecast</span><b id="fund-w">—</b></div>92 </div>93 </div>94 <table class="data" id="fund-table" style="margin-top:12px"></table>95 </div>96 <div class="card">97 <h3>Poids des fondamentaux selon l'horizon</h3>98 <p class="section-sub">w(t) = min(0,8 · (jours restants / 540)<sup>1,3</sup>) —99 les fondamentaux comptent à 18 mois, presque plus la veille du vote.</p>100 <div class="chart" id="weight-chart"></div>101 </div>102 </div>103104 <h2 class="section">Votes réels — élections partielles</h2>105 <p class="section-sub">Les seuls bulletins comptés depuis 2022. Chaque partielle devient106 une observation nationale bruitée (swing local rétréci ×<span id="shrink"></span>,107 n équivalent <span id="eqn"></span>) injectée dans le filtre de Kalman à sa date.</p>108 <div class="card" style="overflow-x:auto"><table class="data" id="bye-table"></table></div>109110 <h2 class="section">Ensemble avec les marchés prédictifs</h2>111 <div class="card" id="market-card" style="overflow-x:auto"><div class="loading">Chargement…</div></div>112113 <h2 class="section">Radar de veille continue <span class="chip badge-aux">Firecrawl</span></h2>114 <p class="section-sub">À chaque cycle (6 h) : détection des nouveaux sondages dans la115 presse, mise à jour de la satisfaction gouvernementale, articles au-delà des flux RSS.116 Les détections sont des alertes de provenance — rien n'entre dans le modèle sans117 validation.</p>118 <div class="card" style="overflow-x:auto"><table class="data" id="radar-table"></table></div>119</div>120<footer class="site"></footer>121122<script src="/assets/app.js"></script>123<script>124(async () => {125 const meta = await initShell("/signaux");126 const parties = Object.keys(meta.parties).filter(p => p !== "AUT");127 document.getElementById("ver-chip").textContent = "modèle v" + meta.model_version;128129 const [bd, byes, sig] = await Promise.all([130 getJSON(`${API}/beyond`), getJSON(`${API}/byelections`), getJSON(`${API}/signals`)]);131 const B = bd.beyond || {};132 const layers = (B.decomposition || {}).layers || [];133 const fund = B.fundamentals || {};134 const media = B.media_adjustment || {};135 const mkt = B.market_ensemble;136137 /* ---- cartes des 5 couches ---- */138 const wFund = ((fund.blend || {}).precision_share || 0) * 100;139 const nBye = (B.byelections_used || []).length;140 const maxNudge = Math.max(0, ...Object.values(media.delta_pp || {}).map(Math.abs));141 const cards = [142 ["1", "Sondages", "Le cœur : Kalman bayésien, effets maison, pondération",143 `${bd.n_polls ?? "—"} sondages pondérés`, "poids dominant"],144 ["2", "Votes réels", "Partielles converties en observations nationales",145 `${nBye} partielles injectées`, "n équiv. " + (byes[0] ? byes[0].equivalent_n : 350)],146 ["3", "Fondamentaux", "Satisfaction, vote précédent, usure du pouvoir",147 `${((fund.satisfaction || {}).value ?? "—")} % satisfaits`,148 `poids actuel ${wFund.toFixed(1)} % (décroît vers 0)`],149 ["4", "Médias", "Sentiment de presse 14 j — nudge borné",150 `±${maxNudge.toFixed(2)} pp appliqués`, "plafond ±0,35 pp"],151 ["5", "Marchés", "Ensemble modèle/marché sur P(plus de sièges)",152 mkt ? `${(mkt.weight_market * 100).toFixed(0)} % marché` : "indisponible",153 mkt && mkt.market_meta ? `volume ${(mkt.market_meta.volume_usd || 0).toLocaleString("fr-CA")} $ US` : ""],154 ];155 document.getElementById("layers").innerHTML = cards.map(([ic, t, d, v, tag]) =>156 `<div class="card layer-card">157 <div class="pname"><span class="lidx">${ic}</span>${t}</div>158 <p class="section-sub" style="margin:6px 0">${d}</p>159 <div class="lnum">${v}</div><span class="ltag">${tag}</span>160 </div>`).join("");161162 /* ---- table de décomposition ---- */163 if (layers.length) {164 const last = layers[layers.length - 1];165 const rows = parties.sort((a, b) => last.vote[b] - last.vote[a]);166 let html = `<tr><th>Couche</th>${rows.map(p =>167 `<th><span class="dot" style="background:${partyColor(p)}"></span>${p}</th>`).join("")}168 <th>P(1er) PQ vs CAQ</th></tr>`;169 for (const l of layers) {170 const top2 = rows.slice(0, 2);171 html += `<tr><td>${l.label}</td>${rows.map(p => {172 const d = l.delta_vote ? l.delta_vote[p] : null;173 return `<td>${l.vote[p].toFixed(1)} %${d != null && Math.abs(d) >= 0.01174 ? ` <span style="color:var(--muted);font-size:11px">(${d > 0 ? "+" : ""}${d.toFixed(2)})</span>` : ""}</td>`;175 }).join("")}<td style="color:var(--muted)">${top2.map(p =>176 `${p} ${(l.prob_most[p] * 100).toFixed(1)} %`).join(" · ")}</td></tr>`;177 }178 if (mkt) {179 html += `<tr><td><b>+ ensemble marchés (probabilités)</b>${180 ""}</td><td colspan="${rows.length}" style="color:var(--muted)">les intentions de vote ne changent pas — seule P(plus de sièges) est blendée</td>181 <td>${rows.slice(0, 2).map(p => `${p} ${(mkt.blended[p] * 100).toFixed(1)} %`).join(" · ")}</td></tr>`;182 }183 document.getElementById("decomp-table").innerHTML = html;184 document.getElementById("decomp-note").textContent =185 `Décomposition recalculée à chaque run (${(B.decomposition.n_sims_probe || 8000).toLocaleString("fr-CA")} simulations par variante) — run #${bd.run_id}, ${fmtDate(bd.as_of)}.`;186187 /* ---- graphique des deltas cumulés ---- */188 const base = layers[0];189 const dLayers = layers.slice(1);190 const c = baseChart(document.getElementById("decomp-chart"));191 c.setOption({192 grid: { left: 50, right: 20, top: 34, bottom: 28 },193 legend: { ...LEGEND, data: dLayers.map(l => l.label) },194 tooltip: { ...TOOLTIP, valueFormatter: v => (v >= 0 ? "+" : "") + v.toFixed(3) + " pp" },195 xAxis: { type: "category", data: parties, ...AXIS_STYLE, splitLine: { show: false } },196 yAxis: { type: "value", ...AXIS_STYLE,197 axisLabel: { ...AXIS_STYLE.axisLabel, formatter: v => v + " pp" } },198 series: dLayers.map((l, i) => ({199 name: l.label, type: "bar", barGap: "22%", barMaxWidth: 26,200 itemStyle: { opacity: 0.9, borderRadius: 4 },201 data: parties.map(p => +(l.vote[p] - base.vote[p]).toFixed(3)) })),202 });203 } else {204 document.getElementById("decomp-table").innerHTML =205 `<tr><td style="color:var(--muted)">Décomposition disponible au prochain run du pipeline.</td></tr>`;206 }207208 /* ---- fondamentaux ---- */209 const sat = fund.satisfaction || sig.satisfaction || {};210 document.getElementById("sat-val").textContent =211 sat.value != null ? sat.value.toFixed(0) + " %" : "—";212 document.getElementById("sat-src").innerHTML = sat.source_url213 ? `<a href="${sat.source_url}" target="_blank" rel="noopener">${(sat.source || "source").slice(0, 60)}</a>`214 : (sat.note || "valeur de repli");215 document.getElementById("sat-method").textContent =216 sat.method === "firecrawl" ? "veille web (Firecrawl)" :217 sat.method === "manual" ? "saisie manuelle" : "repli de configuration";218 const reg = (fund.prior || {}).regression || {};219 document.getElementById("fund-loo").textContent =220 reg.loo_rmse_pp != null ? "±" + reg.loo_rmse_pp + " pp (leave-one-out)" : "—";221 document.getElementById("fund-w").textContent =222 wFund.toFixed(2) + " % de la précision totale";223 const priorPct = (fund.prior || {}).prior_pct;224 if (priorPct && layers.length) {225 const nowVote = layers[layers.length - 1].vote;226 document.getElementById("fund-table").innerHTML =227 `<tr><th>Parti</th><th>Prior fondamentaux</th><th>Forecast actuel</th><th>Écart</th></tr>` +228 parties.map(p => `<tr>229 <td><span class="dot" style="background:${partyColor(p)}"></span>${p}</td>230 <td>${priorPct[p].toFixed(1)} %</td><td>${nowVote[p].toFixed(1)} %</td>231 <td style="color:var(--muted)">${(priorPct[p] - nowVote[p] >= 0 ? "+" : "")}${(priorPct[p] - nowVote[p]).toFixed(1)} pp</td></tr>`).join("");232 }233234 /* ---- courbe w(t) ---- */235 const sched = bd.fundamentals_weight_schedule || [];236 const wc = baseChart(document.getElementById("weight-chart"));237 wc.setOption({238 grid: { left: 46, right: 20, top: 20, bottom: 40 },239 tooltip: { ...TOOLTIP, valueFormatter: v => (v * 100).toFixed(1) + " %" },240 xAxis: { type: "value", inverse: true, name: "jours avant le scrutin",241 nameLocation: "middle", nameGap: 26, nameTextStyle: { color: MUTED },242 ...AXIS_STYLE, splitLine: { show: false } },243 yAxis: { type: "value", ...AXIS_STYLE, max: 1,244 axisLabel: { ...AXIS_STYLE.axisLabel, formatter: v => (v * 100) + " %" } },245 series: [{ type: "line", symbol: "circle", symbolSize: 6, smooth: 0.3,246 color: "#ad8226", lineStyle: lineGlow("#ad8226", 2.4),247 areaStyle: areaGrad("#ad8226", 0.28),248 data: sched.map(s => [s.days, s.weight]),249 markLine: { symbol: "none", silent: true,250 lineStyle: { color: MUTED, type: "dashed" },251 label: { color: MUTED, formatter: "aujourd'hui" },252 data: [{ xAxis: bd.days_to_election }] } }],253 });254255 /* ---- partielles ---- */256 document.getElementById("shrink").textContent = byes[0] ? byes[0].swing_shrink : "0,6";257 document.getElementById("eqn").textContent = byes[0] ? byes[0].equivalent_n : 350;258 document.getElementById("bye-table").innerHTML =259 `<tr><th>Circonscription</th><th>Date</th><th>Résultat réel</th><th>Gain</th>260 <th>Observation nationale implicite</th><th>Participation</th><th>Statut</th></tr>` +261 byes.map(b => {262 const res = Object.entries(b.result).filter(([p]) => p !== "AUT")263 .sort((a, c) => c[1] - a[1]).slice(0, 3)264 .map(([p, v]) => `<span class="chip" style="background:${partyColor(p)}22;color:${partyColor(p)}">${p} ${v.toFixed(1)} %</span>`).join(" ");265 const imp = b.implied_national ? Object.entries(b.implied_national)266 .filter(([p]) => p !== "AUT").sort((a, c) => c[1] - a[1]).slice(0, 3)267 .map(([p, v]) => `${p} ${v.toFixed(0)}`).join(" · ") : "—";268 return `<tr><td><b>${b.district}</b></td><td>${fmtDate(b.held_on)}</td>269 <td>${res}</td>270 <td><span class="dot" style="background:${partyColor(b.winner)}"></span>${b.winner}271 <span style="color:var(--muted)">(était ${b.previous_winner || "?"})</span></td>272 <td style="color:var(--muted)">${imp}</td>273 <td>${b.turnout != null ? b.turnout.toFixed(0) + " %" : "—"}</td>274 <td class="${b.verified ? "verified" : "unverified"}">${b.verified ? "✓ vérifié en ligne" : "DGEQ (embarqué)"}${b.used_in_model ? "" : " · exclu"}</td></tr>`;275 }).join("");276277 /* ---- marchés ---- */278 if (mkt && mkt.market && Object.keys(mkt.market).length) {279 const rows = parties.filter(p => mkt.blended[p] != null)280 .sort((a, b) => mkt.blended[b] - mkt.blended[a]);281 document.getElementById("market-card").innerHTML = `282 <table class="data">283 <tr><th>Parti</th><th>Modèle</th><th>Marché (Polymarket)</th>284 <th>Publié (ensemble ${(100 - mkt.weight_market * 100).toFixed(0)}/${(mkt.weight_market * 100).toFixed(0)})</th></tr>285 ${rows.map(p => `<tr>286 <td><span class="dot" style="background:${partyColor(p)}"></span>${partyName(p)}</td>287 <td>${(mkt.model[p] * 100).toFixed(1)} %</td>288 <td>${mkt.market[p] != null ? (mkt.market[p] * 100).toFixed(1) + " %" : "—"}</td>289 <td><b>${(mkt.blended[p] * 100).toFixed(1)} %</b></td></tr>`).join("")}290 </table>291 <p class="note">P(plus de sièges). Le marché agrège de l'information hors-sondages;292 la combinaison modèle/marché améliore presque toujours la calibration293 (littérature sur l'agrégation de prévisions).294 ${mkt.market_meta ? `<a href="${mkt.market_meta.url}" target="_blank" rel="noopener">${mkt.market_meta.event}</a> · consulté ${new Date(mkt.market_meta.fetched_at).toLocaleString("fr-CA")}` : ""}</p>`;295 } else {296 document.getElementById("market-card").innerHTML =297 '<p class="note">Marché indisponible pour ce run — probabilités du modèle publiées telles quelles.</p>';298 }299300 /* ---- radar ---- */301 const signals = (sig.signals || []).filter(s => s.kind !== "presse");302 document.getElementById("radar-table").innerHTML =303 `<tr><th>Détecté</th><th>Type</th><th>Maison</th><th>Titre</th></tr>` +304 (signals.length ? signals.slice(0, 30).map(s => `<tr>305 <td style="white-space:nowrap">${new Date(s.detected_at + "Z").toLocaleDateString("fr-CA", { month: "short", day: "numeric" })}</td>306 <td><span class="chip badge-aux">${s.kind === "poll-radar" ? "sondage repéré" : s.kind}</span></td>307 <td>${s.pollster || "—"}</td>308 <td><a href="${s.url}" target="_blank" rel="noopener">${s.title}</a></td></tr>`).join("")309 : `<tr><td colspan="4" style="color:var(--muted)">${sig.enabled310 ? "Aucune détection encore — le prochain cycle de veille alimentera cette section."311 : "Veille Firecrawl désactivée (clé absente)."}</td></tr>`);312})();313</script>314</body>315</html>316