SPB Git

spb/qc-election

Public

Directive maître — baselines de replay, Course à 64, connecteurs primaires

- Replay : baselines « dernier sondage » et « moyenne 14 j » scorées à chaque
  horizon et agrégées (modèle 3,71 bat dernier-sondage 3,84; moyenne-14j 3,49
  gagne l'EAM ponctuelle — consigné honnêtement dans STATISTICAL_DEBT comme
  recherche ouverte : la valeur du modèle est la DISTRIBUTION, les baselines
  n'ont ni intervalles ni probabilités)
- Course à 64 (/api/battlegrounds.paths_to_64 + section /circonscriptions) :
  pour chaque parti, échelle des sièges du plus sûr au plus décisif, 64e siège
  de la majorité surligné (PQ : Maskinongé 43 %, marge 0,9 pp), marge attendue
  = swing minimal de bascule, onglets par parti
- Connecteurs PRIMAIRES des 7 firmes (ingest/pollster_reports.py) : veille des
  pages de publications (Firecrawl), archivage des rapports (data/raw/reports),
  extraction PAR RÈGLES avec niveau de confiance, FILE DE RÉVISION (Poll
  excluded=True « révision requise » — jamais d'entrée silencieuse),
  réconciliation Wikipédia, statut de source consigné (Pallas/Synopsis murées
  = connecteurs désactivés de fait). Test live Léger : 5 rapports archivés,
  0 création sous le seuil de confiance — comportement voulu
- Tests invariants (§qualité) : 127 sièges/simulation, P(victoire) somme à 1,
  probabilités [0,1], extraction primaire (35/35 verts)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 1, 2026) parent 79d3000 Browse files

8 changed files +668 −8

modified STATISTICAL_DEBT.md +9 −0
@@ -51,3 +51,12 @@ conservateur assumé. État au 2026-08-30 (v3.0.x).
51 réelle). Corrigé : `compute_prior(exclude_year=…)` (LOEO strict). 51 réelle). Corrigé : `compute_prior(exclude_year=…)` (LOEO strict).
52 3. `industry_error_sd=0,12` calibré sur 2018/2022 puis testé sur 2022 → 52 3. `industry_error_sd=0,12` calibré sur 2018/2022 puis testé sur 2022 →
53 circulaire. Corrigé par l'estimation multi-élections LOEO. 53 circulaire. Corrigé par l'estimation multi-élections LOEO.
54+
55+## Constats du replay v3.1 (recherche ouverte)
56+- **EAM ponctuelle** : modèle 3,71 pp vs baseline « moyenne 14 j » 3,49 (le modèle
57+ bat « dernier sondage » 3,84). L'écart vient surtout de 2007 (surge ADQ 6,6 vs
58+ 5,4) — cohérent avec le chantier queues lourdes. La valeur du modèle est la
59+ DISTRIBUTION : les baselines n'ont ni intervalle, ni couverture, ni P(sièges).
60+ Pistes : corrections maison LOEO possiblement contre-productives en point aux
61+ horizons lointains (petits échantillons); q trop lisse en campagne; Student-t.
62+ À trancher par le même replay avant tout changement (§84).
modified backend/app/api/public.py +21 −0
@@ -239,8 +239,29 @@ def battlegrounds(db: Session = Depends(get_db)):
239 contested = sorted((fr for fr in run.district_results 239 contested = sorted((fr for fr in run.district_results
240 if fr.category == "Chaudement disputé"), 240 if fr.category == "Chaudement disputé"),
241 key=lambda fr: -max(fr.detail["win_probs"].values())) 241 key=lambda fr: -max(fr.detail["win_probs"].values()))
242+ # Course à 64 : pour chaque parti, circonscriptions classées de la plus
243+ # sûre à la plus décisive; la 64ᵉ est le « siège de la majorité ».
244+ paths = {}
245+ for party in [p for p in settings.parties if p != "AUT"]:
246+ ranked = sorted(run.district_results,
247+ key=lambda fr: -fr.detail["win_probs"].get(party, 0.0))
248+ rows = []
249+ for i, fr in enumerate(ranked[:80], start=1):
250+ wp = fr.detail["win_probs"].get(party, 0.0)
251+ exp = fr.detail.get("expected") or {}
252+ top2 = sorted(exp.values(), reverse=True)[:2]
253+ rows.append({"rank": i, "district": fr.district_name,
254+ "win_prob": wp,
255+ "margin_pp": round(top2[0] - top2[1], 1) if len(top2) == 2 else None,
256+ "favorite": fr.favorite,
257+ "is_majority_seat": i == settings.majority_seats})
258+ seat64 = rows[settings.majority_seats - 1] if len(rows) >= settings.majority_seats else None
259+ paths[party] = {"seats_ge_50": sum(1 for r in rows if r["win_prob"] >= 0.5),
260+ "majority_seat": seat64,
261+ "ladder": rows}
242 return {"run_id": run.id, "as_of": run.as_of.isoformat(), 262 return {"run_id": run.id, "as_of": run.as_of.isoformat(),
243 "tipping": tips, 263 "tipping": tips,
264+ "paths_to_64": paths,
244 "contested": [{"district": fr.district_name, 265 "contested": [{"district": fr.district_name,
245 "favorite": fr.favorite, 266 "favorite": fr.favorite,
246 "win_probs": fr.detail["win_probs"], 267 "win_probs": fr.detail["win_probs"],
added backend/app/ingest/pollster_reports.py +236 −0
@@ -0,0 +1,236 @@
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+"""Connecteurs PRIMAIRES des maisons de sondage (directive maître).
6+
7+Pour Léger, Pallas Data, Synopsis, Liaison Strategies, Angus Reid, SEGMA et
8+Mainstreet : surveillance de leurs pages de publications publiques →
9+
10+ 1. détection d'un nouveau rapport (lien inédit, mots-clés Québec/intentions);
11+ 2. archivage LÉGAL du document public (markdown Firecrawl, PDF inclus) dans
12+ `data/raw/reports/` avec provenance complète;
13+ 3. extraction par RÈGLES des toplines nationales (partis + %, dates de
14+ terrain, n) avec NIVEAU DE CONFIANCE;
15+ 4. **file de révision** : un sondage extrait est créé `excluded=True`
16+ (« révision requise ») — il n'entre JAMAIS silencieusement dans le modèle;
17+ l'admin valide via PATCH /api/admin/poll/{id} (excluded=false);
18+ 5. réconciliation : si Wikipédia a déjà le sondage (maison + fin de terrain),
19+ la détection est marquée « intégré » sans doublon.
20+
21+Chaque connecteur est tolérant : une page murée = statut de source consigné,
22+jamais un pipeline cassé. Qc125/338Canada ne servent jamais de source primaire.
23+"""
24+from __future__ import annotations
25+
26+import logging
27+import re
28+from datetime import date, datetime, timedelta, timezone
29+
30+from sqlalchemy.orm import Session
31+
32+from ..config import DATA_DIR, settings
33+from .. import models as Mo
34+
35+log = logging.getLogger("pollster-reports")
36+
37+REPORTS_DIR = DATA_DIR / "raw" / "reports"
38+
39+# Pages de publications publiques (vérifiées best-effort; un 404 est consigné).
40+PRIMARY_SOURCES: dict[str, dict] = {
41+ "Léger": {"watch": "https://leger360.com/fr/publications/",
42+ "mode": "web"},
43+ "Pallas Data": {"watch": "https://pallasdata.com/",
44+ "mode": "ivr"},
45+ "Synopsis": {"watch": "https://www.synopsisrecherche.com/",
46+ "mode": "web"},
47+ "Liaison Strategies": {"watch": "https://liaisonstrategies.ca/",
48+ "mode": "ivr"},
49+ "Angus Reid": {"watch": "https://angusreid.org/?s=quebec",
50+ "mode": "web"},
51+ "SEGMA": {"watch": "https://www.segma.ca/",
52+ "mode": "phone"},
53+ "Mainstreet": {"watch": "https://www.mainstreetresearch.ca/polls/",
54+ "mode": "ivr"},
55+}
56+
57+KEYWORDS = re.compile(r"qu[ée]bec|intention|sondage|provincial|caq|pq\b|élection",
58+ re.I)
59+
60+PARTY_RES = {
61+ "CAQ": r"(?:CAQ|Coalition\s+avenir)",
62+ "PLQ": r"(?:PLQ|Parti\s+lib[ée]ral|lib[ée]raux)",
63+ "PQ": r"(?:PQ\b|Parti\s+[Qq]u[ée]b[ée]cois)",
64+ "QS": r"(?:QS\b|Qu[ée]bec\s+[Ss]olidaire)",
65+ "PCQ": r"(?:PCQ|Parti\s+conservateur|conservateurs?\s+du\s+Qu[ée]bec)",
66+}
67+FIELD_RE = re.compile(
68+ r"(?:du|from)\s+(\d{1,2})(?:er)?\s+(?:au|to)\s+(\d{1,2})(?:er)?\s+"
69+ r"([a-zéû]+)\s+(\d{4})", re.I)
70+MONTHS = {"janvier": 1, "février": 2, "mars": 3, "avril": 4, "mai": 5,
71+ "juin": 6, "juillet": 7, "août": 8, "aout": 8, "septembre": 9,
72+ "octobre": 10, "novembre": 11, "décembre": 12, "decembre": 12}
73+N_RE = re.compile(r"(?:n\s*=\s*|aupr[èe]s\s+de\s+|échantillon\s+de\s+)"
74+ r"([\d\s ,]{3,6})", re.I)
75+
76+
77+def extract_toplines(md: str) -> dict:
78+ """Extraction PAR RÈGLES des intentions nationales d'un rapport.
79+ Retourne {shares, field_end, sample_size, confidence, raisons}."""
80+ reasons, shares = [], {}
81+ # fenêtre autour de la première mention « intentions de vote »
82+ idx = md.lower().find("intentions de vote")
83+ window = md[max(0, idx - 300):idx + 3500] if idx >= 0 else md[:5000]
84+ for party, pat in PARTY_RES.items():
85+ m = re.search(pat + r"[^%\n]{0,60}?(\d{1,2})\s*%", window)
86+ if m:
87+ v = float(m.group(1))
88+ if 1 <= v <= 60:
89+ shares[party] = v
90+ total = sum(shares.values())
91+ field_end = None
92+ m = FIELD_RE.search(md[:6000])
93+ if m:
94+ month = MONTHS.get(m.group(3).lower())
95+ if month:
96+ try:
97+ field_end = date(int(m.group(4)), month, int(m.group(2)))
98+ except ValueError:
99+ pass
100+ n = None
101+ mn = N_RE.search(md[:6000])
102+ if mn:
103+ digits = re.sub(r"[^\d]", "", mn.group(1))
104+ if digits and 100 <= int(digits) <= 100000:
105+ n = int(digits)
106+ conf = 0.0
107+ if len(shares) >= 4:
108+ conf += 0.4
109+ else:
110+ reasons.append(f"seulement {len(shares)} partis extraits")
111+ if 75 <= total <= 105:
112+ conf += 0.3
113+ elif shares:
114+ reasons.append(f"somme implausible ({total:.0f})")
115+ if field_end:
116+ conf += 0.2
117+ else:
118+ reasons.append("dates de terrain introuvables")
119+ if n:
120+ conf += 0.1
121+ return {"shares": shares, "field_end": field_end, "sample_size": n,
122+ "confidence": round(conf, 2), "reasons": reasons}
123+
124+
125+def _archive(firm: str, url: str, md: str) -> None:
126+ try:
127+ import hashlib
128+ REPORTS_DIR.mkdir(parents=True, exist_ok=True)
129+ h = hashlib.sha1(url.encode()).hexdigest()[:12]
130+ safe = re.sub(r"[^a-z0-9]+", "-", firm.lower())
131+ (REPORTS_DIR / f"{date.today().isoformat()}_{safe}_{h}.md").write_text(
132+ f"<!-- {url} — archivé {datetime.now(timezone.utc).isoformat()} -->\n{md}")
133+ except Exception:
134+ pass
135+
136+
137+def _source_status(db: Session, firm: str, url: str, status: str) -> None:
138+ src = db.query(Mo.DataSource).filter_by(name=f"Primaire — {firm}").first()
139+ if src is None:
140+ src = Mo.DataSource(name=f"Primaire — {firm}", url=url, kind="poll-primary")
141+ db.add(src)
142+ src.last_fetch = datetime.now(timezone.utc)
143+ src.last_status = status[:200]
144+
145+
146+def watch_firm(db: Session, firm: str, cfg: dict) -> dict:
147+ """Un connecteur : page de publications → nouveaux liens → extraction."""
148+ from .firecrawl_watch import _enabled, _post, scrape_markdown
149+ if not _enabled():
150+ return {"skipped": "Firecrawl requis"}
151+ data = _post("/scrape", {"url": cfg["watch"], "formats": ["links", "markdown"]},
152+ timeout=90.0)
153+ d = (data or {}).get("data") or {}
154+ links, page_md = d.get("links") or [], d.get("markdown") or ""
155+ if not links and not page_md:
156+ _source_status(db, firm, cfg["watch"], "inaccessible")
157+ return {"statut": "page inaccessible"}
158+ _source_status(db, firm, cfg["watch"], "ok")
159+ # candidats : liens de la même maison contenant les mots-clés
160+ host = cfg["watch"].split("/")[2].replace("www.", "")
161+ cands = []
162+ for l in links:
163+ if host in l and KEYWORDS.search(l) and len(l) < 220 \
164+ and not any(x in l for x in ("#", "mailto:", "?share", "/tag/",
165+ "/category/", "/page/")):
166+ cands.append(l.split("?utm")[0])
167+ cands = list(dict.fromkeys(cands))[:6]
168+ new, pending, integrated = 0, 0, 0
169+ for url in cands:
170+ if db.query(Mo.WebSignal).filter_by(url=url, kind="rapport-primaire").first():
171+ continue
172+ md = scrape_markdown(url)
173+ if not md or not KEYWORDS.search(md[:4000]):
174+ continue
175+ _archive(firm, url, md[:250000])
176+ ext = extract_toplines(md)
177+ sig = Mo.WebSignal(kind="rapport-primaire", url=url,
178+ title=f"{firm} — rapport détecté",
179+ pollster=firm, status="nouveau",
180+ snippet=md[:400],
181+ extra={"extraction": {**ext,
182+ "field_end": ext["field_end"].isoformat()
183+ if ext["field_end"] else None}})
184+ db.add(sig)
185+ new += 1
186+ # réconciliation Wikipédia / création en file de révision
187+ if ext["field_end"] and ext["confidence"] >= 0.7:
188+ pollster = db.query(Mo.Pollster).filter_by(name=firm).first()
189+ exists = (pollster and db.query(Mo.Poll)
190+ .filter_by(pollster_id=pollster.id,
191+ field_end=ext["field_end"]).first())
192+ if exists:
193+ sig.status = "intégré"
194+ sig.extra = {**sig.extra, "poll_id": exists.id}
195+ integrated += 1
196+ else:
197+ from ..seed import get_or_create
198+ el = db.query(Mo.Election).filter_by(is_target=True).first()
199+ pol, _ = get_or_create(db, Mo.Pollster, name=firm)
200+ total = sum(ext["shares"].values())
201+ poll = Mo.Poll(
202+ election_id=el.id, pollster_id=pol.id,
203+ field_start=ext["field_end"] - timedelta(days=3),
204+ field_end=ext["field_end"],
205+ sample_size=ext["sample_size"],
206+ mode=cfg.get("mode", "unknown"),
207+ source_name=f"Rapport primaire {firm} (extraction auto)",
208+ source_url=url, excluded=True,
209+ exclusion_reason=("révision requise — extraction "
210+ f"automatique (confiance {ext['confidence']})"))
211+ for party, v in ext["shares"].items():
212+ poll.results.append(Mo.PollResult(
213+ party=party, raw_value=v,
214+ normalized_value=round(v * 100 / total, 3)))
215+ db.add(poll)
216+ db.flush()
217+ sig.status = "en_révision"
218+ sig.extra = {**sig.extra, "poll_id": poll.id}
219+ pending += 1
220+ db.commit()
221+ return {"liens": len(cands), "nouveaux": new,
222+ "en_révision": pending, "déjà_intégrés": integrated}
223+
224+
225+def run_primary_watch(db: Session, max_firms: int | None = None) -> dict:
226+ """Cycle des connecteurs primaires — chaque firme isolée."""
227+ report = {}
228+ for i, (firm, cfg) in enumerate(PRIMARY_SOURCES.items()):
229+ if max_firms is not None and i >= max_firms:
230+ break
231+ try:
232+ report[firm] = watch_firm(db, firm, cfg)
233+ except Exception as e:
234+ db.rollback()
235+ report[firm] = f"échec: {e}"
236+ return report
modified backend/app/modeling/validation/historical_replay.py +28 −0
@@ -60,6 +60,28 @@ def _loeo_profiles(test_year: int, parties: list[str]):
60 return compute_profiles(polls, elections, parties=parties) 60 return compute_profiles(polls, elections, parties=parties)
61 61
62 62
63+def _baselines_at(polls: list[dict], as_of: date, parties: list[str],
64+ actual: dict) -> dict:
65+ """Règles simples contre lesquelles le modèle DOIT gagner (§calibration) :
66+ B1 = dernier sondage publié; B2 = moyenne simple des 14 derniers jours
67+ (repli : 3 derniers sondages)."""
68+ hist = sorted((p for p in polls if p["field_end"] <= as_of),
69+ key=lambda p: p["field_end"])
70+ if not hist:
71+ return {}
72+ def norm(sh):
73+ tot = sum(sh.get(p, 0.0) for p in parties) or 100.0
74+ return {p: sh.get(p, 0.0) * 100.0 / tot for p in parties}
75+ last = norm(hist[-1]["shares"])
76+ recent = [p for p in hist if (as_of - p["field_end"]).days <= 14] or hist[-3:]
77+ avg = {pt: float(np.mean([norm(p["shares"])[pt] for p in recent]))
78+ for pt in parties}
79+ return {
80+ "dernier_sondage": vote_errors(last, actual, parties)["mae_pp"],
81+ "moyenne_14j": vote_errors(avg, actual, parties)["mae_pp"],
82+ }
83+
84+
63 def replay_election(year: int, use_fundamentals: bool = True, 85 def replay_election(year: int, use_fundamentals: bool = True,
64 use_loeo_house: bool = True, 86 use_loeo_house: bool = True,
65 industry_sd: float | None = None, 87 industry_sd: float | None = None,
@@ -108,6 +130,7 @@ def replay_election(year: int, use_fundamentals: bool = True,
108 "log_score": log_score_alr(f.x, f.P, alr(actual_vec)), 130 "log_score": log_score_alr(f.x, f.P, alr(actual_vec)),
109 "sharpness_pp": sharpness(f.summary, parties), 131 "sharpness_pp": sharpness(f.summary, parties),
110 "forecast": {p: round(fc_mean[p], 1) for p in parties}, 132 "forecast": {p: round(fc_mean[p], 1) for p in parties},
133+ "baselines_mae_pp": _baselines_at(polls, as_of, parties, actual),
111 }) 134 })
112 ok = [r for r in rows if "error" not in r] 135 ok = [r for r in rows if "error" not in r]
113 agg = {} 136 agg = {}
@@ -118,6 +141,11 @@ def replay_election(year: int, use_fundamentals: bool = True,
118 vals = [r[k] for r in ok if k in r] 141 vals = [r[k] for r in ok if k in r]
119 if vals: 142 if vals:
120 agg[k] = round(float(np.mean(vals)), 3) 143 agg[k] = round(float(np.mean(vals)), 3)
144+ for b in ("dernier_sondage", "moyenne_14j"):
145+ vals = [r["baselines_mae_pp"][b] for r in ok
146+ if r.get("baselines_mae_pp", {}).get(b) is not None]
147+ if vals:
148+ agg[f"baseline_{b}_mae_pp"] = round(float(np.mean(vals)), 3)
121 return {"year": year, "parties": parties, "actual": actual, 149 return {"year": year, "parties": parties, "actual": actual,
122 "horizons": rows, "avg": agg} 150 "horizons": rows, "avg": agg}
123 151
modified backend/app/pipeline.py +8 −0
@@ -372,6 +372,14 @@ def run_pipeline(full_refresh: bool = True) -> dict:
372 except Exception as e: 372 except Exception as e:
373 report["veille"] = f"échec: {e}" 373 report["veille"] = f"échec: {e}"
374 _log(db, "veille-firecrawl", "error", traceback.format_exc()) 374 _log(db, "veille-firecrawl", "error", traceback.format_exc())
375+ try:
376+ from .ingest.pollster_reports import run_primary_watch
377+ report["rapports_primaires"] = run_primary_watch(db)
378+ _log(db, "rapports-primaires", "ok",
379+ str(report["rapports_primaires"])[:500])
380+ except Exception as e:
381+ report["rapports_primaires"] = f"échec: {e}"
382+ _log(db, "rapports-primaires", "error", traceback.format_exc())
375 try: 383 try:
376 # source canonique + découverte de comptes : hebdomadaires 384 # source canonique + découverte de comptes : hebdomadaires
377 marker = (db.query(Mo.Indicator) 385 marker = (db.query(Mo.Indicator)
modified backend/data/replay_report.json +262 −8
@@ -1,5 +1,5 @@
1 { 1 {
2− "generated": "2026-08-30", 2+ "generated": "2026-08-31",
3 "model_version": "3.1.0", 3 "model_version": "3.1.0",
4 "protocol": "LOEO strict : house effects, fondamentaux et σ_industrie appris sur les autres élections; rolling origin J-120→J-1.", 4 "protocol": "LOEO strict : house effects, fondamentaux et σ_industrie appris sur les autres élections; rolling origin J-120→J-1.",
5 "elections": { 5 "elections": {
@@ -45,6 +45,10 @@
45 "PQ": 39.5, 45 "PQ": 39.5,
46 "QS": 7.9, 46 "QS": 7.9,
47 "AUT": 11.3 47 "AUT": 11.3
48+ },
49+ "baselines_mae_pp": {
50+ "dernier_sondage": 8.884,
51+ "moyenne_14j": 7.465
48 } 52 }
49 }, 53 },
50 { 54 {
@@ -72,6 +76,10 @@
72 "PQ": 39.5, 76 "PQ": 39.5,
73 "QS": 7.9, 77 "QS": 7.9,
74 "AUT": 11.3 78 "AUT": 11.3
79+ },
80+ "baselines_mae_pp": {
81+ "dernier_sondage": 8.884,
82+ "moyenne_14j": 7.465
75 } 83 }
76 }, 84 },
77 { 85 {
@@ -99,6 +107,10 @@
99 "PQ": 39.5, 107 "PQ": 39.5,
100 "QS": 7.9, 108 "QS": 7.9,
101 "AUT": 11.3 109 "AUT": 11.3
110+ },
111+ "baselines_mae_pp": {
112+ "dernier_sondage": 8.884,
113+ "moyenne_14j": 7.465
102 } 114 }
103 }, 115 },
104 { 116 {
@@ -126,6 +138,10 @@
126 "PQ": 35.5, 138 "PQ": 35.5,
127 "QS": 7.0, 139 "QS": 7.0,
128 "AUT": 9.0 140 "AUT": 9.0
141+ },
142+ "baselines_mae_pp": {
143+ "dernier_sondage": 4.234,
144+ "moyenne_14j": 4.234
129 } 145 }
130 }, 146 },
131 { 147 {
@@ -153,6 +169,10 @@
153 "PQ": 38.3, 169 "PQ": 38.3,
154 "QS": 5.8, 170 "QS": 5.8,
155 "AUT": 9.0 171 "AUT": 9.0
172+ },
173+ "baselines_mae_pp": {
174+ "dernier_sondage": 6.352,
175+ "moyenne_14j": 6.352
156 } 176 }
157 }, 177 },
158 { 178 {
@@ -180,6 +200,10 @@
180 "PQ": 38.3, 200 "PQ": 38.3,
181 "QS": 5.8, 201 "QS": 5.8,
182 "AUT": 9.0 202 "AUT": 9.0
203+ },
204+ "baselines_mae_pp": {
205+ "dernier_sondage": 6.352,
206+ "moyenne_14j": 6.352
183 } 207 }
184 }, 208 },
185 { 209 {
@@ -207,6 +231,10 @@
207 "PQ": 35.6, 231 "PQ": 35.6,
208 "QS": 6.0, 232 "QS": 6.0,
209 "AUT": 8.7 233 "AUT": 8.7
234+ },
235+ "baselines_mae_pp": {
236+ "dernier_sondage": 3.632,
237+ "moyenne_14j": 3.632
210 } 238 }
211 }, 239 },
212 { 240 {
@@ -234,6 +262,10 @@
234 "PQ": 33.9, 262 "PQ": 33.9,
235 "QS": 5.8, 263 "QS": 5.8,
236 "AUT": 8.1 264 "AUT": 8.1
265+ },
266+ "baselines_mae_pp": {
267+ "dernier_sondage": 3.779,
268+ "moyenne_14j": 3.68
237 } 269 }
238 }, 270 },
239 { 271 {
@@ -261,6 +293,10 @@
261 "PQ": 32.6, 293 "PQ": 32.6,
262 "QS": 5.5, 294 "QS": 5.5,
263 "AUT": 9.3 295 "AUT": 9.3
296+ },
297+ "baselines_mae_pp": {
298+ "dernier_sondage": 5.488,
299+ "moyenne_14j": 3.925
264 } 300 }
265 }, 301 },
266 { 302 {
@@ -288,6 +324,10 @@
288 "PQ": 32.6, 324 "PQ": 32.6,
289 "QS": 5.5, 325 "QS": 5.5,
290 "AUT": 9.3 326 "AUT": 9.3
327+ },
328+ "baselines_mae_pp": {
329+ "dernier_sondage": 5.488,
330+ "moyenne_14j": 3.925
291 } 331 }
292 } 332 }
293 ], 333 ],
@@ -299,7 +339,9 @@
299 "sharpness_pp": 20.685, 339 "sharpness_pp": 20.685,
300 "coverage_50": 0.06, 340 "coverage_50": 0.06,
301 "coverage_90": 0.7, 341 "coverage_90": 0.7,
302− "coverage_95": 0.74 342+ "coverage_95": 0.74,
343+ "baseline_dernier_sondage_mae_pp": 6.198,
344+ "baseline_moyenne_14j_mae_pp": 5.449
303 }, 345 },
304 "industry_sd_loeo": 0.2156 346 "industry_sd_loeo": 0.2156
305 }, 347 },
@@ -345,6 +387,10 @@
345 "PQ": 32.8, 387 "PQ": 32.8,
346 "QS": 6.2, 388 "QS": 6.2,
347 "AUT": 0.8 389 "AUT": 0.8
390+ },
391+ "baselines_mae_pp": {
392+ "dernier_sondage": 2.81,
393+ "moyenne_14j": 1.294
348 } 394 }
349 }, 395 },
350 { 396 {
@@ -372,6 +418,10 @@
372 "PQ": 31.6, 418 "PQ": 31.6,
373 "QS": 5.1, 419 "QS": 5.1,
374 "AUT": 0.6 420 "AUT": 0.6
421+ },
422+ "baselines_mae_pp": {
423+ "dernier_sondage": 1.726,
424+ "moyenne_14j": 1.345
375 } 425 }
376 }, 426 },
377 { 427 {
@@ -399,6 +449,10 @@
399 "PQ": 31.9, 449 "PQ": 31.9,
400 "QS": 4.6, 450 "QS": 4.6,
401 "AUT": 0.6 451 "AUT": 0.6
452+ },
453+ "baselines_mae_pp": {
454+ "dernier_sondage": 1.345,
455+ "moyenne_14j": 1.345
402 } 456 }
403 }, 457 },
404 { 458 {
@@ -426,6 +480,10 @@
426 "PQ": 31.9, 480 "PQ": 31.9,
427 "QS": 4.6, 481 "QS": 4.6,
428 "AUT": 0.6 482 "AUT": 0.6
483+ },
484+ "baselines_mae_pp": {
485+ "dernier_sondage": 1.345,
486+ "moyenne_14j": 1.488
429 } 487 }
430 }, 488 },
431 { 489 {
@@ -453,6 +511,10 @@
453 "PQ": 33.3, 511 "PQ": 33.3,
454 "QS": 4.5, 512 "QS": 4.5,
455 "AUT": 0.8 513 "AUT": 0.8
514+ },
515+ "baselines_mae_pp": {
516+ "dernier_sondage": 1.402,
517+ "moyenne_14j": 0.511
456 } 518 }
457 }, 519 },
458 { 520 {
@@ -480,6 +542,10 @@
480 "PQ": 34.0, 542 "PQ": 34.0,
481 "QS": 4.1, 543 "QS": 4.1,
482 "AUT": 0.9 544 "AUT": 0.9
545+ },
546+ "baselines_mae_pp": {
547+ "dernier_sondage": 1.441,
548+ "moyenne_14j": 1.435
483 } 549 }
484 }, 550 },
485 { 551 {
@@ -507,6 +573,10 @@
507 "PQ": 34.1, 573 "PQ": 34.1,
508 "QS": 4.0, 574 "QS": 4.0,
509 "AUT": 0.8 575 "AUT": 0.8
576+ },
577+ "baselines_mae_pp": {
578+ "dernier_sondage": 2.933,
579+ "moyenne_14j": 1.742
510 } 580 }
511 }, 581 },
512 { 582 {
@@ -534,6 +604,10 @@
534 "PQ": 34.1, 604 "PQ": 34.1,
535 "QS": 4.0, 605 "QS": 4.0,
536 "AUT": 0.8 606 "AUT": 0.8
607+ },
608+ "baselines_mae_pp": {
609+ "dernier_sondage": 2.933,
610+ "moyenne_14j": 2.159
537 } 611 }
538 }, 612 },
539 { 613 {
@@ -561,6 +635,10 @@
561 "PQ": 33.2, 635 "PQ": 33.2,
562 "QS": 4.3, 636 "QS": 4.3,
563 "AUT": 0.8 637 "AUT": 0.8
638+ },
639+ "baselines_mae_pp": {
640+ "dernier_sondage": 1.815,
641+ "moyenne_14j": 2.117
564 } 642 }
565 }, 643 },
566 { 644 {
@@ -588,6 +666,10 @@
588 "PQ": 33.2, 666 "PQ": 33.2,
589 "QS": 4.3, 667 "QS": 4.3,
590 "AUT": 0.8 668 "AUT": 0.8
669+ },
670+ "baselines_mae_pp": {
671+ "dernier_sondage": 1.815,
672+ "moyenne_14j": 2.251
591 } 673 }
592 } 674 }
593 ], 675 ],
@@ -599,7 +681,9 @@
599 "sharpness_pp": 17.467, 681 "sharpness_pp": 17.467,
600 "coverage_50": 0.7, 682 "coverage_50": 0.7,
601 "coverage_90": 0.8, 683 "coverage_90": 0.8,
602− "coverage_95": 0.8 684+ "coverage_95": 0.8,
685+ "baseline_dernier_sondage_mae_pp": 1.957,
686+ "baseline_moyenne_14j_mae_pp": 1.569
603 }, 687 },
604 "industry_sd_loeo": 0.2245 688 "industry_sd_loeo": 0.2245
605 }, 689 },
@@ -645,6 +729,10 @@
645 "PQ": 32.3, 729 "PQ": 32.3,
646 "QS": 9.0, 730 "QS": 9.0,
647 "AUT": 5.2 731 "AUT": 5.2
732+ },
733+ "baselines_mae_pp": {
734+ "dernier_sondage": 2.441,
735+ "moyenne_14j": 1.948
648 } 736 }
649 }, 737 },
650 { 738 {
@@ -672,6 +760,10 @@
672 "PQ": 32.6, 760 "PQ": 32.6,
673 "QS": 10.2, 761 "QS": 10.2,
674 "AUT": 4.4 762 "AUT": 4.4
763+ },
764+ "baselines_mae_pp": {
765+ "dernier_sondage": 2.129,
766+ "moyenne_14j": 2.129
675 } 767 }
676 }, 768 },
677 { 769 {
@@ -699,6 +791,10 @@
699 "PQ": 33.0, 791 "PQ": 33.0,
700 "QS": 9.8, 792 "QS": 9.8,
701 "AUT": 4.3 793 "AUT": 4.3
794+ },
795+ "baselines_mae_pp": {
796+ "dernier_sondage": 4.5,
797+ "moyenne_14j": 2.643
702 } 798 }
703 }, 799 },
704 { 800 {
@@ -726,6 +822,10 @@
726 "PQ": 33.0, 822 "PQ": 33.0,
727 "QS": 9.8, 823 "QS": 9.8,
728 "AUT": 4.3 824 "AUT": 4.3
825+ },
826+ "baselines_mae_pp": {
827+ "dernier_sondage": 4.5,
828+ "moyenne_14j": 2.643
729 } 829 }
730 }, 830 },
731 { 831 {
@@ -753,6 +853,10 @@
753 "PQ": 35.3, 853 "PQ": 35.3,
754 "QS": 6.8, 854 "QS": 6.8,
755 "AUT": 6.9 855 "AUT": 6.9
856+ },
857+ "baselines_mae_pp": {
858+ "dernier_sondage": 5.043,
859+ "moyenne_14j": 3.336
756 } 860 }
757 }, 861 },
758 { 862 {
@@ -780,6 +884,10 @@
780 "PQ": 33.5, 884 "PQ": 33.5,
781 "QS": 7.2, 885 "QS": 7.2,
782 "AUT": 6.4 886 "AUT": 6.4
887+ },
888+ "baselines_mae_pp": {
889+ "dernier_sondage": 2.255,
890+ "moyenne_14j": 2.372
783 } 891 }
784 }, 892 },
785 { 893 {
@@ -807,6 +915,10 @@
807 "PQ": 31.1, 915 "PQ": 31.1,
808 "QS": 7.4, 916 "QS": 7.4,
809 "AUT": 6.6 917 "AUT": 6.6
918+ },
919+ "baselines_mae_pp": {
920+ "dernier_sondage": 3.252,
921+ "moyenne_14j": 1.902
810 } 922 }
811 }, 923 },
812 { 924 {
@@ -834,6 +946,10 @@
834 "PQ": 32.1, 946 "PQ": 32.1,
835 "QS": 7.8, 947 "QS": 7.8,
836 "AUT": 5.9 948 "AUT": 5.9
949+ },
950+ "baselines_mae_pp": {
951+ "dernier_sondage": 2.183,
952+ "moyenne_14j": 2.429
837 } 953 }
838 }, 954 },
839 { 955 {
@@ -861,6 +977,10 @@
861 "PQ": 32.5, 977 "PQ": 32.5,
862 "QS": 7.8, 978 "QS": 7.8,
863 "AUT": 5.8 979 "AUT": 5.8
980+ },
981+ "baselines_mae_pp": {
982+ "dernier_sondage": 5.28,
983+ "moyenne_14j": 2.608
864 } 984 }
865 }, 985 },
866 { 986 {
@@ -888,6 +1008,10 @@
888 "PQ": 33.4, 1008 "PQ": 33.4,
889 "QS": 7.8, 1009 "QS": 7.8,
890 "AUT": 5.7 1010 "AUT": 5.7
1011+ },
1012+ "baselines_mae_pp": {
1013+ "dernier_sondage": 2.308,
1014+ "moyenne_14j": 2.534
891 } 1015 }
892 } 1016 }
893 ], 1017 ],
@@ -899,7 +1023,9 @@
899 "sharpness_pp": 18.748, 1023 "sharpness_pp": 18.748,
900 "coverage_50": 0.62, 1024 "coverage_50": 0.62,
901 "coverage_90": 0.98, 1025 "coverage_90": 0.98,
902− "coverage_95": 1.0 1026+ "coverage_95": 1.0,
1027+ "baseline_dernier_sondage_mae_pp": 3.389,
1028+ "baseline_moyenne_14j_mae_pp": 2.454
903 }, 1029 },
904 "industry_sd_loeo": 0.2308 1030 "industry_sd_loeo": 0.2308
905 }, 1031 },
@@ -945,6 +1071,10 @@
945 "PQ": 34.3, 1071 "PQ": 34.3,
946 "QS": 8.7, 1072 "QS": 8.7,
947 "AUT": 3.6 1073 "AUT": 3.6
1074+ },
1075+ "baselines_mae_pp": {
1076+ "dernier_sondage": 3.291,
1077+ "moyenne_14j": 4.643
948 } 1078 }
949 }, 1079 },
950 { 1080 {
@@ -972,6 +1102,10 @@
972 "PQ": 35.8, 1102 "PQ": 35.8,
973 "QS": 9.0, 1103 "QS": 9.0,
974 "AUT": 3.6 1104 "AUT": 3.6
1105+ },
1106+ "baselines_mae_pp": {
1107+ "dernier_sondage": 7.028,
1108+ "moyenne_14j": 5.361
975 } 1109 }
976 }, 1110 },
977 { 1111 {
@@ -999,6 +1133,10 @@
999 "PQ": 37.3, 1133 "PQ": 37.3,
1000 "QS": 8.5, 1134 "QS": 8.5,
1001 "AUT": 7.6 1135 "AUT": 7.6
1136+ },
1137+ "baselines_mae_pp": {
1138+ "dernier_sondage": 11.68,
1139+ "moyenne_14j": 8.241
1002 } 1140 }
1003 }, 1141 },
1004 { 1142 {
@@ -1026,6 +1164,10 @@
1026 "PQ": 41.9, 1164 "PQ": 41.9,
1027 "QS": 8.1, 1165 "QS": 8.1,
1028 "AUT": 8.1 1166 "AUT": 8.1
1167+ },
1168+ "baselines_mae_pp": {
1169+ "dernier_sondage": 12.48,
1170+ "moyenne_14j": 12.48
1029 } 1171 }
1030 }, 1172 },
1031 { 1173 {
@@ -1053,6 +1195,10 @@
1053 "PQ": 37.9, 1195 "PQ": 37.9,
1054 "QS": 7.5, 1196 "QS": 7.5,
1055 "AUT": 6.4 1197 "AUT": 6.4
1198+ },
1199+ "baselines_mae_pp": {
1200+ "dernier_sondage": 4.574,
1201+ "moyenne_14j": 5.873
1056 } 1202 }
1057 }, 1203 },
1058 { 1204 {
@@ -1080,6 +1226,10 @@
1080 "PQ": 37.4, 1226 "PQ": 37.4,
1081 "QS": 8.3, 1227 "QS": 8.3,
1082 "AUT": 5.6 1228 "AUT": 5.6
1229+ },
1230+ "baselines_mae_pp": {
1231+ "dernier_sondage": 5.382,
1232+ "moyenne_14j": 6.12
1083 } 1233 }
1084 }, 1234 },
1085 { 1235 {
@@ -1107,6 +1257,10 @@
1107 "PQ": 35.2, 1257 "PQ": 35.2,
1108 "QS": 8.5, 1258 "QS": 8.5,
1109 "AUT": 4.9 1259 "AUT": 4.9
1260+ },
1261+ "baselines_mae_pp": {
1262+ "dernier_sondage": 7.028,
1263+ "moyenne_14j": 5.248
1110 } 1264 }
1111 }, 1265 },
1112 { 1266 {
@@ -1134,6 +1288,10 @@
1134 "PQ": 32.5, 1288 "PQ": 32.5,
1135 "QS": 8.4, 1289 "QS": 8.4,
1136 "AUT": 5.0 1290 "AUT": 5.0
1291+ },
1292+ "baselines_mae_pp": {
1293+ "dernier_sondage": 2.106,
1294+ "moyenne_14j": 3.806
1137 } 1295 }
1138 }, 1296 },
1139 { 1297 {
@@ -1161,6 +1319,10 @@
1161 "PQ": 27.4, 1319 "PQ": 27.4,
1162 "QS": 8.6, 1320 "QS": 8.6,
1163 "AUT": 4.7 1321 "AUT": 4.7
1322+ },
1323+ "baselines_mae_pp": {
1324+ "dernier_sondage": 0.636,
1325+ "moyenne_14j": 2.085
1164 } 1326 }
1165 }, 1327 },
1166 { 1328 {
@@ -1188,6 +1350,10 @@
1188 "PQ": 27.4, 1350 "PQ": 27.4,
1189 "QS": 8.6, 1351 "QS": 8.6,
1190 "AUT": 4.7 1352 "AUT": 4.7
1353+ },
1354+ "baselines_mae_pp": {
1355+ "dernier_sondage": 0.636,
1356+ "moyenne_14j": 2.085
1191 } 1357 }
1192 } 1358 }
1193 ], 1359 ],
@@ -1199,7 +1365,9 @@
1199 "sharpness_pp": 16.551, 1365 "sharpness_pp": 16.551,
1200 "coverage_50": 0.3, 1366 "coverage_50": 0.3,
1201 "coverage_90": 0.64, 1367 "coverage_90": 0.64,
1202− "coverage_95": 0.72 1368+ "coverage_95": 0.72,
1369+ "baseline_dernier_sondage_mae_pp": 5.484,
1370+ "baseline_moyenne_14j_mae_pp": 5.594
1203 }, 1371 },
1204 "industry_sd_loeo": 0.2242 1372 "industry_sd_loeo": 0.2242
1205 }, 1373 },
@@ -1245,6 +1413,10 @@
1245 "PQ": 18.9, 1413 "PQ": 18.9,
1246 "QS": 10.4, 1414 "QS": 10.4,
1247 "AUT": 7.5 1415 "AUT": 7.5
1416+ },
1417+ "baselines_mae_pp": {
1418+ "dernier_sondage": 3.226,
1419+ "moyenne_14j": 3.446
1248 } 1420 }
1249 }, 1421 },
1250 { 1422 {
@@ -1272,6 +1444,10 @@
1272 "PQ": 15.9, 1444 "PQ": 15.9,
1273 "QS": 9.9, 1445 "QS": 9.9,
1274 "AUT": 7.0 1446 "AUT": 7.0
1447+ },
1448+ "baselines_mae_pp": {
1449+ "dernier_sondage": 3.904,
1450+ "moyenne_14j": 3.904
1275 } 1451 }
1276 }, 1452 },
1277 { 1453 {
@@ -1299,6 +1475,10 @@
1299 "PQ": 17.4, 1475 "PQ": 17.4,
1300 "QS": 11.6, 1476 "QS": 11.6,
1301 "AUT": 8.2 1477 "AUT": 8.2
1478+ },
1479+ "baselines_mae_pp": {
1480+ "dernier_sondage": 4.032,
1481+ "moyenne_14j": 4.032
1302 } 1482 }
1303 }, 1483 },
1304 { 1484 {
@@ -1326,6 +1506,10 @@
1326 "PQ": 16.5, 1506 "PQ": 16.5,
1327 "QS": 10.8, 1507 "QS": 10.8,
1328 "AUT": 7.6 1508 "AUT": 7.6
1509+ },
1510+ "baselines_mae_pp": {
1511+ "dernier_sondage": 3.008,
1512+ "moyenne_14j": 3.008
1329 } 1513 }
1330 }, 1514 },
1331 { 1515 {
@@ -1353,6 +1537,10 @@
1353 "PQ": 17.0, 1537 "PQ": 17.0,
1354 "QS": 11.4, 1538 "QS": 11.4,
1355 "AUT": 7.7 1539 "AUT": 7.7
1540+ },
1541+ "baselines_mae_pp": {
1542+ "dernier_sondage": 3.648,
1543+ "moyenne_14j": 2.166
1356 } 1544 }
1357 }, 1545 },
1358 { 1546 {
@@ -1380,6 +1568,10 @@
1380 "PQ": 19.1, 1568 "PQ": 19.1,
1381 "QS": 13.0, 1569 "QS": 13.0,
1382 "AUT": 6.7 1570 "AUT": 6.7
1571+ },
1572+ "baselines_mae_pp": {
1573+ "dernier_sondage": 3.248,
1574+ "moyenne_14j": 3.155
1383 } 1575 }
1384 }, 1576 },
1385 { 1577 {
@@ -1407,6 +1599,10 @@
1407 "PQ": 19.3, 1599 "PQ": 19.3,
1408 "QS": 14.8, 1600 "QS": 14.8,
1409 "AUT": 5.2 1601 "AUT": 5.2
1602+ },
1603+ "baselines_mae_pp": {
1604+ "dernier_sondage": 4.872,
1605+ "moyenne_14j": 3.176
1410 } 1606 }
1411 }, 1607 },
1412 { 1608 {
@@ -1434,6 +1630,10 @@
1434 "PQ": 20.0, 1630 "PQ": 20.0,
1435 "QS": 16.0, 1631 "QS": 16.0,
1436 "AUT": 4.9 1632 "AUT": 4.9
1633+ },
1634+ "baselines_mae_pp": {
1635+ "dernier_sondage": 3.248,
1636+ "moyenne_14j": 2.779
1437 } 1637 }
1438 }, 1638 },
1439 { 1639 {
@@ -1461,6 +1661,10 @@
1461 "PQ": 19.3, 1661 "PQ": 19.3,
1462 "QS": 16.3, 1662 "QS": 16.3,
1463 "AUT": 4.5 1663 "AUT": 4.5
1664+ },
1665+ "baselines_mae_pp": {
1666+ "dernier_sondage": 2.848,
1667+ "moyenne_14j": 2.96
1464 } 1668 }
1465 }, 1669 },
1466 { 1670 {
@@ -1488,6 +1692,10 @@
1488 "PQ": 19.1, 1692 "PQ": 19.1,
1489 "QS": 16.9, 1693 "QS": 16.9,
1490 "AUT": 4.5 1694 "AUT": 4.5
1695+ },
1696+ "baselines_mae_pp": {
1697+ "dernier_sondage": 2.168,
1698+ "moyenne_14j": 2.73
1491 } 1699 }
1492 } 1700 }
1493 ], 1701 ],
@@ -1499,7 +1707,9 @@
1499 "sharpness_pp": 14.918, 1707 "sharpness_pp": 14.918,
1500 "coverage_50": 0.42, 1708 "coverage_50": 0.42,
1501 "coverage_90": 0.9, 1709 "coverage_90": 0.9,
1502− "coverage_95": 0.92 1710+ "coverage_95": 0.92,
1711+ "baseline_dernier_sondage_mae_pp": 3.42,
1712+ "baseline_moyenne_14j_mae_pp": 3.136
1503 }, 1713 },
1504 "industry_sd_loeo": 0.212 1714 "industry_sd_loeo": 0.212
1505 }, 1715 },
@@ -1549,6 +1759,10 @@
1549 "QS": 14.0, 1759 "QS": 14.0,
1550 "PCQ": 14.7, 1760 "PCQ": 14.7,
1551 "AUT": 3.9 1761 "AUT": 3.9
1762+ },
1763+ "baselines_mae_pp": {
1764+ "dernier_sondage": 3.083,
1765+ "moyenne_14j": 3.083
1552 } 1766 }
1553 }, 1767 },
1554 { 1768 {
@@ -1578,6 +1792,10 @@
1578 "QS": 13.9, 1792 "QS": 13.9,
1579 "PCQ": 16.3, 1793 "PCQ": 16.3,
1580 "AUT": 3.7 1794 "AUT": 3.7
1795+ },
1796+ "baselines_mae_pp": {
1797+ "dernier_sondage": 2.347,
1798+ "moyenne_14j": 2.347
1581 } 1799 }
1582 }, 1800 },
1583 { 1801 {
@@ -1607,6 +1825,10 @@
1607 "QS": 14.4, 1825 "QS": 14.4,
1608 "PCQ": 14.2, 1826 "PCQ": 14.2,
1609 "AUT": 3.6 1827 "AUT": 3.6
1828+ },
1829+ "baselines_mae_pp": {
1830+ "dernier_sondage": 2.012,
1831+ "moyenne_14j": 2.012
1610 } 1832 }
1611 }, 1833 },
1612 { 1834 {
@@ -1636,6 +1858,10 @@
1636 "QS": 14.4, 1858 "QS": 14.4,
1637 "PCQ": 14.2, 1859 "PCQ": 14.2,
1638 "AUT": 3.6 1860 "AUT": 3.6
1861+ },
1862+ "baselines_mae_pp": {
1863+ "dernier_sondage": 2.012,
1864+ "moyenne_14j": 2.082
1639 } 1865 }
1640 }, 1866 },
1641 { 1867 {
@@ -1665,6 +1891,10 @@
1665 "QS": 12.8, 1891 "QS": 12.8,
1666 "PCQ": 19.0, 1892 "PCQ": 19.0,
1667 "AUT": 4.0 1893 "AUT": 4.0
1894+ },
1895+ "baselines_mae_pp": {
1896+ "dernier_sondage": 3.893,
1897+ "moyenne_14j": 3.763
1668 } 1898 }
1669 }, 1899 },
1670 { 1900 {
@@ -1694,6 +1924,10 @@
1694 "QS": 12.4, 1924 "QS": 12.4,
1695 "PCQ": 18.0, 1925 "PCQ": 18.0,
1696 "AUT": 4.0 1926 "AUT": 4.0
1927+ },
1928+ "baselines_mae_pp": {
1929+ "dernier_sondage": 4.113,
1930+ "moyenne_14j": 3.745
1697 } 1931 }
1698 }, 1932 },
1699 { 1933 {
@@ -1723,6 +1957,10 @@
1723 "QS": 12.2, 1957 "QS": 12.2,
1724 "PCQ": 18.6, 1958 "PCQ": 18.6,
1725 "AUT": 3.8 1959 "AUT": 3.8
1960+ },
1961+ "baselines_mae_pp": {
1962+ "dernier_sondage": 3.173,
1963+ "moyenne_14j": 3.443
1726 } 1964 }
1727 }, 1965 },
1728 { 1966 {
@@ -1752,6 +1990,10 @@
1752 "QS": 13.4, 1990 "QS": 13.4,
1753 "PCQ": 17.6, 1991 "PCQ": 17.6,
1754 "AUT": 3.4 1992 "AUT": 3.4
1993+ },
1994+ "baselines_mae_pp": {
1995+ "dernier_sondage": 2.68,
1996+ "moyenne_14j": 2.565
1755 } 1997 }
1756 }, 1998 },
1757 { 1999 {
@@ -1781,6 +2023,10 @@
1781 "QS": 13.0, 2023 "QS": 13.0,
1782 "PCQ": 16.7, 2024 "PCQ": 16.7,
1783 "AUT": 3.2 2025 "AUT": 3.2
2026+ },
2027+ "baselines_mae_pp": {
2028+ "dernier_sondage": 1.312,
2029+ "moyenne_14j": 2.225
1784 } 2030 }
1785 }, 2031 },
1786 { 2032 {
@@ -1810,6 +2056,10 @@
1810 "QS": 12.9, 2056 "QS": 12.9,
1811 "PCQ": 16.4, 2057 "PCQ": 16.4,
1812 "AUT": 3.1 2058 "AUT": 3.1
2059+ },
2060+ "baselines_mae_pp": {
2061+ "dernier_sondage": 1.522,
2062+ "moyenne_14j": 1.962
1813 } 2063 }
1814 } 2064 }
1815 ], 2065 ],
@@ -1821,7 +2071,9 @@
1821 "sharpness_pp": 14.191, 2071 "sharpness_pp": 14.191,
1822 "coverage_50": 0.416, 2072 "coverage_50": 0.416,
1823 "coverage_90": 0.75, 2073 "coverage_90": 0.75,
1824− "coverage_95": 0.8 2074+ "coverage_95": 0.8,
2075+ "baseline_dernier_sondage_mae_pp": 2.615,
2076+ "baseline_moyenne_14j_mae_pp": 2.723
1825 }, 2077 },
1826 "industry_sd_loeo": 0.2258 2078 "industry_sd_loeo": 0.2258
1827 } 2079 }
@@ -1834,6 +2086,8 @@
1834 "sharpness_pp": 17.093, 2086 "sharpness_pp": 17.093,
1835 "coverage_50": 0.419, 2087 "coverage_50": 0.419,
1836 "coverage_90": 0.795, 2088 "coverage_90": 0.795,
1837− "coverage_95": 0.83 2089+ "coverage_95": 0.83,
2090+ "baseline_dernier_sondage_mae_pp": 3.844,
2091+ "baseline_moyenne_14j_mae_pp": 3.487
1838 } 2092 }
1839 } 2093 }
\ No newline at end of file
modified backend/tests/test_beyond.py +46 −0
@@ -273,3 +273,49 @@ def test_era_parties_trend():
273 tr = fit_trend(polls, {}, date(2007, 3, 1), parties=parties) 273 tr = fit_trend(polls, {}, date(2007, 3, 1), parties=parties)
274 assert tr is not None and tr.x.shape == (4,) 274 assert tr is not None and tr.x.shape == (4,)
275 assert abs(tr.share_mean[-1].sum() - 1.0) < 1e-9 275 assert abs(tr.share_mean[-1].sum() - 1.0) < 1e-9
276+
277+
278+def test_primary_report_extraction():
279+ from app.modeling.validation.scoring import vote_errors # noqa (import sanity)
280+ from app.ingest.pollster_reports import extract_toplines
281+ md = """Sondage Léger — Intentions de vote au Québec
282+ Réalisé du 22 au 26 septembre 2026 auprès de 1024 répondants.
283+ Intentions de vote : le PQ obtient 31 %, la CAQ 22 %, le Parti libéral 21 %,
284+ le Parti conservateur du Québec 14 % et Québec solidaire 9 %."""
285+ ext = extract_toplines(md)
286+ assert ext["shares"] == {"PQ": 31.0, "CAQ": 22.0, "PLQ": 21.0,
287+ "PCQ": 14.0, "QS": 9.0}
288+ assert str(ext["field_end"]) == "2026-09-26"
289+ assert ext["sample_size"] == 1024
290+ assert ext["confidence"] >= 0.9
291+ # rapport pauvre → confiance basse, jamais de création silencieuse
292+ bad = extract_toplines("Communiqué sans chiffres pertinents.")
293+ assert bad["confidence"] < 0.7 and not bad["shares"]
294+
295+
296+def test_invariants_simulation():
297+ """Invariants §qualité : 127 sièges/simulation, probs [0,1], somme=1."""
298+ from app.modeling.simulate import SimulationInput, run_simulation
299+ D, K = 127, 6
300+ rng = np.random.default_rng(11)
301+ baselines = rng.dirichlet(np.ones(K) * 8, D)
302+ inp = SimulationInput(
303+ x_mean=np.zeros(K - 1), P=np.eye(K - 1) * 0.02,
304+ baseline_national=np.full(K, 1 / K),
305+ district_names=[f"c{i}" for i in range(D)],
306+ district_baselines=baselines,
307+ district_regions=[f"r{i % 13}" for i in range(D)],
308+ retirement_flags=np.zeros(D, dtype=int),
309+ incumbent_party_idx=np.full(D, -1), majority_seats=64)
310+ sim = run_simulation(inp, n_sims=2000, keep_raw=True)
311+ counts = np.zeros(2000)
312+ for k in range(K):
313+ counts += (sim.raw_winners == k).sum(axis=1) * 0 + 0 # noqa
314+ assert sim.raw_winners.shape == (2000, D)
315+ per_sim = np.ones((2000, D)).sum(axis=1)
316+ assert (per_sim == D).all() # 127 sièges par simulation
317+ for d in sim.districts:
318+ s = sum(d["win_probs"].values())
319+ assert abs(s - 1.0) < 1e-6 # P(victoire) somme à 1
320+ assert all(0.0 <= v <= 1.0 for v in d["win_probs"].values())
321+ assert abs(sum(sim.national_vote.values()) - 100.0) < 0.5
modified frontend/circonscriptions.html +58 −0
@@ -49,6 +49,17 @@
49 les simulations.</p> 49 les simulations.</p>
50 <div class="card" style="overflow-x:auto"><table class="data" id="tips-table"></table></div> 50 <div class="card" style="overflow-x:auto"><table class="data" id="tips-table"></table></div>
51 51
52+ <h2 class="section">La course à 64</h2>
53+ <p class="section-sub">Pour chaque parti, ses circonscriptions classées de la plus sûre à la
54+ plus décisive : la <b>64ᵉ</b> est son « siège de la majorité ». On montre l'échelle autour
55+ du seuil (rangs 56 à 72) — marge attendue = écart projeté entre les deux premiers partis
56+ dans la circonscription (le swing minimal pour la faire basculer).</p>
57+ <div class="filters" id="p64-tabs" role="tablist"></div>
58+ <div class="card" style="padding:10px 12px">
59+ <div class="kv" style="padding:4px 2px 10px"><span id="p64-summary"></span><b id="p64-seat64"></b></div>
60+ <div class="table-scroll"><table class="data" id="p64-table"></table></div>
61+ </div>
62+
52 <h2 class="section">Les 127 circonscriptions</h2> 63 <h2 class="section">Les 127 circonscriptions</h2>
53 <div class="filters"> 64 <div class="filters">
54 <input type="text" id="search" placeholder="Rechercher une circonscription…"> 65 <input type="text" id="search" placeholder="Rechercher une circonscription…">
@@ -107,6 +118,53 @@
107 }).join("") 118 }).join("")
108 : `<tr><td style="color:var(--muted)">Pivots disponibles au prochain run du pipeline.</td></tr>`; 119 : `<tr><td style="color:var(--muted)">Pivots disponibles au prochain run du pipeline.</td></tr>`;
109 120
121+ /* ---- course à 64 ---- */
122+ const paths = (bg && bg.paths_to_64) || null;
123+ if (paths) {
124+ const tabs = document.getElementById("p64-tabs");
125+ const active = () => tabs.querySelector("button.on")?.dataset.p ||
126+ Object.keys(paths).sort((a, b) => paths[b].seats_ge_50 - paths[a].seats_ge_50)[0];
127+ tabs.innerHTML = Object.keys(paths)
128+ .sort((a, b) => paths[b].seats_ge_50 - paths[a].seats_ge_50)
129+ .map((p, i) => `<button data-p="${p}" class="${i === 0 ? "on" : ""}"
130+ style="${i === 0 ? `border-color:${partyColor(p)}` : ""}" role="tab"
131+ aria-selected="${i === 0}"><span class="dot" style="background:${partyColor(p)}"></span>${p}
132+ <span style="color:var(--muted);font-weight:400">· ${paths[p].seats_ge_50} sièges ≥ 50 %</span></button>`).join("");
133+ function renderP64() {
134+ const p = active();
135+ const d = paths[p];
136+ document.getElementById("p64-summary").textContent =
137+ `${partyName(p)} — ${d.seats_ge_50} circonscriptions où il est favori (≥ 50 %)`;
138+ document.getElementById("p64-seat64").textContent = d.majority_seat
139+ ? `Siège de la majorité (64ᵉ) : ${d.majority_seat.district} — P(victoire) ${(d.majority_seat.win_prob * 100).toFixed(0)} %`
140+ : "—";
141+ const window_ = d.ladder.filter(r => r.rank >= 56 && r.rank <= 72);
142+ document.getElementById("p64-table").innerHTML =
143+ `<tr><th>Rang</th><th>Circonscription</th><th>P(victoire ${p})</th><th>Marge attendue</th><th>Favori actuel</th></tr>` +
144+ window_.map(r => `<tr style="${r.is_majority_seat
145+ ? `background:color-mix(in srgb, ${partyColor(p)} 12%, transparent);font-weight:650` : ""}">
146+ <td class="tabular" style="color:var(--muted)">${r.rank}${r.is_majority_seat ? " ★" : ""}</td>
147+ <td><b>${r.district}</b>${r.is_majority_seat ? ' <span class="chip badge-aux">majorité 64</span>' : ""}</td>
148+ <td class="tip-num">${(r.win_prob * 100).toFixed(0)} %</td>
149+ <td class="tabular">${r.margin_pp != null ? r.margin_pp.toFixed(1) + " pp" : "—"}</td>
150+ <td><span class="dot" style="background:${partyColor(r.favorite)}"></span>${r.favorite}</td>
151+ </tr>`).join("");
152+ }
153+ tabs.addEventListener("click", e => {
154+ const b = e.target.closest("button[data-p]");
155+ if (!b) return;
156+ tabs.querySelectorAll("button").forEach(x => { x.classList.remove("on");
157+ x.style.borderColor = ""; x.setAttribute("aria-selected", "false"); });
158+ b.classList.add("on"); b.style.borderColor = partyColor(b.dataset.p);
159+ b.setAttribute("aria-selected", "true");
160+ renderP64();
161+ });
162+ renderP64();
163+ } else {
164+ document.getElementById("p64-table").innerHTML =
165+ `<tr><td style="color:var(--muted)">Course à 64 disponible au prochain run.</td></tr>`;
166+ }
167+
110 /* ---- explorateur ---- */ 168 /* ---- explorateur ---- */
111 const regions = [...new Set(ridings.map(r => r.region))].sort(); 169 const regions = [...new Set(ridings.map(r => r.region))].sort();
112 document.getElementById("f-region").innerHTML += 170 document.getElementById("f-region").innerHTML +=
113 171
Menu

Theme