v2.1.0 — signaux web avancés + « Le point du jour »
- Attention Wikipédia (pageviews partis+chefs, API Wikimedia) : parts 7 j/28 j, z-scores; détecteur de turbulence → variance de campagne ×≤1,45 (jamais la moyenne) [Smith & Gustafson POQ 2017] - Sondage synthétique LLM « silicon sampling » : 96 strates région×âge×genre×langue poststratifiées, ancré dans les manchettes réelles — EXPÉRIMENTAL, poids nul [arXiv:2411.01582; critique McKown-Dawson 2025] - Page quotidienne /aujourdhui : forecast daté, Δ vs veille, journal 24 h (sondages intégrés + radar), attention web avec sparklines, synthétique vs forecast - API : /api/today, /api/attention, /api/synthetic; admin POST /synthetic (amorçage) - forecast(drift_multiplier) : turbulence élargit l'incertitude seulement - 24 tests verts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
15 changed files +2,291 −9
modified
METHODOLOGY.md
+20 −0
@@ -101,6 +101,26 @@ sondages dans la presse — alerte avec provenance, aucune ingestion automatique | ||
| 101 | 101 | chiffres; (b) extraction du % de satisfaits (baromètres Léger); (c) presse élargie |
| 102 | 102 | au-delà des RSS. Toutes les pages brutes sont archivées (`data/raw/firecrawl/`). |
| 103 | 103 | |
| 104 | +**Signaux web avancés (v2.1)** : | |
| 105 | +- **Attention Wikipédia** (API Wikimedia, pageviews des pages des partis et chef·fe·s, | |
| 106 | + fr.wikipédia) — ajoute du pouvoir prédictif au-delà des sondages+fondamentaux | |
| 107 | + (Smith & Gustafson, *POQ* 2017; Salem & Stephany 2020). Signal **jamais | |
| 108 | + directionnel** : un pic global d'attention (z ≥ 2 sur 3 j vs base 180 j) gonfle la | |
| 109 | + variance de dérive du forecast (multiplicateur borné ≤ 1,45), sans toucher aucune | |
| 110 | + moyenne. Parts d'attention 7 j/28 j publiées (`/api/attention`). | |
| 111 | +- **Sondage synthétique LLM** (« silicon sampling », arXiv:2411.01582) — 96 strates | |
| 112 | + démographiques (région×âge×genre×langue, poids de population), LLM incarnant chaque | |
| 113 | + strate, ancré dans les manchettes réelles de la veille (jamais de chiffres de | |
| 114 | + sondage dans le prompt), poststratification. **EXPÉRIMENTAL, poids strictement | |
| 115 | + nul** (critique McKown-Dawson 2025 : un LLM ne collecte aucune donnée nouvelle); | |
| 116 | + écart au forecast publié comme objet d'étude, évaluation après le 5 octobre. | |
| 117 | + Provenance complète archivée (`data/synthetic_polls/`). | |
| 118 | + | |
| 119 | +**Page quotidienne** : `/aujourdhui` (« Le point du jour ») — snapshot daté : | |
| 120 | +probabilités publiées, Δ vs dernier run de la veille, journal 24 h (sondages intégrés, | |
| 121 | +détections du radar), attention web, sondage synthétique, état des couches | |
| 122 | +(`/api/today`). | |
| 123 | + | |
| 104 | 124 | **Décomposition** : à chaque run, le forecast est reconstruit couche par couche |
| 105 | 125 | (sondages seuls → + partielles → + fondamentaux → + médias) avec 8 000 simulations par |
| 106 | 126 | variante; les Δ de vote et de probabilité de chaque couche sont publiés. |
modified
backend/app/api/admin.py
+21 −0
@@ -160,6 +160,27 @@ def set_indicator(ind: IndicatorIn, db: Session = Depends(get_db)): | ||
| 160 | 160 | return {"id": row.id, "status": "enregistré"} |
| 161 | 161 | |
| 162 | 162 | |
| 163 | +class SyntheticIn(BaseModel): | |
| 164 | + """Injection d'un sondage synthétique généré hors-plateforme (amorçage).""" | |
| 165 | + shares: dict[str, float] | |
| 166 | + model: str | |
| 167 | + n_strata: int = Field(ge=1) | |
| 168 | + headlines: list[str] = Field(default_factory=list) | |
| 169 | + method: str = "silicon-sampling-bootstrap" | |
| 170 | + cells: list[dict] = Field(default_factory=list) | |
| 171 | + | |
| 172 | + | |
| 173 | +@router.post("/synthetic", dependencies=[Depends(require_admin)]) | |
| 174 | +def set_synthetic(s: SyntheticIn, db: Session = Depends(get_db)): | |
| 175 | + unknown = [k for k in s.shares if k not in settings.parties] | |
| 176 | + if unknown or not (85 <= sum(s.shares.values()) <= 110): | |
| 177 | + raise HTTPException(422, "Répartition invalide") | |
| 178 | + from ..modeling.synthetic_poll import save | |
| 179 | + save(db, s.shares, s.model, s.n_strata, s.headlines, method=s.method, | |
| 180 | + cells=s.cells) | |
| 181 | + return {"status": "enregistré", "shares": s.shares} | |
| 182 | + | |
| 183 | + | |
| 163 | 184 | @router.get("/logs", dependencies=[Depends(require_admin)]) |
| 164 | 185 | def logs(db: Session = Depends(get_db), limit: int = 100): |
| 165 | 186 | rows = (db.query(Mo.PipelineLog).order_by(Mo.PipelineLog.at.desc()).limit(limit).all()) |
modified
backend/app/api/public.py
+95 −0
@@ -219,6 +219,101 @@ def signals(db: Session = Depends(get_db), kind: str | None = None, | ||
| 219 | 219 | for s in rows]} |
| 220 | 220 | |
| 221 | 221 | |
| 222 | +@router.get("/attention") | |
| 223 | +def attention(db: Session = Depends(get_db)): | |
| 224 | + """Attention Wikipédia : parts, tendances, turbulence (signal non directionnel).""" | |
| 225 | + from ..ingest.wiki_attention import signal | |
| 226 | + return signal(db) | |
| 227 | + | |
| 228 | + | |
| 229 | +@router.get("/synthetic") | |
| 230 | +def synthetic(db: Session = Depends(get_db)): | |
| 231 | + """Dernier sondage synthétique LLM — EXPÉRIMENTAL, poids nul dans le forecast.""" | |
| 232 | + from ..modeling.synthetic_poll import latest | |
| 233 | + data = latest(db) | |
| 234 | + if data is None: | |
| 235 | + raise HTTPException(404, "Aucun sondage synthétique généré pour l'instant") | |
| 236 | + try: | |
| 237 | + run = _latest_run(db) | |
| 238 | + data["forecast_comparison"] = { | |
| 239 | + p: {"synthetique": data["shares"].get(p), | |
| 240 | + "forecast": run.national["forecast"][p]["mean"]} | |
| 241 | + for p in settings.parties if data.get("shares")} | |
| 242 | + except HTTPException: | |
| 243 | + pass | |
| 244 | + return data | |
| 245 | + | |
| 246 | + | |
| 247 | +@router.get("/today") | |
| 248 | +def today(db: Session = Depends(get_db)): | |
| 249 | + """« Le point du jour » : forecast daté + ce qui a changé depuis la veille.""" | |
| 250 | + run = _latest_run(db) | |
| 251 | + # dernier run d'une journée antérieure (comparaison « depuis hier ») | |
| 252 | + prev = (db.query(Mo.ForecastRun) | |
| 253 | + .filter(Mo.ForecastRun.is_backtest.is_(False), | |
| 254 | + Mo.ForecastRun.as_of < run.as_of) | |
| 255 | + .order_by(Mo.ForecastRun.as_of.desc(), Mo.ForecastRun.id.desc()).first()) | |
| 256 | + | |
| 257 | + def probs(r): | |
| 258 | + ens = (r.seats.get("ensemble") or {}).get("blended") if r.seats else None | |
| 259 | + return {p: (ens or {}).get(p, r.seats["per_party"][p]["prob_most"]) | |
| 260 | + for p in settings.parties} | |
| 261 | + | |
| 262 | + cur_p, cur_v = probs(run), {p: run.national["forecast"][p]["mean"] | |
| 263 | + for p in settings.parties} | |
| 264 | + cur_s = {p: run.seats["per_party"][p]["mean"] for p in settings.parties} | |
| 265 | + delta = None | |
| 266 | + if prev: | |
| 267 | + pv = {p: prev.national["forecast"][p]["mean"] for p in settings.parties} | |
| 268 | + pp = probs(prev) | |
| 269 | + ps = {p: prev.seats["per_party"][p]["mean"] for p in settings.parties} | |
| 270 | + delta = {"since": prev.as_of.isoformat(), | |
| 271 | + "prob_most": {p: round(cur_p[p] - pp[p], 4) for p in settings.parties}, | |
| 272 | + "vote": {p: round(cur_v[p] - pv[p], 2) for p in settings.parties}, | |
| 273 | + "seats": {p: round(cur_s[p] - ps[p], 1) for p in settings.parties}} | |
| 274 | + | |
| 275 | + # nouveautés des dernières 24 h | |
| 276 | + day_ago = datetime.now(timezone.utc) - timedelta(hours=24) | |
| 277 | + new_polls = (db.query(Mo.Poll).filter(Mo.Poll.accessed_at >= day_ago) | |
| 278 | + .order_by(Mo.Poll.field_end.desc()).limit(10).all()) | |
| 279 | + new_signals = (db.query(Mo.WebSignal).filter(Mo.WebSignal.detected_at >= day_ago) | |
| 280 | + .order_by(Mo.WebSignal.detected_at.desc()).limit(12).all()) | |
| 281 | + upcoming = (db.query(Mo.NewsEvent).filter(Mo.NewsEvent.event_date >= date.today()) | |
| 282 | + .order_by(Mo.NewsEvent.event_date.asc()).limit(5).all()) | |
| 283 | + | |
| 284 | + target = db.query(Mo.Election).filter_by(is_target=True).first() | |
| 285 | + B = run.national.get("beyond") or {} | |
| 286 | + return { | |
| 287 | + "date": date.today().isoformat(), | |
| 288 | + "as_of": run.as_of.isoformat(), "run_id": run.id, | |
| 289 | + "run_at": run.run_at.isoformat(), "model_version": run.model_version, | |
| 290 | + "days_to_election": (target.election_date - date.today()).days if target else None, | |
| 291 | + "prob_most": cur_p, "vote": cur_v, | |
| 292 | + "seats": {p: {"mean": cur_s[p], | |
| 293 | + "p05": run.seats["per_party"][p]["p05"], | |
| 294 | + "p95": run.seats["per_party"][p]["p95"], | |
| 295 | + "prob_majority": run.seats["per_party"][p]["prob_majority"]} | |
| 296 | + for p in settings.parties}, | |
| 297 | + "prob_no_majority": run.seats["summary"]["prob_no_majority"], | |
| 298 | + "delta": delta, | |
| 299 | + "beyond": {"web_attention": B.get("web_attention"), | |
| 300 | + "media_adjustment": B.get("media_adjustment"), | |
| 301 | + "market_ensemble": B.get("market_ensemble"), | |
| 302 | + "fundamentals": {"satisfaction": (B.get("fundamentals") or {}).get("satisfaction"), | |
| 303 | + "blend": (B.get("fundamentals") or {}).get("blend")}, | |
| 304 | + "n_byelections": len(B.get("byelections_used") or [])}, | |
| 305 | + "new_polls_24h": [{"pollster": p.pollster.name, | |
| 306 | + "field_end": p.field_end.isoformat(), | |
| 307 | + "sample_size": p.sample_size, | |
| 308 | + "shares": {r.party: r.normalized_value for r in p.results}} | |
| 309 | + for p in new_polls], | |
| 310 | + "new_signals_24h": [{"kind": s.kind, "title": s.title, "url": s.url, | |
| 311 | + "pollster": s.pollster} for s in new_signals], | |
| 312 | + "upcoming_events": [{"date": e.event_date.isoformat(), "title": e.title, | |
| 313 | + "kind": e.kind} for e in upcoming], | |
| 314 | + } | |
| 315 | + | |
| 316 | + | |
| 222 | 317 | @router.get("/polls") |
| 223 | 318 | def polls(db: Session = Depends(get_db), |
| 224 | 319 | date_from: date | None = None, date_to: date | None = None, |
modified
backend/app/config.py
+1 −1
@@ -36,7 +36,7 @@ _load_env_file(BASE_DIR / ".env") | ||
| 36 | 36 | class Settings(BaseSettings): |
| 37 | 37 | # --- Général --- |
| 38 | 38 | app_name: str = "QC Élection Forecast" |
| 39 | − model_version: str = "2.0.0" | |
| 39 | + model_version: str = "2.1.0" | |
| 40 | 40 | database_url: str = f"sqlite:///{BASE_DIR / 'qc_election.db'}" |
| 41 | 41 | admin_token: str = os.environ.get("ADMIN_TOKEN", "change-me") |
| 42 | 42 | port: int = 8140 |
added
backend/app/ingest/wiki_attention.py
+158 −0
@@ -0,0 +1,158 @@ | ||
| 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 | +"""Attention Wikipédia — comportement de recherche d'information (v2.1). | |
| 6 | + | |
| 7 | +Littérature : les pageviews Wikipédia ajoutent du pouvoir prédictif AU-DELÀ des | |
| 8 | +sondages et des fondamentaux (Smith & Gustafson, *Public Opinion Quarterly* 2017; | |
| 9 | +Salem & Stephany 2020 — « challenger's best friend ») : contrairement aux réseaux | |
| 10 | +sociaux, consulter une page est une recherche d'information privée, sans mécanisme | |
| 11 | +de rétroaction sociale, et l'attention se porte de façon disproportionnée vers les | |
| 12 | +partis en MOUVEMENT (gains de votes), surtout les challengers. | |
| 13 | + | |
| 14 | +Utilisation (prudente, jamais directionnelle) : | |
| 15 | + 1. **Signal affiché** : parts d'attention 7 j / 28 j par parti (page du parti + | |
| 16 | + pages des chef·fe·s, fr.wikipédia), tendance vs base de 180 j. | |
| 17 | + 2. **Détecteur de turbulence** : un pic d'attention global (z ≥ seuil) signale | |
| 18 | + un moment chaud de campagne → la variance de dérive du forecast est GONFLÉE | |
| 19 | + (multiplicateur borné ≤ 1,45). L'attention ne déplace jamais la moyenne — | |
| 20 | + un pic peut être bon ou mauvais pour un parti; il annonce du mouvement. | |
| 21 | + | |
| 22 | +Source : API REST Wikimedia (pageviews per-article), publique, sans clé. | |
| 23 | +""" | |
| 24 | +from __future__ import annotations | |
| 25 | + | |
| 26 | +import logging | |
| 27 | +from datetime import date, timedelta | |
| 28 | + | |
| 29 | +import httpx | |
| 30 | +import numpy as np | |
| 31 | +from sqlalchemy.orm import Session | |
| 32 | + | |
| 33 | +from ..config import settings | |
| 34 | +from .. import models as Mo | |
| 35 | + | |
| 36 | +log = logging.getLogger("wiki-attention") | |
| 37 | + | |
| 38 | +WM_API = ("https://wikimedia.org/api/rest_v1/metrics/pageviews/per-article/" | |
| 39 | + "fr.wikipedia/all-access/user/{article}/daily/{start}/{end}") | |
| 40 | +UA = {"User-Agent": "qc-election-forecast/2.1 (https://www.qc-election.com; " | |
| 41 | + "contact@spboucher.ai)"} | |
| 42 | + | |
| 43 | +# Pages suivies : parti + chef·fe·s (fr.wikipédia) | |
| 44 | +ARTICLES: dict[str, list[str]] = { | |
| 45 | + "CAQ": ["Coalition_avenir_Québec", "Christine_Fréchette"], | |
| 46 | + "PLQ": ["Parti_libéral_du_Québec", "Charles_Milliard"], | |
| 47 | + "PQ": ["Parti_québécois", "Paul_St-Pierre_Plamondon"], | |
| 48 | + "QS": ["Québec_solidaire", "Ruba_Ghazal", "Sol_Zanetti"], | |
| 49 | + "PCQ": ["Parti_conservateur_du_Québec", "Éric_Duhaime"], | |
| 50 | +} | |
| 51 | + | |
| 52 | +BASELINE_DAYS = 180 | |
| 53 | +INDICATOR = "wiki_att_{party}" | |
| 54 | + | |
| 55 | + | |
| 56 | +def _fetch_article(article: str, start: date, end: date) -> dict[date, int]: | |
| 57 | + from urllib.parse import quote | |
| 58 | + url = WM_API.format(article=quote(article, safe=""), | |
| 59 | + start=start.strftime("%Y%m%d"), end=end.strftime("%Y%m%d")) | |
| 60 | + r = httpx.get(url, headers=UA, timeout=30) | |
| 61 | + if r.status_code == 404: # article sans données sur la période | |
| 62 | + return {} | |
| 63 | + r.raise_for_status() | |
| 64 | + out = {} | |
| 65 | + for item in r.json().get("items", []): | |
| 66 | + d = date(int(item["timestamp"][:4]), int(item["timestamp"][4:6]), | |
| 67 | + int(item["timestamp"][6:8])) | |
| 68 | + out[d] = int(item["views"]) | |
| 69 | + return out | |
| 70 | + | |
| 71 | + | |
| 72 | +def refresh(db: Session, days: int = BASELINE_DAYS + 40) -> dict: | |
| 73 | + """Récupère et upsert les pageviews quotidiens (parti + chefs agrégés).""" | |
| 74 | + end = date.today() - timedelta(days=1) # la veille : journée complète | |
| 75 | + # reprise incrémentale : depuis le dernier point stocké (moins 3 j de marge) | |
| 76 | + last = (db.query(Mo.Indicator) | |
| 77 | + .filter(Mo.Indicator.name.like("wiki_att_%")) | |
| 78 | + .order_by(Mo.Indicator.as_of.desc()).first()) | |
| 79 | + start = max(end - timedelta(days=days), | |
| 80 | + (last.as_of - timedelta(days=3)) if last else end - timedelta(days=days)) | |
| 81 | + added = 0 | |
| 82 | + for party, arts in ARTICLES.items(): | |
| 83 | + daily: dict[date, int] = {} | |
| 84 | + for a in arts: | |
| 85 | + try: | |
| 86 | + for d, v in _fetch_article(a, start, end).items(): | |
| 87 | + daily[d] = daily.get(d, 0) + v | |
| 88 | + except Exception as e: | |
| 89 | + log.warning("pageviews %s: %s", a, e) | |
| 90 | + name = INDICATOR.format(party=party) | |
| 91 | + for d, v in daily.items(): | |
| 92 | + row = db.query(Mo.Indicator).filter_by(name=name, as_of=d).first() | |
| 93 | + if row is None: | |
| 94 | + db.add(Mo.Indicator(name=name, as_of=d, value=float(v), | |
| 95 | + source="Wikimedia pageviews (fr)", | |
| 96 | + source_url="https://wikimedia.org/api/rest_v1/", | |
| 97 | + method="wikimedia-api", | |
| 98 | + extra={"articles": arts})) | |
| 99 | + added += 1 | |
| 100 | + else: | |
| 101 | + row.value = float(v) | |
| 102 | + db.commit() | |
| 103 | + return {"points_ajoutés": added, "fenêtre": [start.isoformat(), end.isoformat()]} | |
| 104 | + | |
| 105 | + | |
| 106 | +def signal(db: Session) -> dict: | |
| 107 | + """Parts d'attention, tendances et détecteur de turbulence. | |
| 108 | + | |
| 109 | + Retourne aussi `drift_multiplier` ∈ [1, 1,45] : gonfle la variance de dérive | |
| 110 | + du forecast quand l'attention totale est anormalement élevée (z ≥ 2).""" | |
| 111 | + end = date.today() - timedelta(days=1) | |
| 112 | + start = end - timedelta(days=BASELINE_DAYS) | |
| 113 | + series: dict[str, dict[date, float]] = {} | |
| 114 | + for party in ARTICLES: | |
| 115 | + rows = (db.query(Mo.Indicator) | |
| 116 | + .filter(Mo.Indicator.name == INDICATOR.format(party=party), | |
| 117 | + Mo.Indicator.as_of >= start).all()) | |
| 118 | + series[party] = {r.as_of: r.value for r in rows} | |
| 119 | + all_days = sorted(set().union(*[set(s) for s in series.values()])) if series else [] | |
| 120 | + if len(all_days) < 30: | |
| 121 | + return {"available": False, "drift_multiplier": 1.0, | |
| 122 | + "note": "historique d'attention insuffisant (premier cycle)"} | |
| 123 | + | |
| 124 | + def win_sum(party, n): | |
| 125 | + cut = end - timedelta(days=n) | |
| 126 | + return sum(v for d, v in series[party].items() if d > cut) | |
| 127 | + | |
| 128 | + parties = list(ARTICLES) | |
| 129 | + tot7 = {p: win_sum(p, 7) for p in parties} | |
| 130 | + tot28 = {p: win_sum(p, 28) for p in parties} | |
| 131 | + base = {p: win_sum(p, BASELINE_DAYS) for p in parties} | |
| 132 | + s7, s28, sbase = sum(tot7.values()), sum(tot28.values()), sum(base.values()) | |
| 133 | + | |
| 134 | + out_p, z_max = {}, 0.0 | |
| 135 | + for p in parties: | |
| 136 | + vals = np.array([series[p].get(d, 0.0) for d in all_days]) | |
| 137 | + mu, sd = float(vals[:-7].mean()), float(vals[:-7].std() or 1.0) | |
| 138 | + z_recent = float((np.mean(vals[-3:]) - mu) / sd) | |
| 139 | + z_max = max(z_max, z_recent) | |
| 140 | + share7 = tot7[p] / s7 if s7 else 0.0 | |
| 141 | + share_base = base[p] / sbase if sbase else 0.0 | |
| 142 | + out_p[p] = { | |
| 143 | + "views_7j": int(tot7[p]), | |
| 144 | + "share_7j": round(share7 * 100, 1), | |
| 145 | + "share_28j": round((tot28[p] / s28 if s28 else 0) * 100, 1), | |
| 146 | + "share_base_180j": round(share_base * 100, 1), | |
| 147 | + "delta_share_pp": round((share7 - share_base) * 100, 1), | |
| 148 | + "z_3j": round(z_recent, 2), | |
| 149 | + "sparkline": [int(v) for v in vals[-28:]], | |
| 150 | + } | |
| 151 | + # turbulence : pic d'attention global → variance de campagne gonflée (borné) | |
| 152 | + mult = float(np.clip(1.0 + 0.15 * max(0.0, z_max - 2.0), 1.0, 1.45)) | |
| 153 | + return {"available": True, "parties": out_p, "z_max": round(z_max, 2), | |
| 154 | + "drift_multiplier": round(mult, 3), | |
| 155 | + "as_of": end.isoformat(), "baseline_days": BASELINE_DAYS, | |
| 156 | + "note": ("Signal d'attention (jamais directionnel) : un pic global " | |
| 157 | + "gonfle l'incertitude de campagne, ne déplace aucune moyenne."), | |
| 158 | + "sparkline_dates": [d.isoformat() for d in all_days[-28:]]} | |
modified
backend/app/main.py
+2 −2
@@ -78,8 +78,8 @@ def health(): | ||
| 78 | 78 | return {"status": "ok", "app": settings.app_name, "version": settings.model_version} |
| 79 | 79 | |
| 80 | 80 | |
| 81 | −PAGES = ["index", "carte", "sondages", "signaux", "simulateur", "intelligence", | |
| 82 | − "methodologie", "backtest", "admin"] | |
| 81 | +PAGES = ["index", "aujourdhui", "carte", "sondages", "signaux", "simulateur", | |
| 82 | + "intelligence", "methodologie", "backtest", "admin"] | |
| 83 | 83 | for page in PAGES: |
| 84 | 84 | path = "/" if page == "index" else f"/{page}" |
| 85 | 85 | |
modified
backend/app/modeling/forecast.py
+5 −2
@@ -58,10 +58,13 @@ def nowcast(trend: TrendResult, as_of: date, | ||
| 58 | 58 | |
| 59 | 59 | |
| 60 | 60 | def forecast(trend: TrendResult, as_of: date, election_day: date, |
| 61 | − rng: np.random.Generator | None = None) -> VoteDistribution: | |
| 61 | + rng: np.random.Generator | None = None, | |
| 62 | + drift_multiplier: float = 1.0) -> VoteDistribution: | |
| 63 | + """drift_multiplier ∈ [1, 1,45] : turbulence détectée (pic d'attention web) | |
| 64 | + → l'incertitude d'évolution future est gonflée, la moyenne jamais touchée.""" | |
| 62 | 65 | rng = rng or np.random.default_rng(2) |
| 63 | 66 | days = max(0, (election_day - as_of).days) |
| 64 | − drift_var = trend.q * days * settings.campaign_drift_inflation | |
| 67 | + drift_var = trend.q * days * settings.campaign_drift_inflation * drift_multiplier | |
| 65 | 68 | # + erreur systémique de sondage (corrélée entre partis via la composition) : |
| 66 | 69 | # même la veille du vote, l'industrie entière peut se tromper de ~2-3 pp. |
| 67 | 70 | P_f = trend.P + (drift_var + settings.industry_error_sd ** 2) * np.eye(M) |
added
backend/app/modeling/synthetic_poll.py
+192 −0
@@ -0,0 +1,192 @@ | ||
| 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 | +"""Sondage synthétique LLM (« silicon sampling ») — signal EXPÉRIMENTAL, poids nul. | |
| 6 | + | |
| 7 | +Méthode (littérature 2024-2025 : Argyle et al.; arXiv:2411.01582; critique de | |
| 8 | +McKown-Dawson/Silver — « les sondages IA ne sont pas des sondages ») : | |
| 9 | + | |
| 10 | + 1. **Strates** : grille démographique du Québec (région × âge × genre × langue), | |
| 11 | + pondérée par des poids de population (recensement, approximés). | |
| 12 | + 2. **Interrogation** : pour chaque strate, le LLM (MacLustr, local) incarne un | |
| 13 | + électeur type de cette strate, ancré dans l'actualité RÉELLE de la campagne | |
| 14 | + (titres des 14 derniers jours issus de notre propre veille — retrieval- | |
| 15 | + augmented, jamais de chiffres de sondage dans le prompt), et retourne une | |
| 16 | + distribution de probabilités de vote. | |
| 17 | + 3. **Poststratification** : moyenne des distributions pondérée par la | |
| 18 | + population de chaque strate (MRP-lite). | |
| 19 | + | |
| 20 | +**Statut : expérimental, poids STRICTEMENT NUL dans le forecast.** Un LLM ne | |
| 21 | +collecte aucune donnée nouvelle : c'est un modèle, pas un échantillon. On publie | |
| 22 | +l'écart au forecast comme objet d'étude (le signal sera évalué après le 5 octobre), | |
| 23 | +avec provenance complète (modèle, date, prompts archivés). | |
| 24 | +""" | |
| 25 | +from __future__ import annotations | |
| 26 | + | |
| 27 | +import json | |
| 28 | +import logging | |
| 29 | +import re | |
| 30 | +from datetime import date, datetime, timedelta, timezone | |
| 31 | + | |
| 32 | +import httpx | |
| 33 | +import numpy as np | |
| 34 | +from sqlalchemy.orm import Session | |
| 35 | + | |
| 36 | +from ..config import DATA_DIR, settings | |
| 37 | +from .. import models as Mo | |
| 38 | + | |
| 39 | +log = logging.getLogger("synthetic-poll") | |
| 40 | + | |
| 41 | +OUT_DIR = DATA_DIR / "synthetic_polls" | |
| 42 | +INDICATOR = "synthetic_poll" | |
| 43 | +REFRESH_DAYS = 6 # au plus un sondage synthétique par ~semaine | |
| 44 | + | |
| 45 | +# Strates : (région groupée, poids pop., âge, poids âge, genre, langue, poids langue) | |
| 46 | +REGIONS = [("Montréal", 0.24), ("Montérégie et Rive-Sud", 0.18), | |
| 47 | + ("Québec et Chaudière-Appalaches", 0.14), ("Laval et Laurentides-Lanaudière", 0.16), | |
| 48 | + ("Estrie, Centre-du-Québec et Mauricie", 0.12), ("Outaouais et Abitibi", 0.06), | |
| 49 | + ("Saguenay–Lac-Saint-Jean et Côte-Nord", 0.05), ("Bas-Saint-Laurent et Gaspésie", 0.05)] | |
| 50 | +AGES = [("18-34 ans", 0.26), ("35-54 ans", 0.33), ("55 ans et plus", 0.41)] | |
| 51 | +GENDERS = [("femme", 0.51), ("homme", 0.49)] | |
| 52 | +LANGS = [("francophone", 0.82), ("anglophone ou allophone", 0.18)] | |
| 53 | + | |
| 54 | +PROMPT = """Tu es un·e électeur·rice québécois·e représentatif·ve de cette strate : | |
| 55 | +- Région : {region} | |
| 56 | +- Âge : {age} | |
| 57 | +- Genre : {gender} | |
| 58 | +- Langue principale : {lang} | |
| 59 | + | |
| 60 | +Contexte : élection générale du Québec le 5 octobre 2026. La campagne est en cours. | |
| 61 | +Actualité récente de la campagne (titres réels des derniers jours) : | |
| 62 | +{headlines} | |
| 63 | + | |
| 64 | +Partis : CAQ (Coalition avenir Québec, Christine Fréchette, sortant après 2 mandats), | |
| 65 | +PLQ (Parti libéral, Charles Milliard), PQ (Parti québécois, Paul St-Pierre Plamondon), | |
| 66 | +QS (Québec solidaire, Ruba Ghazal et Sol Zanetti), PCQ (Parti conservateur, Éric Duhaime). | |
| 67 | + | |
| 68 | +En te basant sur ce que voterait TYPIQUEMENT l'ensemble des électeurs de cette strate | |
| 69 | +(pas un individu), donne la répartition probable de leur vote. | |
| 70 | +Réponds UNIQUEMENT en JSON : {{"CAQ": x, "PLQ": x, "PQ": x, "QS": x, "PCQ": x, "AUT": x}} | |
| 71 | +(pourcentages, somme = 100).""" | |
| 72 | + | |
| 73 | + | |
| 74 | +def _llm(prompt: str) -> dict | None: | |
| 75 | + """Appel au LLM configuré (MacLustr par défaut) — None si indisponible.""" | |
| 76 | + if not settings.maclustr_api_key: | |
| 77 | + return None | |
| 78 | + try: | |
| 79 | + r = httpx.post(settings.maclustr_llm_url, | |
| 80 | + headers={"Authorization": f"Bearer {settings.maclustr_api_key}"}, | |
| 81 | + json={"model": settings.maclustr_llm_model, "temperature": 0.4, | |
| 82 | + "messages": [{"role": "user", "content": prompt}], | |
| 83 | + "max_tokens": 160}, | |
| 84 | + timeout=45) | |
| 85 | + r.raise_for_status() | |
| 86 | + content = r.json()["choices"][0]["message"]["content"] | |
| 87 | + m = re.search(r"\{[^{}]+\}", content, re.S) | |
| 88 | + return json.loads(m.group()) if m else None | |
| 89 | + except Exception as e: | |
| 90 | + log.warning("llm strate: %s", e) | |
| 91 | + return None | |
| 92 | + | |
| 93 | + | |
| 94 | +def recent_headlines(db: Session, n: int = 8) -> list[str]: | |
| 95 | + cutoff = datetime.now(timezone.utc) - timedelta(days=14) | |
| 96 | + docs = (db.query(Mo.SentimentDocument) | |
| 97 | + .filter(Mo.SentimentDocument.fetched_at >= cutoff) | |
| 98 | + .order_by(Mo.SentimentDocument.published.desc().nullslast()).limit(60).all()) | |
| 99 | + seen, titles = set(), [] | |
| 100 | + for d in docs: | |
| 101 | + k = d.cluster_key or d.url | |
| 102 | + if k in seen: | |
| 103 | + continue | |
| 104 | + seen.add(k) | |
| 105 | + titles.append(d.title.strip()) | |
| 106 | + if len(titles) >= n: | |
| 107 | + break | |
| 108 | + return titles | |
| 109 | + | |
| 110 | + | |
| 111 | +def strata() -> list[dict]: | |
| 112 | + out = [] | |
| 113 | + for reg, wr in REGIONS: | |
| 114 | + for age, wa in AGES: | |
| 115 | + for g, wg in GENDERS: | |
| 116 | + for lang, wl in LANGS: | |
| 117 | + out.append({"region": reg, "age": age, "gender": g, "lang": lang, | |
| 118 | + "weight": wr * wa * wg * wl}) | |
| 119 | + return out # 96 strates, somme des poids = 1 | |
| 120 | + | |
| 121 | + | |
| 122 | +def poststratify(cells: list[dict]) -> dict[str, float]: | |
| 123 | + """Moyenne des distributions de strates pondérée par la population.""" | |
| 124 | + tot_w = sum(c["weight"] for c in cells) | |
| 125 | + agg = {p: 0.0 for p in settings.parties} | |
| 126 | + for c in cells: | |
| 127 | + d = c["shares"] | |
| 128 | + s = sum(max(0.0, float(d.get(p, 0.0))) for p in settings.parties) or 100.0 | |
| 129 | + for p in settings.parties: | |
| 130 | + agg[p] += c["weight"] / tot_w * max(0.0, float(d.get(p, 0.0))) * 100.0 / s | |
| 131 | + return {p: round(v, 1) for p, v in agg.items()} | |
| 132 | + | |
| 133 | + | |
| 134 | +def latest(db: Session) -> dict | None: | |
| 135 | + row = (db.query(Mo.Indicator).filter_by(name=INDICATOR) | |
| 136 | + .order_by(Mo.Indicator.as_of.desc()).first()) | |
| 137 | + return ({"as_of": row.as_of.isoformat(), "shares": row.extra.get("shares"), | |
| 138 | + "model": row.extra.get("model"), "n_strata": row.extra.get("n_strata"), | |
| 139 | + "method": row.method, "headlines": row.extra.get("headlines"), | |
| 140 | + "note": row.extra.get("note")} if row else None) | |
| 141 | + | |
| 142 | + | |
| 143 | +def save(db: Session, shares: dict, model: str, n_strata: int, | |
| 144 | + headlines: list[str], method: str = "silicon-sampling", | |
| 145 | + cells: list[dict] | None = None) -> None: | |
| 146 | + today = date.today() | |
| 147 | + row = db.query(Mo.Indicator).filter_by(name=INDICATOR, as_of=today).first() | |
| 148 | + extra = {"shares": shares, "model": model, "n_strata": n_strata, | |
| 149 | + "headlines": headlines, | |
| 150 | + "note": ("EXPÉRIMENTAL — poids nul dans le forecast. Un LLM ne collecte " | |
| 151 | + "aucune donnée nouvelle; l'écart au forecast est publié comme " | |
| 152 | + "objet d'étude (évaluation après le 5 octobre).")} | |
| 153 | + if row is None: | |
| 154 | + row = Mo.Indicator(name=INDICATOR, as_of=today, value=0.0) | |
| 155 | + db.add(row) | |
| 156 | + row.value = float(shares.get("PQ", 0.0)) # valeur repère (part PQ) | |
| 157 | + row.source = f"Sondage synthétique ({model})" | |
| 158 | + row.method = method | |
| 159 | + row.extra = extra | |
| 160 | + db.commit() | |
| 161 | + try: # archive complète (provenance) | |
| 162 | + OUT_DIR.mkdir(parents=True, exist_ok=True) | |
| 163 | + (OUT_DIR / f"{today.isoformat()}.json").write_text(json.dumps( | |
| 164 | + {"date": today.isoformat(), "model": model, "method": method, | |
| 165 | + "shares": shares, "headlines": headlines, "cells": cells or []}, | |
| 166 | + ensure_ascii=False, indent=1)) | |
| 167 | + except Exception: | |
| 168 | + pass | |
| 169 | + | |
| 170 | + | |
| 171 | +def run(db: Session, force: bool = False) -> dict: | |
| 172 | + """Génère un sondage synthétique si le précédent date de > REFRESH_DAYS.""" | |
| 173 | + prev = (db.query(Mo.Indicator).filter_by(name=INDICATOR) | |
| 174 | + .order_by(Mo.Indicator.as_of.desc()).first()) | |
| 175 | + if prev and not force and (date.today() - prev.as_of).days < REFRESH_DAYS: | |
| 176 | + return {"skipped": f"dernier sondage synthétique: {prev.as_of.isoformat()}"} | |
| 177 | + heads = recent_headlines(db) | |
| 178 | + head_txt = "\n".join(f"- {t}" for t in heads) or "- (aucune manchette récente)" | |
| 179 | + cells, failed = [], 0 | |
| 180 | + for st in strata(): | |
| 181 | + d = _llm(PROMPT.format(headlines=head_txt, **st)) | |
| 182 | + if d is None: | |
| 183 | + failed += 1 | |
| 184 | + if failed >= 5 and not cells: # endpoint mort → abandon propre | |
| 185 | + return {"skipped": "LLM indisponible — sondage synthétique reporté"} | |
| 186 | + continue | |
| 187 | + cells.append({**st, "shares": d}) | |
| 188 | + if len(cells) < 40: | |
| 189 | + return {"skipped": f"trop de strates en échec ({len(cells)}/96 réussies)"} | |
| 190 | + shares = poststratify(cells) | |
| 191 | + save(db, shares, settings.maclustr_llm_model, len(cells), heads, cells=cells) | |
| 192 | + return {"shares": shares, "n_strata": len(cells), "échecs": failed} | |
modified
backend/app/pipeline.py
+29 −2
@@ -116,8 +116,19 @@ def run_forecast(db: Session, as_of: date | None = None, label: str | None = Non | ||
| 116 | 116 | if trend is None: |
| 117 | 117 | raise RuntimeError("Pas assez de sondages pour ajuster le modèle") |
| 118 | 118 | |
| 119 | + # --- attention Wikipédia : détecteur de turbulence (variance seulement) --- | |
| 120 | + attention: dict = {"available": False, "drift_multiplier": 1.0} | |
| 121 | + if not is_backtest: | |
| 122 | + try: | |
| 123 | + from .ingest.wiki_attention import signal as att_signal | |
| 124 | + attention = att_signal(db) | |
| 125 | + except Exception: | |
| 126 | + pass | |
| 127 | + drift_mult = float(attention.get("drift_multiplier", 1.0)) | |
| 128 | + | |
| 119 | 129 | now_d = nc(trend, as_of) |
| 120 | − fc_b = fc(trend, as_of, target.election_date) # sondages + partielles | |
| 130 | + fc_b = fc(trend, as_of, target.election_date, | |
| 131 | + drift_multiplier=drift_mult) # sondages + partielles | |
| 121 | 132 | |
| 122 | 133 | # --- couche 3 : prior de fondamentaux (poids décroissant vers le scrutin) --- |
| 123 | 134 | result_2022 = (db.query(Mo.Election) |
@@ -151,7 +162,7 @@ def run_forecast(db: Session, as_of: date | None = None, label: str | None = Non | ||
| 151 | 162 | s = SIM.run_simulation(inp, n_sims=8000, |
| 152 | 163 | rng=np.random.default_rng(seed)) |
| 153 | 164 | return {p: s.seats[p]["prob_most"] for p in settings.parties} |
| 154 | − fc_a = fc(trend_a, as_of, target.election_date) | |
| 165 | + fc_a = fc(trend_a, as_of, target.election_date, drift_multiplier=drift_mult) | |
| 155 | 166 | variants = [ |
| 156 | 167 | ("sondages", "Sondages seuls (modèle v1)", fc_a.x, fc_a.P), |
| 157 | 168 | ("partielles", "+ votes réels des partielles", fc_b.x, fc_b.P), |
@@ -207,6 +218,7 @@ def run_forecast(db: Session, as_of: date | None = None, label: str | None = Non | ||
| 207 | 218 | |
| 208 | 219 | beyond = { |
| 209 | 220 | "decomposition": decomposition, |
| 221 | + "web_attention": {k: v for k, v in attention.items() if k != "sparkline_dates"}, | |
| 210 | 222 | "fundamentals": fund_diag, |
| 211 | 223 | "byelections_used": [{"pollster": b["pollster"], |
| 212 | 224 | "field_end": b["field_end"].isoformat(), |
@@ -293,6 +305,21 @@ def run_pipeline(full_refresh: bool = True) -> dict: | ||
| 293 | 305 | except Exception as e: |
| 294 | 306 | report["veille"] = f"échec: {e}" |
| 295 | 307 | _log(db, "veille-firecrawl", "error", traceback.format_exc()) |
| 308 | + try: | |
| 309 | + from .ingest.wiki_attention import refresh as att_refresh | |
| 310 | + report["attention"] = att_refresh(db) | |
| 311 | + _log(db, "attention-wiki", "ok", str(report["attention"])[:500]) | |
| 312 | + except Exception as e: | |
| 313 | + report["attention"] = f"échec: {e}" | |
| 314 | + _log(db, "attention-wiki", "error", traceback.format_exc()) | |
| 315 | + try: | |
| 316 | + from .modeling.synthetic_poll import run as synth_run | |
| 317 | + report["sondage_synthétique"] = synth_run(db) | |
| 318 | + _log(db, "sondage-synthetique", "ok", | |
| 319 | + str(report["sondage_synthétique"])[:500]) | |
| 320 | + except Exception as e: | |
| 321 | + report["sondage_synthétique"] = f"échec: {e}" | |
| 322 | + _log(db, "sondage-synthetique", "error", traceback.format_exc()) | |
| 296 | 323 | try: |
| 297 | 324 | run = run_forecast(db) |
| 298 | 325 | report["forecast_run_id"] = run.id |
modified
backend/app/seed.py
+9 −0
@@ -149,6 +149,15 @@ def seed_all(db: Session | None = None) -> dict: | ||
| 149 | 149 | "88/12 avec Polymarket sur P(plus de sièges), veille web continue " |
| 150 | 150 | "Firecrawl (radar sondages + satisfaction + presse élargie), " |
| 151 | 151 | "décomposition du forecast par couche, backtest comparatif v2."))) |
| 152 | + get_or_create(db, Mo.ModelVersion, version="2.1.0", | |
| 153 | + defaults=dict(changelog=( | |
| 154 | + "Signaux web avancés : attention Wikipédia (pageviews partis+chefs, " | |
| 155 | + "littérature Smith & Gustafson 2017) → détecteur de turbulence qui " | |
| 156 | + "gonfle la variance de campagne (×≤1,45, jamais la moyenne); sondage " | |
| 157 | + "synthétique LLM (« silicon sampling », 96 strates poststratifiées, " | |
| 158 | + "ancré dans l'actualité réelle) — EXPÉRIMENTAL, poids nul; page " | |
| 159 | + "quotidienne « Le point du jour » (/aujourdhui, API /api/today) : " | |
| 160 | + "forecast daté, Δ vs veille, journal 24 h."))) | |
| 152 | 161 | # Sources |
| 153 | 162 | for name, url, kind in [ |
| 154 | 163 | ("Wikipédia — sondages 2026", "https://en.wikipedia.org/wiki/2026_Quebec_general_election", "polls"), |
added
backend/data/synthetic_polls/2026-08-30.json
+1465 −0
@@ -0,0 +1,1465 @@ | ||
| 1 | +{ | |
| 2 | + "date": "2026-08-30", | |
| 3 | + "model": "claude-sonnet-4-6", | |
| 4 | + "method": "silicon-sampling-bootstrap", | |
| 5 | + "shares": { | |
| 6 | + "CAQ": 20.1, | |
| 7 | + "PCQ": 9.9, | |
| 8 | + "PLQ": 17.5, | |
| 9 | + "PQ": 25.5, | |
| 10 | + "QS": 19.0, | |
| 11 | + "AUT": 8.0 | |
| 12 | + }, | |
| 13 | + "headlines": [ | |
| 14 | + "Élections fédérales partielles | Les électeurs se rendront aux urnes lundi dans trois circonscriptions", | |
| 15 | + "Relations Canada–États-Unis | Les Américains devront s’excuser, selon un ancien ambassadeur", | |
| 16 | + "Sondage Synopsis-La Presse | Les appuis pour Carney augmentent au Québec", | |
| 17 | + "En plus du troisième lien | Le maire de Lévis ressuscite l’idée d’un téléphérique vers Québec", | |
| 18 | + "Comparez les promesses et programmes des partis aux élections québécoises de 2026", | |
| 19 | + "Charles Milliard, à la rescousse des services publics", | |
| 20 | + "Le cofondateur de l’ADQ, Jean Allaire, s’est éteint", | |
| 21 | + "Cofondateur de l’ADQ | Jean Allaire rend l’âme à l’âge de 96 ans" | |
| 22 | + ], | |
| 23 | + "cells": [ | |
| 24 | + { | |
| 25 | + "region": "Montréal", | |
| 26 | + "age": "18-34 ans", | |
| 27 | + "gender": "femme", | |
| 28 | + "lang": "francophone", | |
| 29 | + "weight": 0.026095679999999996, | |
| 30 | + "shares": { | |
| 31 | + "CAQ": 10, | |
| 32 | + "PLQ": 12, | |
| 33 | + "PQ": 18, | |
| 34 | + "QS": 48, | |
| 35 | + "PCQ": 3, | |
| 36 | + "AUT": 9 | |
| 37 | + } | |
| 38 | + }, | |
| 39 | + { | |
| 40 | + "region": "Montréal", | |
| 41 | + "age": "18-34 ans", | |
| 42 | + "gender": "femme", | |
| 43 | + "lang": "anglophone ou allophone", | |
| 44 | + "weight": 0.005728319999999999, | |
| 45 | + "shares": { | |
| 46 | + "CAQ": 7, | |
| 47 | + "PLQ": 38, | |
| 48 | + "PQ": 5, | |
| 49 | + "QS": 28, | |
| 50 | + "PCQ": 2, | |
| 51 | + "AUT": 20 | |
| 52 | + } | |
| 53 | + }, | |
| 54 | + { | |
| 55 | + "region": "Montréal", | |
| 56 | + "age": "18-34 ans", | |
| 57 | + "gender": "homme", | |
| 58 | + "lang": "francophone", | |
| 59 | + "weight": 0.02507232, | |
| 60 | + "shares": { | |
| 61 | + "CAQ": 13, | |
| 62 | + "PLQ": 10, | |
| 63 | + "PQ": 22, | |
| 64 | + "QS": 35, | |
| 65 | + "PCQ": 8, | |
| 66 | + "AUT": 12 | |
| 67 | + } | |
| 68 | + }, | |
| 69 | + { | |
| 70 | + "region": "Montréal", | |
| 71 | + "age": "18-34 ans", | |
| 72 | + "gender": "homme", | |
| 73 | + "lang": "anglophone ou allophone", | |
| 74 | + "weight": 0.0055036799999999995, | |
| 75 | + "shares": { | |
| 76 | + "CAQ": 8, | |
| 77 | + "PLQ": 34, | |
| 78 | + "PQ": 6, | |
| 79 | + "QS": 22, | |
| 80 | + "PCQ": 5, | |
| 81 | + "AUT": 25 | |
| 82 | + } | |
| 83 | + }, | |
| 84 | + { | |
| 85 | + "region": "Montréal", | |
| 86 | + "age": "35-54 ans", | |
| 87 | + "gender": "femme", | |
| 88 | + "lang": "francophone", | |
| 89 | + "weight": 0.03312144, | |
| 90 | + "shares": { | |
| 91 | + "CAQ": 18, | |
| 92 | + "PLQ": 15, | |
| 93 | + "PQ": 25, | |
| 94 | + "QS": 30, | |
| 95 | + "PCQ": 5, | |
| 96 | + "AUT": 7 | |
| 97 | + } | |
| 98 | + }, | |
| 99 | + { | |
| 100 | + "region": "Montréal", | |
| 101 | + "age": "35-54 ans", | |
| 102 | + "gender": "femme", | |
| 103 | + "lang": "anglophone ou allophone", | |
| 104 | + "weight": 0.0072705600000000006, | |
| 105 | + "shares": { | |
| 106 | + "CAQ": 8, | |
| 107 | + "PLQ": 44, | |
| 108 | + "PQ": 5, | |
| 109 | + "QS": 20, | |
| 110 | + "PCQ": 3, | |
| 111 | + "AUT": 20 | |
| 112 | + } | |
| 113 | + }, | |
| 114 | + { | |
| 115 | + "region": "Montréal", | |
| 116 | + "age": "35-54 ans", | |
| 117 | + "gender": "homme", | |
| 118 | + "lang": "francophone", | |
| 119 | + "weight": 0.03182256, | |
| 120 | + "shares": { | |
| 121 | + "CAQ": 20, | |
| 122 | + "PLQ": 13, | |
| 123 | + "PQ": 28, | |
| 124 | + "QS": 22, | |
| 125 | + "PCQ": 10, | |
| 126 | + "AUT": 7 | |
| 127 | + } | |
| 128 | + }, | |
| 129 | + { | |
| 130 | + "region": "Montréal", | |
| 131 | + "age": "35-54 ans", | |
| 132 | + "gender": "homme", | |
| 133 | + "lang": "anglophone ou allophone", | |
| 134 | + "weight": 0.00698544, | |
| 135 | + "shares": { | |
| 136 | + "CAQ": 9, | |
| 137 | + "PLQ": 42, | |
| 138 | + "PQ": 6, | |
| 139 | + "QS": 16, | |
| 140 | + "PCQ": 6, | |
| 141 | + "AUT": 21 | |
| 142 | + } | |
| 143 | + }, | |
| 144 | + { | |
| 145 | + "region": "Montréal", | |
| 146 | + "age": "55 ans et plus", | |
| 147 | + "gender": "femme", | |
| 148 | + "lang": "francophone", | |
| 149 | + "weight": 0.041150879999999994, | |
| 150 | + "shares": { | |
| 151 | + "CAQ": 24, | |
| 152 | + "PLQ": 18, | |
| 153 | + "PQ": 30, | |
| 154 | + "QS": 16, | |
| 155 | + "PCQ": 6, | |
| 156 | + "AUT": 6 | |
| 157 | + } | |
| 158 | + }, | |
| 159 | + { | |
| 160 | + "region": "Montréal", | |
| 161 | + "age": "55 ans et plus", | |
| 162 | + "gender": "femme", | |
| 163 | + "lang": "anglophone ou allophone", | |
| 164 | + "weight": 0.009033119999999999, | |
| 165 | + "shares": { | |
| 166 | + "CAQ": 7, | |
| 167 | + "PLQ": 55, | |
| 168 | + "PQ": 5, | |
| 169 | + "QS": 10, | |
| 170 | + "PCQ": 4, | |
| 171 | + "AUT": 19 | |
| 172 | + } | |
| 173 | + }, | |
| 174 | + { | |
| 175 | + "region": "Montréal", | |
| 176 | + "age": "55 ans et plus", | |
| 177 | + "gender": "homme", | |
| 178 | + "lang": "francophone", | |
| 179 | + "weight": 0.039537119999999995, | |
| 180 | + "shares": { | |
| 181 | + "CAQ": 26, | |
| 182 | + "PLQ": 16, | |
| 183 | + "PQ": 32, | |
| 184 | + "QS": 12, | |
| 185 | + "PCQ": 9, | |
| 186 | + "AUT": 5 | |
| 187 | + } | |
| 188 | + }, | |
| 189 | + { | |
| 190 | + "region": "Montréal", | |
| 191 | + "age": "55 ans et plus", | |
| 192 | + "gender": "homme", | |
| 193 | + "lang": "anglophone ou allophone", | |
| 194 | + "weight": 0.008678879999999998, | |
| 195 | + "shares": { | |
| 196 | + "CAQ": 8, | |
| 197 | + "PLQ": 53, | |
| 198 | + "PQ": 6, | |
| 199 | + "QS": 8, | |
| 200 | + "PCQ": 6, | |
| 201 | + "AUT": 19 | |
| 202 | + } | |
| 203 | + }, | |
| 204 | + { | |
| 205 | + "region": "Montérégie et Rive-Sud", | |
| 206 | + "age": "18-34 ans", | |
| 207 | + "gender": "femme", | |
| 208 | + "lang": "francophone", | |
| 209 | + "weight": 0.01957176, | |
| 210 | + "shares": { | |
| 211 | + "CAQ": 14, | |
| 212 | + "PLQ": 10, | |
| 213 | + "PQ": 22, | |
| 214 | + "QS": 38, | |
| 215 | + "PCQ": 6, | |
| 216 | + "AUT": 10 | |
| 217 | + } | |
| 218 | + }, | |
| 219 | + { | |
| 220 | + "region": "Montérégie et Rive-Sud", | |
| 221 | + "age": "18-34 ans", | |
| 222 | + "gender": "femme", | |
| 223 | + "lang": "anglophone ou allophone", | |
| 224 | + "weight": 0.00429624, | |
| 225 | + "shares": { | |
| 226 | + "CAQ": 8, | |
| 227 | + "PLQ": 38, | |
| 228 | + "PQ": 5, | |
| 229 | + "QS": 28, | |
| 230 | + "PCQ": 4, | |
| 231 | + "AUT": 17 | |
| 232 | + } | |
| 233 | + }, | |
| 234 | + { | |
| 235 | + "region": "Montérégie et Rive-Sud", | |
| 236 | + "age": "18-34 ans", | |
| 237 | + "gender": "homme", | |
| 238 | + "lang": "francophone", | |
| 239 | + "weight": 0.01880424, | |
| 240 | + "shares": { | |
| 241 | + "CAQ": 16, | |
| 242 | + "PLQ": 9, | |
| 243 | + "PQ": 25, | |
| 244 | + "QS": 28, | |
| 245 | + "PCQ": 12, | |
| 246 | + "AUT": 10 | |
| 247 | + } | |
| 248 | + }, | |
| 249 | + { | |
| 250 | + "region": "Montérégie et Rive-Sud", | |
| 251 | + "age": "18-34 ans", | |
| 252 | + "gender": "homme", | |
| 253 | + "lang": "anglophone ou allophone", | |
| 254 | + "weight": 0.00412776, | |
| 255 | + "shares": { | |
| 256 | + "CAQ": 9, | |
| 257 | + "PLQ": 34, | |
| 258 | + "PQ": 6, | |
| 259 | + "QS": 22, | |
| 260 | + "PCQ": 8, | |
| 261 | + "AUT": 21 | |
| 262 | + } | |
| 263 | + }, | |
| 264 | + { | |
| 265 | + "region": "Montérégie et Rive-Sud", | |
| 266 | + "age": "35-54 ans", | |
| 267 | + "gender": "femme", | |
| 268 | + "lang": "francophone", | |
| 269 | + "weight": 0.024841079999999998, | |
| 270 | + "shares": { | |
| 271 | + "CAQ": 22, | |
| 272 | + "PLQ": 13, | |
| 273 | + "PQ": 28, | |
| 274 | + "QS": 22, | |
| 275 | + "PCQ": 9, | |
| 276 | + "AUT": 6 | |
| 277 | + } | |
| 278 | + }, | |
| 279 | + { | |
| 280 | + "region": "Montérégie et Rive-Sud", | |
| 281 | + "age": "35-54 ans", | |
| 282 | + "gender": "femme", | |
| 283 | + "lang": "anglophone ou allophone", | |
| 284 | + "weight": 0.00545292, | |
| 285 | + "shares": { | |
| 286 | + "CAQ": 10, | |
| 287 | + "PLQ": 44, | |
| 288 | + "PQ": 5, | |
| 289 | + "QS": 18, | |
| 290 | + "PCQ": 5, | |
| 291 | + "AUT": 18 | |
| 292 | + } | |
| 293 | + }, | |
| 294 | + { | |
| 295 | + "region": "Montérégie et Rive-Sud", | |
| 296 | + "age": "35-54 ans", | |
| 297 | + "gender": "homme", | |
| 298 | + "lang": "francophone", | |
| 299 | + "weight": 0.02386692, | |
| 300 | + "shares": { | |
| 301 | + "CAQ": 24, | |
| 302 | + "PLQ": 11, | |
| 303 | + "PQ": 30, | |
| 304 | + "QS": 15, | |
| 305 | + "PCQ": 14, | |
| 306 | + "AUT": 6 | |
| 307 | + } | |
| 308 | + }, | |
| 309 | + { | |
| 310 | + "region": "Montérégie et Rive-Sud", | |
| 311 | + "age": "35-54 ans", | |
| 312 | + "gender": "homme", | |
| 313 | + "lang": "anglophone ou allophone", | |
| 314 | + "weight": 0.00523908, | |
| 315 | + "shares": { | |
| 316 | + "CAQ": 11, | |
| 317 | + "PLQ": 40, | |
| 318 | + "PQ": 6, | |
| 319 | + "QS": 12, | |
| 320 | + "PCQ": 10, | |
| 321 | + "AUT": 21 | |
| 322 | + } | |
| 323 | + }, | |
| 324 | + { | |
| 325 | + "region": "Montérégie et Rive-Sud", | |
| 326 | + "age": "55 ans et plus", | |
| 327 | + "gender": "femme", | |
| 328 | + "lang": "francophone", | |
| 329 | + "weight": 0.030863159999999997, | |
| 330 | + "shares": { | |
| 331 | + "CAQ": 30, | |
| 332 | + "PLQ": 16, | |
| 333 | + "PQ": 28, | |
| 334 | + "QS": 12, | |
| 335 | + "PCQ": 9, | |
| 336 | + "AUT": 5 | |
| 337 | + } | |
| 338 | + }, | |
| 339 | + { | |
| 340 | + "region": "Montérégie et Rive-Sud", | |
| 341 | + "age": "55 ans et plus", | |
| 342 | + "gender": "femme", | |
| 343 | + "lang": "anglophone ou allophone", | |
| 344 | + "weight": 0.006774839999999999, | |
| 345 | + "shares": { | |
| 346 | + "CAQ": 10, | |
| 347 | + "PLQ": 52, | |
| 348 | + "PQ": 5, | |
| 349 | + "QS": 8, | |
| 350 | + "PCQ": 6, | |
| 351 | + "AUT": 19 | |
| 352 | + } | |
| 353 | + }, | |
| 354 | + { | |
| 355 | + "region": "Montérégie et Rive-Sud", | |
| 356 | + "age": "55 ans et plus", | |
| 357 | + "gender": "homme", | |
| 358 | + "lang": "francophone", | |
| 359 | + "weight": 0.029652839999999993, | |
| 360 | + "shares": { | |
| 361 | + "CAQ": 28, | |
| 362 | + "PLQ": 14, | |
| 363 | + "PQ": 30, | |
| 364 | + "QS": 8, | |
| 365 | + "PCQ": 14, | |
| 366 | + "AUT": 6 | |
| 367 | + } | |
| 368 | + }, | |
| 369 | + { | |
| 370 | + "region": "Montérégie et Rive-Sud", | |
| 371 | + "age": "55 ans et plus", | |
| 372 | + "gender": "homme", | |
| 373 | + "lang": "anglophone ou allophone", | |
| 374 | + "weight": 0.006509159999999998, | |
| 375 | + "shares": { | |
| 376 | + "CAQ": 11, | |
| 377 | + "PLQ": 48, | |
| 378 | + "PQ": 6, | |
| 379 | + "QS": 5, | |
| 380 | + "PCQ": 11, | |
| 381 | + "AUT": 19 | |
| 382 | + } | |
| 383 | + }, | |
| 384 | + { | |
| 385 | + "region": "Québec et Chaudière-Appalaches", | |
| 386 | + "age": "18-34 ans", | |
| 387 | + "gender": "femme", | |
| 388 | + "lang": "francophone", | |
| 389 | + "weight": 0.01522248, | |
| 390 | + "shares": { | |
| 391 | + "CAQ": 14, | |
| 392 | + "PLQ": 8, | |
| 393 | + "PQ": 22, | |
| 394 | + "QS": 38, | |
| 395 | + "PCQ": 10, | |
| 396 | + "AUT": 8 | |
| 397 | + } | |
| 398 | + }, | |
| 399 | + { | |
| 400 | + "region": "Québec et Chaudière-Appalaches", | |
| 401 | + "age": "18-34 ans", | |
| 402 | + "gender": "femme", | |
| 403 | + "lang": "anglophone ou allophone", | |
| 404 | + "weight": 0.00334152, | |
| 405 | + "shares": { | |
| 406 | + "CAQ": 10, | |
| 407 | + "PLQ": 32, | |
| 408 | + "PQ": 8, | |
| 409 | + "QS": 28, | |
| 410 | + "PCQ": 6, | |
| 411 | + "AUT": 16 | |
| 412 | + } | |
| 413 | + }, | |
| 414 | + { | |
| 415 | + "region": "Québec et Chaudière-Appalaches", | |
| 416 | + "age": "18-34 ans", | |
| 417 | + "gender": "homme", | |
| 418 | + "lang": "francophone", | |
| 419 | + "weight": 0.01462552, | |
| 420 | + "shares": { | |
| 421 | + "CAQ": 16, | |
| 422 | + "PLQ": 7, | |
| 423 | + "PQ": 24, | |
| 424 | + "QS": 26, | |
| 425 | + "PCQ": 18, | |
| 426 | + "AUT": 9 | |
| 427 | + } | |
| 428 | + }, | |
| 429 | + { | |
| 430 | + "region": "Québec et Chaudière-Appalaches", | |
| 431 | + "age": "18-34 ans", | |
| 432 | + "gender": "homme", | |
| 433 | + "lang": "anglophone ou allophone", | |
| 434 | + "weight": 0.00321048, | |
| 435 | + "shares": { | |
| 436 | + "CAQ": 12, | |
| 437 | + "PLQ": 28, | |
| 438 | + "PQ": 10, | |
| 439 | + "QS": 18, | |
| 440 | + "PCQ": 16, | |
| 441 | + "AUT": 16 | |
| 442 | + } | |
| 443 | + }, | |
| 444 | + { | |
| 445 | + "region": "Québec et Chaudière-Appalaches", | |
| 446 | + "age": "35-54 ans", | |
| 447 | + "gender": "femme", | |
| 448 | + "lang": "francophone", | |
| 449 | + "weight": 0.019320840000000002, | |
| 450 | + "shares": { | |
| 451 | + "CAQ": 22, | |
| 452 | + "PLQ": 10, | |
| 453 | + "PQ": 28, | |
| 454 | + "QS": 22, | |
| 455 | + "PCQ": 12, | |
| 456 | + "AUT": 6 | |
| 457 | + } | |
| 458 | + }, | |
| 459 | + { | |
| 460 | + "region": "Québec et Chaudière-Appalaches", | |
| 461 | + "age": "35-54 ans", | |
| 462 | + "gender": "femme", | |
| 463 | + "lang": "anglophone ou allophone", | |
| 464 | + "weight": 0.004241160000000001, | |
| 465 | + "shares": { | |
| 466 | + "CAQ": 14, | |
| 467 | + "PLQ": 36, | |
| 468 | + "PQ": 10, | |
| 469 | + "QS": 18, | |
| 470 | + "PCQ": 8, | |
| 471 | + "AUT": 14 | |
| 472 | + } | |
| 473 | + }, | |
| 474 | + { | |
| 475 | + "region": "Québec et Chaudière-Appalaches", | |
| 476 | + "age": "35-54 ans", | |
| 477 | + "gender": "homme", | |
| 478 | + "lang": "francophone", | |
| 479 | + "weight": 0.018563160000000002, | |
| 480 | + "shares": { | |
| 481 | + "CAQ": 24, | |
| 482 | + "PLQ": 9, | |
| 483 | + "PQ": 28, | |
| 484 | + "QS": 14, | |
| 485 | + "PCQ": 20, | |
| 486 | + "AUT": 5 | |
| 487 | + } | |
| 488 | + }, | |
| 489 | + { | |
| 490 | + "region": "Québec et Chaudière-Appalaches", | |
| 491 | + "age": "35-54 ans", | |
| 492 | + "gender": "homme", | |
| 493 | + "lang": "anglophone ou allophone", | |
| 494 | + "weight": 0.004074840000000001, | |
| 495 | + "shares": { | |
| 496 | + "CAQ": 16, | |
| 497 | + "PLQ": 34, | |
| 498 | + "PQ": 10, | |
| 499 | + "QS": 12, | |
| 500 | + "PCQ": 18, | |
| 501 | + "AUT": 10 | |
| 502 | + } | |
| 503 | + }, | |
| 504 | + { | |
| 505 | + "region": "Québec et Chaudière-Appalaches", | |
| 506 | + "age": "55 ans et plus", | |
| 507 | + "gender": "femme", | |
| 508 | + "lang": "francophone", | |
| 509 | + "weight": 0.02400468, | |
| 510 | + "shares": { | |
| 511 | + "CAQ": 28, | |
| 512 | + "PLQ": 12, | |
| 513 | + "PQ": 30, | |
| 514 | + "QS": 12, | |
| 515 | + "PCQ": 14, | |
| 516 | + "AUT": 4 | |
| 517 | + } | |
| 518 | + }, | |
| 519 | + { | |
| 520 | + "region": "Québec et Chaudière-Appalaches", | |
| 521 | + "age": "55 ans et plus", | |
| 522 | + "gender": "femme", | |
| 523 | + "lang": "anglophone ou allophone", | |
| 524 | + "weight": 0.00526932, | |
| 525 | + "shares": { | |
| 526 | + "CAQ": 16, | |
| 527 | + "PLQ": 44, | |
| 528 | + "PQ": 10, | |
| 529 | + "QS": 8, | |
| 530 | + "PCQ": 12, | |
| 531 | + "AUT": 10 | |
| 532 | + } | |
| 533 | + }, | |
| 534 | + { | |
| 535 | + "region": "Québec et Chaudière-Appalaches", | |
| 536 | + "age": "55 ans et plus", | |
| 537 | + "gender": "homme", | |
| 538 | + "lang": "francophone", | |
| 539 | + "weight": 0.02306332, | |
| 540 | + "shares": { | |
| 541 | + "CAQ": 28, | |
| 542 | + "PLQ": 10, | |
| 543 | + "PQ": 28, | |
| 544 | + "QS": 8, | |
| 545 | + "PCQ": 22, | |
| 546 | + "AUT": 4 | |
| 547 | + } | |
| 548 | + }, | |
| 549 | + { | |
| 550 | + "region": "Québec et Chaudière-Appalaches", | |
| 551 | + "age": "55 ans et plus", | |
| 552 | + "gender": "homme", | |
| 553 | + "lang": "anglophone ou allophone", | |
| 554 | + "weight": 0.005062679999999999, | |
| 555 | + "shares": { | |
| 556 | + "CAQ": 18, | |
| 557 | + "PLQ": 42, | |
| 558 | + "PQ": 10, | |
| 559 | + "QS": 6, | |
| 560 | + "PCQ": 16, | |
| 561 | + "AUT": 8 | |
| 562 | + } | |
| 563 | + }, | |
| 564 | + { | |
| 565 | + "region": "Laval et Laurentides-Lanaudière", | |
| 566 | + "age": "18-34 ans", | |
| 567 | + "gender": "femme", | |
| 568 | + "lang": "francophone", | |
| 569 | + "weight": 0.017397120000000002, | |
| 570 | + "shares": { | |
| 571 | + "CAQ": 14, | |
| 572 | + "PLQ": 10, | |
| 573 | + "PQ": 22, | |
| 574 | + "QS": 38, | |
| 575 | + "PCQ": 6, | |
| 576 | + "AUT": 10 | |
| 577 | + } | |
| 578 | + }, | |
| 579 | + { | |
| 580 | + "region": "Laval et Laurentides-Lanaudière", | |
| 581 | + "age": "18-34 ans", | |
| 582 | + "gender": "femme", | |
| 583 | + "lang": "anglophone ou allophone", | |
| 584 | + "weight": 0.00381888, | |
| 585 | + "shares": { | |
| 586 | + "CAQ": 8, | |
| 587 | + "PLQ": 38, | |
| 588 | + "PQ": 6, | |
| 589 | + "QS": 28, | |
| 590 | + "PCQ": 4, | |
| 591 | + "AUT": 16 | |
| 592 | + } | |
| 593 | + }, | |
| 594 | + { | |
| 595 | + "region": "Laval et Laurentides-Lanaudière", | |
| 596 | + "age": "18-34 ans", | |
| 597 | + "gender": "homme", | |
| 598 | + "lang": "francophone", | |
| 599 | + "weight": 0.01671488, | |
| 600 | + "shares": { | |
| 601 | + "CAQ": 16, | |
| 602 | + "PLQ": 9, | |
| 603 | + "PQ": 26, | |
| 604 | + "QS": 28, | |
| 605 | + "PCQ": 12, | |
| 606 | + "AUT": 9 | |
| 607 | + } | |
| 608 | + }, | |
| 609 | + { | |
| 610 | + "region": "Laval et Laurentides-Lanaudière", | |
| 611 | + "age": "18-34 ans", | |
| 612 | + "gender": "homme", | |
| 613 | + "lang": "anglophone ou allophone", | |
| 614 | + "weight": 0.0036691200000000005, | |
| 615 | + "shares": { | |
| 616 | + "CAQ": 10, | |
| 617 | + "PLQ": 34, | |
| 618 | + "PQ": 7, | |
| 619 | + "QS": 22, | |
| 620 | + "PCQ": 8, | |
| 621 | + "AUT": 19 | |
| 622 | + } | |
| 623 | + }, | |
| 624 | + { | |
| 625 | + "region": "Laval et Laurentides-Lanaudière", | |
| 626 | + "age": "35-54 ans", | |
| 627 | + "gender": "femme", | |
| 628 | + "lang": "francophone", | |
| 629 | + "weight": 0.022080960000000004, | |
| 630 | + "shares": { | |
| 631 | + "CAQ": 22, | |
| 632 | + "PLQ": 12, | |
| 633 | + "PQ": 30, | |
| 634 | + "QS": 22, | |
| 635 | + "PCQ": 8, | |
| 636 | + "AUT": 6 | |
| 637 | + } | |
| 638 | + }, | |
| 639 | + { | |
| 640 | + "region": "Laval et Laurentides-Lanaudière", | |
| 641 | + "age": "35-54 ans", | |
| 642 | + "gender": "femme", | |
| 643 | + "lang": "anglophone ou allophone", | |
| 644 | + "weight": 0.00484704, | |
| 645 | + "shares": { | |
| 646 | + "CAQ": 10, | |
| 647 | + "PLQ": 44, | |
| 648 | + "PQ": 7, | |
| 649 | + "QS": 18, | |
| 650 | + "PCQ": 5, | |
| 651 | + "AUT": 16 | |
| 652 | + } | |
| 653 | + }, | |
| 654 | + { | |
| 655 | + "region": "Laval et Laurentides-Lanaudière", | |
| 656 | + "age": "35-54 ans", | |
| 657 | + "gender": "homme", | |
| 658 | + "lang": "francophone", | |
| 659 | + "weight": 0.02121504, | |
| 660 | + "shares": { | |
| 661 | + "CAQ": 24, | |
| 662 | + "PLQ": 10, | |
| 663 | + "PQ": 32, | |
| 664 | + "QS": 14, | |
| 665 | + "PCQ": 14, | |
| 666 | + "AUT": 6 | |
| 667 | + } | |
| 668 | + }, | |
| 669 | + { | |
| 670 | + "region": "Laval et Laurentides-Lanaudière", | |
| 671 | + "age": "35-54 ans", | |
| 672 | + "gender": "homme", | |
| 673 | + "lang": "anglophone ou allophone", | |
| 674 | + "weight": 0.00465696, | |
| 675 | + "shares": { | |
| 676 | + "CAQ": 12, | |
| 677 | + "PLQ": 40, | |
| 678 | + "PQ": 8, | |
| 679 | + "QS": 12, | |
| 680 | + "PCQ": 10, | |
| 681 | + "AUT": 18 | |
| 682 | + } | |
| 683 | + }, | |
| 684 | + { | |
| 685 | + "region": "Laval et Laurentides-Lanaudière", | |
| 686 | + "age": "55 ans et plus", | |
| 687 | + "gender": "femme", | |
| 688 | + "lang": "francophone", | |
| 689 | + "weight": 0.027433919999999997, | |
| 690 | + "shares": { | |
| 691 | + "CAQ": 28, | |
| 692 | + "PLQ": 14, | |
| 693 | + "PQ": 34, | |
| 694 | + "QS": 10, | |
| 695 | + "PCQ": 9, | |
| 696 | + "AUT": 5 | |
| 697 | + } | |
| 698 | + }, | |
| 699 | + { | |
| 700 | + "region": "Laval et Laurentides-Lanaudière", | |
| 701 | + "age": "55 ans et plus", | |
| 702 | + "gender": "femme", | |
| 703 | + "lang": "anglophone ou allophone", | |
| 704 | + "weight": 0.00602208, | |
| 705 | + "shares": { | |
| 706 | + "CAQ": 10, | |
| 707 | + "PLQ": 52, | |
| 708 | + "PQ": 8, | |
| 709 | + "QS": 8, | |
| 710 | + "PCQ": 6, | |
| 711 | + "AUT": 16 | |
| 712 | + } | |
| 713 | + }, | |
| 714 | + { | |
| 715 | + "region": "Laval et Laurentides-Lanaudière", | |
| 716 | + "age": "55 ans et plus", | |
| 717 | + "gender": "homme", | |
| 718 | + "lang": "francophone", | |
| 719 | + "weight": 0.026358079999999992, | |
| 720 | + "shares": { | |
| 721 | + "CAQ": 26, | |
| 722 | + "PLQ": 12, | |
| 723 | + "PQ": 36, | |
| 724 | + "QS": 7, | |
| 725 | + "PCQ": 14, | |
| 726 | + "AUT": 5 | |
| 727 | + } | |
| 728 | + }, | |
| 729 | + { | |
| 730 | + "region": "Laval et Laurentides-Lanaudière", | |
| 731 | + "age": "55 ans et plus", | |
| 732 | + "gender": "homme", | |
| 733 | + "lang": "anglophone ou allophone", | |
| 734 | + "weight": 0.005785919999999998, | |
| 735 | + "shares": { | |
| 736 | + "CAQ": 12, | |
| 737 | + "PLQ": 48, | |
| 738 | + "PQ": 9, | |
| 739 | + "QS": 5, | |
| 740 | + "PCQ": 10, | |
| 741 | + "AUT": 16 | |
| 742 | + } | |
| 743 | + }, | |
| 744 | + { | |
| 745 | + "region": "Estrie, Centre-du-Québec et Mauricie", | |
| 746 | + "age": "18-34 ans", | |
| 747 | + "gender": "femme", | |
| 748 | + "lang": "francophone", | |
| 749 | + "weight": 0.013047839999999998, | |
| 750 | + "shares": { | |
| 751 | + "CAQ": 14, | |
| 752 | + "PLQ": 8, | |
| 753 | + "PQ": 22, | |
| 754 | + "QS": 42, | |
| 755 | + "PCQ": 6, | |
| 756 | + "AUT": 8 | |
| 757 | + } | |
| 758 | + }, | |
| 759 | + { | |
| 760 | + "region": "Estrie, Centre-du-Québec et Mauricie", | |
| 761 | + "age": "18-34 ans", | |
| 762 | + "gender": "femme", | |
| 763 | + "lang": "anglophone ou allophone", | |
| 764 | + "weight": 0.0028641599999999997, | |
| 765 | + "shares": { | |
| 766 | + "CAQ": 8, | |
| 767 | + "PLQ": 32, | |
| 768 | + "PQ": 10, | |
| 769 | + "QS": 30, | |
| 770 | + "PCQ": 5, | |
| 771 | + "AUT": 15 | |
| 772 | + } | |
| 773 | + }, | |
| 774 | + { | |
| 775 | + "region": "Estrie, Centre-du-Québec et Mauricie", | |
| 776 | + "age": "18-34 ans", | |
| 777 | + "gender": "homme", | |
| 778 | + "lang": "francophone", | |
| 779 | + "weight": 0.01253616, | |
| 780 | + "shares": { | |
| 781 | + "CAQ": 16, | |
| 782 | + "PLQ": 7, | |
| 783 | + "PQ": 26, | |
| 784 | + "QS": 28, | |
| 785 | + "PCQ": 14, | |
| 786 | + "AUT": 9 | |
| 787 | + } | |
| 788 | + }, | |
| 789 | + { | |
| 790 | + "region": "Estrie, Centre-du-Québec et Mauricie", | |
| 791 | + "age": "18-34 ans", | |
| 792 | + "gender": "homme", | |
| 793 | + "lang": "anglophone ou allophone", | |
| 794 | + "weight": 0.0027518399999999998, | |
| 795 | + "shares": { | |
| 796 | + "CAQ": 9, | |
| 797 | + "PLQ": 28, | |
| 798 | + "PQ": 12, | |
| 799 | + "QS": 22, | |
| 800 | + "PCQ": 10, | |
| 801 | + "AUT": 19 | |
| 802 | + } | |
| 803 | + }, | |
| 804 | + { | |
| 805 | + "region": "Estrie, Centre-du-Québec et Mauricie", | |
| 806 | + "age": "35-54 ans", | |
| 807 | + "gender": "femme", | |
| 808 | + "lang": "francophone", | |
| 809 | + "weight": 0.01656072, | |
| 810 | + "shares": { | |
| 811 | + "CAQ": 22, | |
| 812 | + "PLQ": 12, | |
| 813 | + "PQ": 30, | |
| 814 | + "QS": 22, | |
| 815 | + "PCQ": 9, | |
| 816 | + "AUT": 5 | |
| 817 | + } | |
| 818 | + }, | |
| 819 | + { | |
| 820 | + "region": "Estrie, Centre-du-Québec et Mauricie", | |
| 821 | + "age": "35-54 ans", | |
| 822 | + "gender": "femme", | |
| 823 | + "lang": "anglophone ou allophone", | |
| 824 | + "weight": 0.0036352800000000003, | |
| 825 | + "shares": { | |
| 826 | + "CAQ": 10, | |
| 827 | + "PLQ": 38, | |
| 828 | + "PQ": 12, | |
| 829 | + "QS": 18, | |
| 830 | + "PCQ": 7, | |
| 831 | + "AUT": 15 | |
| 832 | + } | |
| 833 | + }, | |
| 834 | + { | |
| 835 | + "region": "Estrie, Centre-du-Québec et Mauricie", | |
| 836 | + "age": "35-54 ans", | |
| 837 | + "gender": "homme", | |
| 838 | + "lang": "francophone", | |
| 839 | + "weight": 0.01591128, | |
| 840 | + "shares": { | |
| 841 | + "CAQ": 24, | |
| 842 | + "PLQ": 10, | |
| 843 | + "PQ": 32, | |
| 844 | + "QS": 14, | |
| 845 | + "PCQ": 16, | |
| 846 | + "AUT": 4 | |
| 847 | + } | |
| 848 | + }, | |
| 849 | + { | |
| 850 | + "region": "Estrie, Centre-du-Québec et Mauricie", | |
| 851 | + "age": "35-54 ans", | |
| 852 | + "gender": "homme", | |
| 853 | + "lang": "anglophone ou allophone", | |
| 854 | + "weight": 0.00349272, | |
| 855 | + "shares": { | |
| 856 | + "CAQ": 11, | |
| 857 | + "PLQ": 34, | |
| 858 | + "PQ": 14, | |
| 859 | + "QS": 12, | |
| 860 | + "PCQ": 14, | |
| 861 | + "AUT": 15 | |
| 862 | + } | |
| 863 | + }, | |
| 864 | + { | |
| 865 | + "region": "Estrie, Centre-du-Québec et Mauricie", | |
| 866 | + "age": "55 ans et plus", | |
| 867 | + "gender": "femme", | |
| 868 | + "lang": "francophone", | |
| 869 | + "weight": 0.020575439999999997, | |
| 870 | + "shares": { | |
| 871 | + "CAQ": 28, | |
| 872 | + "PLQ": 14, | |
| 873 | + "PQ": 34, | |
| 874 | + "QS": 10, | |
| 875 | + "PCQ": 10, | |
| 876 | + "AUT": 4 | |
| 877 | + } | |
| 878 | + }, | |
| 879 | + { | |
| 880 | + "region": "Estrie, Centre-du-Québec et Mauricie", | |
| 881 | + "age": "55 ans et plus", | |
| 882 | + "gender": "femme", | |
| 883 | + "lang": "anglophone ou allophone", | |
| 884 | + "weight": 0.004516559999999999, | |
| 885 | + "shares": { | |
| 886 | + "CAQ": 10, | |
| 887 | + "PLQ": 44, | |
| 888 | + "PQ": 14, | |
| 889 | + "QS": 8, | |
| 890 | + "PCQ": 8, | |
| 891 | + "AUT": 16 | |
| 892 | + } | |
| 893 | + }, | |
| 894 | + { | |
| 895 | + "region": "Estrie, Centre-du-Québec et Mauricie", | |
| 896 | + "age": "55 ans et plus", | |
| 897 | + "gender": "homme", | |
| 898 | + "lang": "francophone", | |
| 899 | + "weight": 0.019768559999999998, | |
| 900 | + "shares": { | |
| 901 | + "CAQ": 26, | |
| 902 | + "PLQ": 12, | |
| 903 | + "PQ": 36, | |
| 904 | + "QS": 7, | |
| 905 | + "PCQ": 16, | |
| 906 | + "AUT": 3 | |
| 907 | + } | |
| 908 | + }, | |
| 909 | + { | |
| 910 | + "region": "Estrie, Centre-du-Québec et Mauricie", | |
| 911 | + "age": "55 ans et plus", | |
| 912 | + "gender": "homme", | |
| 913 | + "lang": "anglophone ou allophone", | |
| 914 | + "weight": 0.004339439999999999, | |
| 915 | + "shares": { | |
| 916 | + "CAQ": 11, | |
| 917 | + "PLQ": 40, | |
| 918 | + "PQ": 16, | |
| 919 | + "QS": 6, | |
| 920 | + "PCQ": 14, | |
| 921 | + "AUT": 13 | |
| 922 | + } | |
| 923 | + }, | |
| 924 | + { | |
| 925 | + "region": "Outaouais et Abitibi", | |
| 926 | + "age": "18-34 ans", | |
| 927 | + "gender": "femme", | |
| 928 | + "lang": "francophone", | |
| 929 | + "weight": 0.006523919999999999, | |
| 930 | + "shares": { | |
| 931 | + "CAQ": 14, | |
| 932 | + "PLQ": 18, | |
| 933 | + "PQ": 22, | |
| 934 | + "QS": 36, | |
| 935 | + "PCQ": 5, | |
| 936 | + "AUT": 5 | |
| 937 | + } | |
| 938 | + }, | |
| 939 | + { | |
| 940 | + "region": "Outaouais et Abitibi", | |
| 941 | + "age": "18-34 ans", | |
| 942 | + "gender": "femme", | |
| 943 | + "lang": "anglophone ou allophone", | |
| 944 | + "weight": 0.0014320799999999999, | |
| 945 | + "shares": { | |
| 946 | + "CAQ": 8, | |
| 947 | + "PLQ": 42, | |
| 948 | + "PQ": 6, | |
| 949 | + "QS": 30, | |
| 950 | + "PCQ": 3, | |
| 951 | + "AUT": 11 | |
| 952 | + } | |
| 953 | + }, | |
| 954 | + { | |
| 955 | + "region": "Outaouais et Abitibi", | |
| 956 | + "age": "18-34 ans", | |
| 957 | + "gender": "homme", | |
| 958 | + "lang": "francophone", | |
| 959 | + "weight": 0.00626808, | |
| 960 | + "shares": { | |
| 961 | + "CAQ": 16, | |
| 962 | + "PLQ": 14, | |
| 963 | + "PQ": 26, | |
| 964 | + "QS": 26, | |
| 965 | + "PCQ": 12, | |
| 966 | + "AUT": 6 | |
| 967 | + } | |
| 968 | + }, | |
| 969 | + { | |
| 970 | + "region": "Outaouais et Abitibi", | |
| 971 | + "age": "18-34 ans", | |
| 972 | + "gender": "homme", | |
| 973 | + "lang": "anglophone ou allophone", | |
| 974 | + "weight": 0.0013759199999999999, | |
| 975 | + "shares": { | |
| 976 | + "CAQ": 9, | |
| 977 | + "PLQ": 38, | |
| 978 | + "PQ": 7, | |
| 979 | + "QS": 22, | |
| 980 | + "PCQ": 10, | |
| 981 | + "AUT": 14 | |
| 982 | + } | |
| 983 | + }, | |
| 984 | + { | |
| 985 | + "region": "Outaouais et Abitibi", | |
| 986 | + "age": "35-54 ans", | |
| 987 | + "gender": "femme", | |
| 988 | + "lang": "francophone", | |
| 989 | + "weight": 0.00828036, | |
| 990 | + "shares": { | |
| 991 | + "CAQ": 20, | |
| 992 | + "PLQ": 22, | |
| 993 | + "PQ": 28, | |
| 994 | + "QS": 20, | |
| 995 | + "PCQ": 6, | |
| 996 | + "AUT": 4 | |
| 997 | + } | |
| 998 | + }, | |
| 999 | + { | |
| 1000 | + "region": "Outaouais et Abitibi", | |
| 1001 | + "age": "35-54 ans", | |
| 1002 | + "gender": "femme", | |
| 1003 | + "lang": "anglophone ou allophone", | |
| 1004 | + "weight": 0.0018176400000000001, | |
| 1005 | + "shares": { | |
| 1006 | + "CAQ": 10, | |
| 1007 | + "PLQ": 48, | |
| 1008 | + "PQ": 7, | |
| 1009 | + "QS": 18, | |
| 1010 | + "PCQ": 4, | |
| 1011 | + "AUT": 13 | |
| 1012 | + } | |
| 1013 | + }, | |
| 1014 | + { | |
| 1015 | + "region": "Outaouais et Abitibi", | |
| 1016 | + "age": "35-54 ans", | |
| 1017 | + "gender": "homme", | |
| 1018 | + "lang": "francophone", | |
| 1019 | + "weight": 0.00795564, | |
| 1020 | + "shares": { | |
| 1021 | + "CAQ": 22, | |
| 1022 | + "PLQ": 18, | |
| 1023 | + "PQ": 32, | |
| 1024 | + "QS": 14, | |
| 1025 | + "PCQ": 10, | |
| 1026 | + "AUT": 4 | |
| 1027 | + } | |
| 1028 | + }, | |
| 1029 | + { | |
| 1030 | + "region": "Outaouais et Abitibi", | |
| 1031 | + "age": "35-54 ans", | |
| 1032 | + "gender": "homme", | |
| 1033 | + "lang": "anglophone ou allophone", | |
| 1034 | + "weight": 0.00174636, | |
| 1035 | + "shares": { | |
| 1036 | + "CAQ": 11, | |
| 1037 | + "PLQ": 45, | |
| 1038 | + "PQ": 8, | |
| 1039 | + "QS": 12, | |
| 1040 | + "PCQ": 10, | |
| 1041 | + "AUT": 14 | |
| 1042 | + } | |
| 1043 | + }, | |
| 1044 | + { | |
| 1045 | + "region": "Outaouais et Abitibi", | |
| 1046 | + "age": "55 ans et plus", | |
| 1047 | + "gender": "femme", | |
| 1048 | + "lang": "francophone", | |
| 1049 | + "weight": 0.010287719999999998, | |
| 1050 | + "shares": { | |
| 1051 | + "CAQ": 24, | |
| 1052 | + "PLQ": 28, | |
| 1053 | + "PQ": 30, | |
| 1054 | + "QS": 9, | |
| 1055 | + "PCQ": 5, | |
| 1056 | + "AUT": 4 | |
| 1057 | + } | |
| 1058 | + }, | |
| 1059 | + { | |
| 1060 | + "region": "Outaouais et Abitibi", | |
| 1061 | + "age": "55 ans et plus", | |
| 1062 | + "gender": "femme", | |
| 1063 | + "lang": "anglophone ou allophone", | |
| 1064 | + "weight": 0.0022582799999999997, | |
| 1065 | + "shares": { | |
| 1066 | + "CAQ": 9, | |
| 1067 | + "PLQ": 56, | |
| 1068 | + "PQ": 6, | |
| 1069 | + "QS": 8, | |
| 1070 | + "PCQ": 4, | |
| 1071 | + "AUT": 17 | |
| 1072 | + } | |
| 1073 | + }, | |
| 1074 | + { | |
| 1075 | + "region": "Outaouais et Abitibi", | |
| 1076 | + "age": "55 ans et plus", | |
| 1077 | + "gender": "homme", | |
| 1078 | + "lang": "francophone", | |
| 1079 | + "weight": 0.009884279999999999, | |
| 1080 | + "shares": { | |
| 1081 | + "CAQ": 26, | |
| 1082 | + "PLQ": 24, | |
| 1083 | + "PQ": 34, | |
| 1084 | + "QS": 7, | |
| 1085 | + "PCQ": 7, | |
| 1086 | + "AUT": 2 | |
| 1087 | + } | |
| 1088 | + }, | |
| 1089 | + { | |
| 1090 | + "region": "Outaouais et Abitibi", | |
| 1091 | + "age": "55 ans et plus", | |
| 1092 | + "gender": "homme", | |
| 1093 | + "lang": "anglophone ou allophone", | |
| 1094 | + "weight": 0.0021697199999999995, | |
| 1095 | + "shares": { | |
| 1096 | + "CAQ": 10, | |
| 1097 | + "PLQ": 54, | |
| 1098 | + "PQ": 7, | |
| 1099 | + "QS": 6, | |
| 1100 | + "PCQ": 6, | |
| 1101 | + "AUT": 17 | |
| 1102 | + } | |
| 1103 | + }, | |
| 1104 | + { | |
| 1105 | + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord", | |
| 1106 | + "age": "18-34 ans", | |
| 1107 | + "gender": "femme", | |
| 1108 | + "lang": "francophone", | |
| 1109 | + "weight": 0.0054366, | |
| 1110 | + "shares": { | |
| 1111 | + "CAQ": 18, | |
| 1112 | + "PLQ": 5, | |
| 1113 | + "PQ": 28, | |
| 1114 | + "QS": 38, | |
| 1115 | + "PCQ": 6, | |
| 1116 | + "AUT": 5 | |
| 1117 | + } | |
| 1118 | + }, | |
| 1119 | + { | |
| 1120 | + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord", | |
| 1121 | + "age": "18-34 ans", | |
| 1122 | + "gender": "femme", | |
| 1123 | + "lang": "anglophone ou allophone", | |
| 1124 | + "weight": 0.0011934, | |
| 1125 | + "shares": { | |
| 1126 | + "CAQ": 12, | |
| 1127 | + "PLQ": 28, | |
| 1128 | + "PQ": 10, | |
| 1129 | + "QS": 32, | |
| 1130 | + "PCQ": 5, | |
| 1131 | + "AUT": 13 | |
| 1132 | + } | |
| 1133 | + }, | |
| 1134 | + { | |
| 1135 | + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord", | |
| 1136 | + "age": "18-34 ans", | |
| 1137 | + "gender": "homme", | |
| 1138 | + "lang": "francophone", | |
| 1139 | + "weight": 0.0052234000000000004, | |
| 1140 | + "shares": { | |
| 1141 | + "CAQ": 20, | |
| 1142 | + "PLQ": 5, | |
| 1143 | + "PQ": 32, | |
| 1144 | + "QS": 24, | |
| 1145 | + "PCQ": 14, | |
| 1146 | + "AUT": 5 | |
| 1147 | + } | |
| 1148 | + }, | |
| 1149 | + { | |
| 1150 | + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord", | |
| 1151 | + "age": "18-34 ans", | |
| 1152 | + "gender": "homme", | |
| 1153 | + "lang": "anglophone ou allophone", | |
| 1154 | + "weight": 0.0011466, | |
| 1155 | + "shares": { | |
| 1156 | + "CAQ": 14, | |
| 1157 | + "PLQ": 25, | |
| 1158 | + "PQ": 12, | |
| 1159 | + "QS": 22, | |
| 1160 | + "PCQ": 15, | |
| 1161 | + "AUT": 12 | |
| 1162 | + } | |
| 1163 | + }, | |
| 1164 | + { | |
| 1165 | + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord", | |
| 1166 | + "age": "35-54 ans", | |
| 1167 | + "gender": "femme", | |
| 1168 | + "lang": "francophone", | |
| 1169 | + "weight": 0.0069003, | |
| 1170 | + "shares": { | |
| 1171 | + "CAQ": 26, | |
| 1172 | + "PLQ": 8, | |
| 1173 | + "PQ": 35, | |
| 1174 | + "QS": 20, | |
| 1175 | + "PCQ": 8, | |
| 1176 | + "AUT": 3 | |
| 1177 | + } | |
| 1178 | + }, | |
| 1179 | + { | |
| 1180 | + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord", | |
| 1181 | + "age": "35-54 ans", | |
| 1182 | + "gender": "femme", | |
| 1183 | + "lang": "anglophone ou allophone", | |
| 1184 | + "weight": 0.0015147000000000001, | |
| 1185 | + "shares": { | |
| 1186 | + "CAQ": 18, | |
| 1187 | + "PLQ": 30, | |
| 1188 | + "PQ": 14, | |
| 1189 | + "QS": 22, | |
| 1190 | + "PCQ": 8, | |
| 1191 | + "AUT": 8 | |
| 1192 | + } | |
| 1193 | + }, | |
| 1194 | + { | |
| 1195 | + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord", | |
| 1196 | + "age": "35-54 ans", | |
| 1197 | + "gender": "homme", | |
| 1198 | + "lang": "francophone", | |
| 1199 | + "weight": 0.0066297, | |
| 1200 | + "shares": { | |
| 1201 | + "CAQ": 28, | |
| 1202 | + "PLQ": 7, | |
| 1203 | + "PQ": 38, | |
| 1204 | + "QS": 12, | |
| 1205 | + "PCQ": 12, | |
| 1206 | + "AUT": 3 | |
| 1207 | + } | |
| 1208 | + }, | |
| 1209 | + { | |
| 1210 | + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord", | |
| 1211 | + "age": "35-54 ans", | |
| 1212 | + "gender": "homme", | |
| 1213 | + "lang": "anglophone ou allophone", | |
| 1214 | + "weight": 0.0014553, | |
| 1215 | + "shares": { | |
| 1216 | + "CAQ": 20, | |
| 1217 | + "PLQ": 28, | |
| 1218 | + "PQ": 16, | |
| 1219 | + "QS": 12, | |
| 1220 | + "PCQ": 14, | |
| 1221 | + "AUT": 10 | |
| 1222 | + } | |
| 1223 | + }, | |
| 1224 | + { | |
| 1225 | + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord", | |
| 1226 | + "age": "55 ans et plus", | |
| 1227 | + "gender": "femme", | |
| 1228 | + "lang": "francophone", | |
| 1229 | + "weight": 0.0085731, | |
| 1230 | + "shares": { | |
| 1231 | + "CAQ": 34, | |
| 1232 | + "PLQ": 10, | |
| 1233 | + "PQ": 36, | |
| 1234 | + "QS": 10, | |
| 1235 | + "PCQ": 7, | |
| 1236 | + "AUT": 3 | |
| 1237 | + } | |
| 1238 | + }, | |
| 1239 | + { | |
| 1240 | + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord", | |
| 1241 | + "age": "55 ans et plus", | |
| 1242 | + "gender": "femme", | |
| 1243 | + "lang": "anglophone ou allophone", | |
| 1244 | + "weight": 0.0018819000000000002, | |
| 1245 | + "shares": { | |
| 1246 | + "CAQ": 22, | |
| 1247 | + "PLQ": 35, | |
| 1248 | + "PQ": 18, | |
| 1249 | + "QS": 10, | |
| 1250 | + "PCQ": 8, | |
| 1251 | + "AUT": 7 | |
| 1252 | + } | |
| 1253 | + }, | |
| 1254 | + { | |
| 1255 | + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord", | |
| 1256 | + "age": "55 ans et plus", | |
| 1257 | + "gender": "homme", | |
| 1258 | + "lang": "francophone", | |
| 1259 | + "weight": 0.0082369, | |
| 1260 | + "shares": { | |
| 1261 | + "CAQ": 32, | |
| 1262 | + "PLQ": 9, | |
| 1263 | + "PQ": 40, | |
| 1264 | + "QS": 7, | |
| 1265 | + "PCQ": 10, | |
| 1266 | + "AUT": 2 | |
| 1267 | + } | |
| 1268 | + }, | |
| 1269 | + { | |
| 1270 | + "region": "Saguenay–Lac-Saint-Jean et Côte-Nord", | |
| 1271 | + "age": "55 ans et plus", | |
| 1272 | + "gender": "homme", | |
| 1273 | + "lang": "anglophone ou allophone", | |
| 1274 | + "weight": 0.0018081, | |
| 1275 | + "shares": { | |
| 1276 | + "CAQ": 24, | |
| 1277 | + "PLQ": 33, | |
| 1278 | + "PQ": 20, | |
| 1279 | + "QS": 8, | |
| 1280 | + "PCQ": 10, | |
| 1281 | + "AUT": 5 | |
| 1282 | + } | |
| 1283 | + }, | |
| 1284 | + { | |
| 1285 | + "region": "Bas-Saint-Laurent et Gaspésie", | |
| 1286 | + "age": "18-34 ans", | |
| 1287 | + "gender": "femme", | |
| 1288 | + "lang": "francophone", | |
| 1289 | + "weight": 0.0054366, | |
| 1290 | + "shares": { | |
| 1291 | + "CAQ": 14, | |
| 1292 | + "PLQ": 6, | |
| 1293 | + "PQ": 28, | |
| 1294 | + "QS": 38, | |
| 1295 | + "PCQ": 6, | |
| 1296 | + "AUT": 8 | |
| 1297 | + } | |
| 1298 | + }, | |
| 1299 | + { | |
| 1300 | + "region": "Bas-Saint-Laurent et Gaspésie", | |
| 1301 | + "age": "18-34 ans", | |
| 1302 | + "gender": "femme", | |
| 1303 | + "lang": "anglophone ou allophone", | |
| 1304 | + "weight": 0.0011934, | |
| 1305 | + "shares": { | |
| 1306 | + "CAQ": 10, | |
| 1307 | + "PLQ": 28, | |
| 1308 | + "PQ": 12, | |
| 1309 | + "QS": 32, | |
| 1310 | + "PCQ": 4, | |
| 1311 | + "AUT": 14 | |
| 1312 | + } | |
| 1313 | + }, | |
| 1314 | + { | |
| 1315 | + "region": "Bas-Saint-Laurent et Gaspésie", | |
| 1316 | + "age": "18-34 ans", | |
| 1317 | + "gender": "homme", | |
| 1318 | + "lang": "francophone", | |
| 1319 | + "weight": 0.0052234000000000004, | |
| 1320 | + "shares": { | |
| 1321 | + "CAQ": 16, | |
| 1322 | + "PLQ": 5, | |
| 1323 | + "PQ": 30, | |
| 1324 | + "QS": 26, | |
| 1325 | + "PCQ": 14, | |
| 1326 | + "AUT": 9 | |
| 1327 | + } | |
| 1328 | + }, | |
| 1329 | + { | |
| 1330 | + "region": "Bas-Saint-Laurent et Gaspésie", | |
| 1331 | + "age": "18-34 ans", | |
| 1332 | + "gender": "homme", | |
| 1333 | + "lang": "anglophone ou allophone", | |
| 1334 | + "weight": 0.0011466, | |
| 1335 | + "shares": { | |
| 1336 | + "CAQ": 12, | |
| 1337 | + "PLQ": 24, | |
| 1338 | + "PQ": 14, | |
| 1339 | + "QS": 22, | |
| 1340 | + "PCQ": 12, | |
| 1341 | + "AUT": 16 | |
| 1342 | + } | |
| 1343 | + }, | |
| 1344 | + { | |
| 1345 | + "region": "Bas-Saint-Laurent et Gaspésie", | |
| 1346 | + "age": "35-54 ans", | |
| 1347 | + "gender": "femme", | |
| 1348 | + "lang": "francophone", | |
| 1349 | + "weight": 0.0069003, | |
| 1350 | + "shares": { | |
| 1351 | + "CAQ": 20, | |
| 1352 | + "PLQ": 8, | |
| 1353 | + "PQ": 36, | |
| 1354 | + "QS": 22, | |
| 1355 | + "PCQ": 8, | |
| 1356 | + "AUT": 6 | |
| 1357 | + } | |
| 1358 | + }, | |
| 1359 | + { | |
| 1360 | + "region": "Bas-Saint-Laurent et Gaspésie", | |
| 1361 | + "age": "35-54 ans", | |
| 1362 | + "gender": "femme", | |
| 1363 | + "lang": "anglophone ou allophone", | |
| 1364 | + "weight": 0.0015147000000000001, | |
| 1365 | + "shares": { | |
| 1366 | + "CAQ": 14, | |
| 1367 | + "PLQ": 30, | |
| 1368 | + "PQ": 18, | |
| 1369 | + "QS": 20, | |
| 1370 | + "PCQ": 6, | |
| 1371 | + "AUT": 12 | |
| 1372 | + } | |
| 1373 | + }, | |
| 1374 | + { | |
| 1375 | + "region": "Bas-Saint-Laurent et Gaspésie", | |
| 1376 | + "age": "35-54 ans", | |
| 1377 | + "gender": "homme", | |
| 1378 | + "lang": "francophone", | |
| 1379 | + "weight": 0.0066297, | |
| 1380 | + "shares": { | |
| 1381 | + "CAQ": 22, | |
| 1382 | + "PLQ": 7, | |
| 1383 | + "PQ": 38, | |
| 1384 | + "QS": 14, | |
| 1385 | + "PCQ": 13, | |
| 1386 | + "AUT": 6 | |
| 1387 | + } | |
| 1388 | + }, | |
| 1389 | + { | |
| 1390 | + "region": "Bas-Saint-Laurent et Gaspésie", | |
| 1391 | + "age": "35-54 ans", | |
| 1392 | + "gender": "homme", | |
| 1393 | + "lang": "anglophone ou allophone", | |
| 1394 | + "weight": 0.0014553, | |
| 1395 | + "shares": { | |
| 1396 | + "CAQ": 16, | |
| 1397 | + "PLQ": 26, | |
| 1398 | + "PQ": 20, | |
| 1399 | + "QS": 12, | |
| 1400 | + "PCQ": 14, | |
| 1401 | + "AUT": 12 | |
| 1402 | + } | |
| 1403 | + }, | |
| 1404 | + { | |
| 1405 | + "region": "Bas-Saint-Laurent et Gaspésie", | |
| 1406 | + "age": "55 ans et plus", | |
| 1407 | + "gender": "femme", | |
| 1408 | + "lang": "francophone", | |
| 1409 | + "weight": 0.0085731, | |
| 1410 | + "shares": { | |
| 1411 | + "CAQ": 26, | |
| 1412 | + "PLQ": 10, | |
| 1413 | + "PQ": 42, | |
| 1414 | + "QS": 10, | |
| 1415 | + "PCQ": 7, | |
| 1416 | + "AUT": 5 | |
| 1417 | + } | |
| 1418 | + }, | |
| 1419 | + { | |
| 1420 | + "region": "Bas-Saint-Laurent et Gaspésie", | |
| 1421 | + "age": "55 ans et plus", | |
| 1422 | + "gender": "femme", | |
| 1423 | + "lang": "anglophone ou allophone", | |
| 1424 | + "weight": 0.0018819000000000002, | |
| 1425 | + "shares": { | |
| 1426 | + "CAQ": 18, | |
| 1427 | + "PLQ": 34, | |
| 1428 | + "PQ": 24, | |
| 1429 | + "QS": 8, | |
| 1430 | + "PCQ": 6, | |
| 1431 | + "AUT": 10 | |
| 1432 | + } | |
| 1433 | + }, | |
| 1434 | + { | |
| 1435 | + "region": "Bas-Saint-Laurent et Gaspésie", | |
| 1436 | + "age": "55 ans et plus", | |
| 1437 | + "gender": "homme", | |
| 1438 | + "lang": "francophone", | |
| 1439 | + "weight": 0.0082369, | |
| 1440 | + "shares": { | |
| 1441 | + "CAQ": 28, | |
| 1442 | + "PLQ": 9, | |
| 1443 | + "PQ": 42, | |
| 1444 | + "QS": 6, | |
| 1445 | + "PCQ": 10, | |
| 1446 | + "AUT": 5 | |
| 1447 | + } | |
| 1448 | + }, | |
| 1449 | + { | |
| 1450 | + "region": "Bas-Saint-Laurent et Gaspésie", | |
| 1451 | + "age": "55 ans et plus", | |
| 1452 | + "gender": "homme", | |
| 1453 | + "lang": "anglophone ou allophone", | |
| 1454 | + "weight": 0.0018081, | |
| 1455 | + "shares": { | |
| 1456 | + "CAQ": 20, | |
| 1457 | + "PLQ": 32, | |
| 1458 | + "PQ": 26, | |
| 1459 | + "QS": 6, | |
| 1460 | + "PCQ": 8, | |
| 1461 | + "AUT": 8 | |
| 1462 | + } | |
| 1463 | + } | |
| 1464 | + ] | |
| 1465 | +} | |
| \ No newline at end of file | ||
modified
backend/tests/test_beyond.py
+30 −0
@@ -109,3 +109,33 @@ def test_market_blend(): | ||
| 109 | 109 | # sans marché : identité |
| 110 | 110 | same = ENS.market_blend(model, None) |
| 111 | 111 | assert same["blended"] == model and same["weight_market"] == 0.0 |
| 112 | + | |
| 113 | + | |
| 114 | +def test_synthetic_poststratify(): | |
| 115 | + from app.modeling.synthetic_poll import poststratify, strata | |
| 116 | + st = strata() | |
| 117 | + assert len(st) == 96 | |
| 118 | + assert abs(sum(s["weight"] for s in st) - 1.0) < 1e-9 | |
| 119 | + cells = [{**s, "shares": {"CAQ": 20, "PLQ": 20, "PQ": 30, "QS": 10, "PCQ": 15, "AUT": 5}} | |
| 120 | + for s in st] | |
| 121 | + agg = poststratify(cells) | |
| 122 | + assert abs(sum(agg.values()) - 100.0) < 0.5 | |
| 123 | + assert abs(agg["PQ"] - 30.0) < 0.2 | |
| 124 | + # strates non normalisées → renormalisées avant agrégation | |
| 125 | + cells[0]["shares"] = {"CAQ": 40, "PLQ": 40, "PQ": 60, "QS": 20, "PCQ": 30, "AUT": 10} | |
| 126 | + agg2 = poststratify(cells) | |
| 127 | + assert abs(sum(agg2.values()) - 100.0) < 0.5 | |
| 128 | + | |
| 129 | + | |
| 130 | +def test_forecast_drift_multiplier_widens_only(): | |
| 131 | + from datetime import date | |
| 132 | + from app.modeling.forecast import forecast | |
| 133 | + from app.modeling.trend import TrendResult | |
| 134 | + x = np.zeros(5); P = np.eye(5) * 0.005 | |
| 135 | + tr = TrendResult(dates=[date(2026, 8, 1)], share_mean=np.zeros((1, 6)), | |
| 136 | + share_lo=np.zeros((1, 6)), share_hi=np.zeros((1, 6)), | |
| 137 | + x=x, P=P, q=1e-4, loglik=0.0, n_polls=10) | |
| 138 | + f1 = forecast(tr, date(2026, 8, 30), date(2026, 10, 5)) | |
| 139 | + f2 = forecast(tr, date(2026, 8, 30), date(2026, 10, 5), drift_multiplier=1.45) | |
| 140 | + assert np.allclose(f1.x, f2.x) # moyenne intacte | |
| 141 | + assert np.trace(f2.P) > np.trace(f1.P) # variance élargie | |
modified
frontend/assets/app.js
+2 −2
@@ -78,8 +78,8 @@ function sortParties(seats) { | ||
| 78 | 78 | |
| 79 | 79 | function headerHTML(active) { |
| 80 | 80 | const links = [ |
| 81 | − ["/", "Aperçu"], ["/carte", "Carte"], ["/sondages", "Sondages"], | |
| 82 | − ["/signaux", "Signaux"], ["/simulateur", "Simulateur"], | |
| 81 | + ["/", "Aperçu"], ["/aujourdhui", "Le point du jour"], ["/carte", "Carte"], | |
| 82 | + ["/sondages", "Sondages"], ["/signaux", "Signaux"], ["/simulateur", "Simulateur"], | |
| 83 | 83 | ["/intelligence", "Intelligence"], ["/backtest", "Calibration"], |
| 84 | 84 | ["/methodologie", "Méthodologie"], |
| 85 | 85 | ]; |
added
frontend/aujourdhui.html
+251 −0
@@ -0,0 +1,251 @@ | ||
| 1 | +<!DOCTYPE html> | |
| 2 | +<!-- | |
| 3 | + QC Élection Forecast — Plateforme de prévision électorale du Québec 2026 | |
| 4 | + Auteur : Simon-Pierre Boucher | |
| 5 | + Contact : contact@spboucher.ai | |
| 6 | + https://www.qc-election.com | |
| 7 | +--> | |
| 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>Le point du jour — QC Élection Forecast</title> | |
| 14 | +<meta name="description" content="Le forecast du jour de l'élection québécoise 2026 : probabilités datées, ce qui a changé depuis hier, attention web, nouveaux signaux."> | |
| 15 | +<link rel="stylesheet" href="/assets/style.css"> | |
| 16 | +<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script> | |
| 17 | +<style> | |
| 18 | + .day-date { font-size: 15px; letter-spacing: .12em; text-transform: uppercase; | |
| 19 | + color: var(--muted); font-weight: 600; } | |
| 20 | + .headline { font-size: clamp(24px, 3.4vw, 40px); font-weight: 800; line-height: 1.25; | |
| 21 | + margin: 8px 0 4px; } | |
| 22 | + .delta-up { color: var(--good); } .delta-down { color: #e5484d; } | |
| 23 | + .delta-flat { color: var(--muted); } | |
| 24 | + .att-bar { height: 10px; border-radius: 6px; display: flex; overflow: hidden; | |
| 25 | + margin: 8px 0 4px; } | |
| 26 | + .mini-spark { height: 34px; } | |
| 27 | + .synth-badge { border: 1px dashed var(--muted); border-radius: 8px; padding: 2px 8px; | |
| 28 | + font-size: 11px; color: var(--muted); } | |
| 29 | + .tick { display:flex; gap:10px; align-items:baseline; padding: 7px 0; | |
| 30 | + border-bottom: 1px solid var(--line, #232838); font-size: 13.5px; } | |
| 31 | + .tick:last-child { border-bottom: 0; } | |
| 32 | + .tick .t { color: var(--muted); white-space: nowrap; font-size: 12px; min-width: 86px; } | |
| 33 | +</style> | |
| 34 | +</head> | |
| 35 | +<body> | |
| 36 | +<header class="site"></header> | |
| 37 | +<div class="wrap"> | |
| 38 | + <div class="hero"> | |
| 39 | + <div class="day-date" id="day-date">—</div> | |
| 40 | + <h1 class="headline" id="headline">Chargement du point du jour…</h1> | |
| 41 | + <p class="sub" id="day-sub"></p> | |
| 42 | + <p class="disclaimer">Snapshot daté et archivé — chaque affirmation renvoie au run du modèle | |
| 43 | + (<a href="/methodologie">méthodologie</a> · <a href="/signaux">décomposition des couches</a>).</p> | |
| 44 | + </div> | |
| 45 | + | |
| 46 | + <h2 class="section">Les chiffres du jour</h2> | |
| 47 | + <div class="grid parties" id="day-cards"><div class="loading">Chargement…</div></div> | |
| 48 | + <div class="card"> | |
| 49 | + <div class="kv"><span>Probabilité qu'aucun parti n'obtienne la majorité</span><b id="d-nomaj"></b></div> | |
| 50 | + <div class="kv"><span>Ce qui bouge depuis la veille</span><b id="d-move"></b></div> | |
| 51 | + </div> | |
| 52 | + | |
| 53 | + <div class="grid two"> | |
| 54 | + <div class="card"> | |
| 55 | + <h3>Ce qui a changé depuis <span id="prev-date">hier</span></h3> | |
| 56 | + <table class="data" id="delta-table"></table> | |
| 57 | + <p class="chart-note">Δ = dernier run vs dernier run de la journée précédente. | |
| 58 | + P(1er) = probabilité publiée (ensemble modèle/marchés).</p> | |
| 59 | + </div> | |
| 60 | + <div class="card"> | |
| 61 | + <h3>Le journal des dernières 24 h</h3> | |
| 62 | + <div id="ticker"></div> | |
| 63 | + </div> | |
| 64 | + </div> | |
| 65 | + | |
| 66 | + <h2 class="section">Attention web <span class="chip badge-aux">Wikipédia — recherche d'information</span></h2> | |
| 67 | + <p class="section-sub">Pages des partis et des chef·fe·s (fr.wikipédia) : qui les Québécois·es | |
| 68 | + recherchent-ils ? La littérature montre que l'attention précède souvent le mouvement — le | |
| 69 | + signal ne déplace jamais le forecast, mais un pic global <b>élargit l'incertitude de campagne</b>.</p> | |
| 70 | + <div class="card" id="attention-card"><div class="loading">Chargement…</div></div> | |
| 71 | + | |
| 72 | + <div class="grid two"> | |
| 73 | + <div class="card"> | |
| 74 | + <h3>Sondage synthétique IA <span class="synth-badge">EXPÉRIMENTAL — poids nul</span></h3> | |
| 75 | + <p class="section-sub">« Silicon sampling » : 96 strates démographiques du Québec incarnées | |
| 76 | + par un LLM local ancré dans l'actualité réelle de la campagne, poststratifiées. Ce n'est | |
| 77 | + <b>pas un sondage</b> (aucune donnée nouvelle) — publié comme objet d'étude, évalué après | |
| 78 | + le 5 octobre.</p> | |
| 79 | + <div id="synthetic-body"><div class="loading">Chargement…</div></div> | |
| 80 | + </div> | |
| 81 | + <div class="card"> | |
| 82 | + <h3>À surveiller</h3> | |
| 83 | + <div class="events" id="upcoming"></div> | |
| 84 | + <h3 style="margin-top:18px">Les couches du forecast aujourd'hui</h3> | |
| 85 | + <div id="layer-state"></div> | |
| 86 | + </div> | |
| 87 | + </div> | |
| 88 | +</div> | |
| 89 | +<footer class="site"></footer> | |
| 90 | + | |
| 91 | +<script src="/assets/app.js"></script> | |
| 92 | +<script> | |
| 93 | +(async () => { | |
| 94 | + const meta = await initShell("/aujourdhui"); | |
| 95 | + const parties = Object.keys(meta.parties).filter(p => p !== "AUT"); | |
| 96 | + const T = await getJSON(`${API}/today`); | |
| 97 | + | |
| 98 | + const dstr = new Date(T.date + "T12:00:00").toLocaleDateString("fr-CA", | |
| 99 | + { weekday: "long", year: "numeric", month: "long", day: "numeric" }); | |
| 100 | + document.getElementById("day-date").textContent = | |
| 101 | + `${dstr} · J−${T.days_to_election} avant le scrutin`; | |
| 102 | + | |
| 103 | + const order = [...parties].sort((a, b) => T.prob_most[b] - T.prob_most[a]); | |
| 104 | + const lead = order[0]; | |
| 105 | + document.getElementById("headline").innerHTML = | |
| 106 | + `Le <span style="color:${partyColor(lead)}">${partyName(lead)}</span> a | |
| 107 | + ${(T.prob_most[lead] * 100).toFixed(0)} % de chances de remporter le plus de sièges`; | |
| 108 | + document.getElementById("day-sub").innerHTML = | |
| 109 | + `Majorité ${lead} : <b>${(T.seats[lead].prob_majority * 100).toFixed(0)} %</b> · | |
| 110 | + vote projeté ${T.vote[lead].toFixed(1)} % · | |
| 111 | + ${T.seats[lead].mean.toFixed(0)} sièges attendus [${T.seats[lead].p05}–${T.seats[lead].p95}] · | |
| 112 | + run #${T.run_id} (${new Date(T.run_at + "Z").toLocaleTimeString("fr-CA", { hour: "2-digit", minute: "2-digit" })})`; | |
| 113 | + | |
| 114 | + const arrow = (d, unit, digits = 1) => { | |
| 115 | + if (d == null) return ""; | |
| 116 | + const a = Math.abs(d) < (unit === "%" ? 0.005 : 0.05) ? "flat" : d > 0 ? "up" : "down"; | |
| 117 | + const sym = a === "flat" ? "→" : a === "up" ? "▲" : "▼"; | |
| 118 | + const txt = unit === "%" ? (Math.abs(d) * 100).toFixed(1) + " pt" : Math.abs(d).toFixed(digits) + unit; | |
| 119 | + return `<span class="delta-${a}" style="font-size:12px;font-weight:600">${sym} ${txt}</span>`; | |
| 120 | + }; | |
| 121 | + | |
| 122 | + /* ---- cartes du jour ---- */ | |
| 123 | + document.getElementById("day-cards").innerHTML = order.map(p => { | |
| 124 | + const dp = T.delta ? T.delta.prob_most[p] : null; | |
| 125 | + const dv = T.delta ? T.delta.vote[p] : null; | |
| 126 | + return `<div class="card party-card"> | |
| 127 | + <div class="bar" style="background:${partyColor(p)}"></div> | |
| 128 | + <div class="party-head"><span class="avatars-stack">${avatarHTML(p, 36)}</span> | |
| 129 | + <div class="pname">${p}</div></div> | |
| 130 | + <div class="bignum">${(T.prob_most[p] * 100).toFixed(0)} <small>% premier</small> ${arrow(dp, "%")}</div> | |
| 131 | + <div class="kv"><span>Vote</span><b>${T.vote[p].toFixed(1)} % ${arrow(dv, " pp", 2)}</b></div> | |
| 132 | + <div class="kv"><span>Sièges</span><b>${T.seats[p].mean.toFixed(0)} [${T.seats[p].p05}–${T.seats[p].p95}]</b></div> | |
| 133 | + </div>`; | |
| 134 | + }).join(""); | |
| 135 | + document.getElementById("d-nomaj").textContent = fmtPct(T.prob_no_majority); | |
| 136 | + const movers = T.delta ? order.map(p => [p, T.delta.vote[p]]) | |
| 137 | + .filter(([, d]) => Math.abs(d) >= 0.05).sort((a, b) => Math.abs(b[1]) - Math.abs(a[1])) : []; | |
| 138 | + document.getElementById("d-move").innerHTML = movers.length | |
| 139 | + ? movers.slice(0, 3).map(([p, d]) => | |
| 140 | + `<span style="color:${partyColor(p)}">${p}</span> ${d > 0 ? "+" : ""}${d.toFixed(2)} pp`).join(" · ") | |
| 141 | + : "journée stable — aucun mouvement notable"; | |
| 142 | + | |
| 143 | + /* ---- table des deltas ---- */ | |
| 144 | + if (T.delta) { | |
| 145 | + document.getElementById("prev-date").textContent = fmtDate(T.delta.since); | |
| 146 | + document.getElementById("delta-table").innerHTML = | |
| 147 | + `<tr><th>Parti</th><th>P(1er)</th><th>Δ</th><th>Vote</th><th>Δ</th><th>Sièges</th><th>Δ</th></tr>` + | |
| 148 | + order.map(p => `<tr> | |
| 149 | + <td><span class="dot" style="background:${partyColor(p)}"></span>${p}</td> | |
| 150 | + <td>${(T.prob_most[p] * 100).toFixed(1)} %</td><td>${arrow(T.delta.prob_most[p], "%")}</td> | |
| 151 | + <td>${T.vote[p].toFixed(1)} %</td><td>${arrow(T.delta.vote[p], " pp", 2)}</td> | |
| 152 | + <td>${T.seats[p].mean.toFixed(0)}</td><td>${arrow(T.delta.seats[p], "", 1)}</td></tr>`).join(""); | |
| 153 | + } else { | |
| 154 | + document.getElementById("delta-table").innerHTML = | |
| 155 | + `<tr><td style="color:var(--muted)">Premier jour de publication — comparaison disponible demain.</td></tr>`; | |
| 156 | + } | |
| 157 | + | |
| 158 | + /* ---- journal 24 h ---- */ | |
| 159 | + const ticks = []; | |
| 160 | + for (const p of T.new_polls_24h) { | |
| 161 | + const top = Object.entries(p.shares).filter(([k]) => k !== "AUT") | |
| 162 | + .sort((a, b) => b[1] - a[1]).slice(0, 2) | |
| 163 | + .map(([k, v]) => `${k} ${v.toFixed(0)}`).join(" · "); | |
| 164 | + ticks.push([`sondage`, `<b>${p.pollster}</b> intégré (terrain ${fmtDate(p.field_end)}, n=${p.sample_size ?? "?"}) — ${top}`]); | |
| 165 | + } | |
| 166 | + for (const s of T.new_signals_24h) { | |
| 167 | + ticks.push([s.kind === "poll-radar" ? "radar" : s.kind, | |
| 168 | + `<a href="${s.url}" target="_blank" rel="noopener">${s.title}</a>${s.pollster ? " · " + s.pollster : ""}`]); | |
| 169 | + } | |
| 170 | + document.getElementById("ticker").innerHTML = ticks.length | |
| 171 | + ? ticks.slice(0, 12).map(([k, h]) => | |
| 172 | + `<div class="tick"><span class="t"><span class="chip badge-aux">${k}</span></span><span>${h}</span></div>`).join("") | |
| 173 | + : '<p class="note">Aucun nouveau sondage ni signal dans les dernières 24 h.</p>'; | |
| 174 | + | |
| 175 | + /* ---- attention web ---- */ | |
| 176 | + try { | |
| 177 | + const A = await getJSON(`${API}/attention`); | |
| 178 | + if (!A.available) throw new Error(A.note || "indisponible"); | |
| 179 | + const ap = A.parties; | |
| 180 | + const ordA = parties.filter(p => ap[p]).sort((a, b) => ap[b].share_7j - ap[a].share_7j); | |
| 181 | + document.getElementById("attention-card").innerHTML = ` | |
| 182 | + <div class="att-bar">${ordA.map(p => | |
| 183 | + `<div style="flex:${ap[p].share_7j};background:${partyColor(p)}" title="${p} ${ap[p].share_7j} %"></div>`).join("")}</div> | |
| 184 | + <table class="data"> | |
| 185 | + <tr><th>Parti</th><th>Part d'attention 7 j</th><th>vs base 180 j</th><th>Pic (z, 3 j)</th><th>28 derniers jours</th></tr> | |
| 186 | + ${ordA.map((p, i) => `<tr> | |
| 187 | + <td><span class="dot" style="background:${partyColor(p)}"></span>${p}</td> | |
| 188 | + <td><b>${ap[p].share_7j.toFixed(1)} %</b> <span style="color:var(--muted);font-size:11px">(${ap[p].views_7j.toLocaleString("fr-CA")} vues)</span></td> | |
| 189 | + <td class="${ap[p].delta_share_pp > 0.5 ? "delta-up" : ap[p].delta_share_pp < -0.5 ? "delta-down" : "delta-flat"}">${ap[p].delta_share_pp > 0 ? "+" : ""}${ap[p].delta_share_pp.toFixed(1)} pp</td> | |
| 190 | + <td>${ap[p].z_3j >= 2 ? "🔥 " : ""}${ap[p].z_3j.toFixed(1)}</td> | |
| 191 | + <td><div class="mini-spark" id="spark-${p}"></div></td></tr>`).join("")} | |
| 192 | + </table> | |
| 193 | + <p class="note">${A.note} Turbulence détectée : z max ${A.z_max} → variance de campagne ×${A.drift_multiplier}. | |
| 194 | + Données au ${fmtDate(A.as_of)} · Wikimedia REST API.</p>`; | |
| 195 | + for (const p of ordA) { | |
| 196 | + const c = baseChart(document.getElementById("spark-" + p)); | |
| 197 | + c.setOption({ grid: { left: 0, right: 0, top: 2, bottom: 2 }, | |
| 198 | + xAxis: { type: "category", show: false, data: ap[p].sparkline.map((_, i) => i) }, | |
| 199 | + yAxis: { type: "value", show: false }, | |
| 200 | + series: [{ type: "line", data: ap[p].sparkline, symbol: "none", | |
| 201 | + color: partyColor(p), lineStyle: { width: 1.5 }, areaStyle: { opacity: 0.15 } }] }); | |
| 202 | + } | |
| 203 | + } catch (e) { | |
| 204 | + document.getElementById("attention-card").innerHTML = | |
| 205 | + `<p class="note">Signal d'attention en cours de constitution — première fenêtre de 30 jours requise.</p>`; | |
| 206 | + } | |
| 207 | + | |
| 208 | + /* ---- sondage synthétique ---- */ | |
| 209 | + try { | |
| 210 | + const S = await getJSON(`${API}/synthetic`); | |
| 211 | + const ordS = parties.sort((a, b) => (S.shares[b] || 0) - (S.shares[a] || 0)); | |
| 212 | + document.getElementById("synthetic-body").innerHTML = ` | |
| 213 | + <table class="data"> | |
| 214 | + <tr><th>Parti</th><th>Synthétique</th><th>Forecast</th><th>Écart</th></tr> | |
| 215 | + ${ordS.map(p => { | |
| 216 | + const c = (S.forecast_comparison || {})[p] || {}; | |
| 217 | + const d = c.synthetique != null && c.forecast != null ? c.synthetique - c.forecast : null; | |
| 218 | + return `<tr><td><span class="dot" style="background:${partyColor(p)}"></span>${p}</td> | |
| 219 | + <td><b>${S.shares[p] != null ? S.shares[p].toFixed(1) + " %" : "—"}</b></td> | |
| 220 | + <td>${c.forecast != null ? c.forecast.toFixed(1) + " %" : "—"}</td> | |
| 221 | + <td style="color:var(--muted)">${d != null ? (d >= 0 ? "+" : "") + d.toFixed(1) + " pp" : "—"}</td></tr>`; | |
| 222 | + }).join("")} | |
| 223 | + </table> | |
| 224 | + <p class="note">${S.note} Généré le ${fmtDate(S.as_of)} · modèle <code>${S.model}</code> · | |
| 225 | + ${S.n_strata} strates poststratifiées · ancré dans ${(S.headlines || []).length} manchettes réelles.</p>`; | |
| 226 | + } catch (e) { | |
| 227 | + document.getElementById("synthetic-body").innerHTML = | |
| 228 | + '<p class="note">Aucun sondage synthétique généré pour l\'instant (LLM local requis).</p>'; | |
| 229 | + } | |
| 230 | + | |
| 231 | + /* ---- à surveiller + état des couches ---- */ | |
| 232 | + document.getElementById("upcoming").innerHTML = (T.upcoming_events || []).map(e => | |
| 233 | + `<div class="event"><span class="d">${fmtDate(e.date)}</span><span>${e.title}</span></div>`).join("") | |
| 234 | + || '<span style="color:var(--muted)">Aucun événement à venir enregistré.</span>'; | |
| 235 | + const B = T.beyond || {}; | |
| 236 | + const sat = ((B.fundamentals || {}).satisfaction || {}); | |
| 237 | + const wF = (((B.fundamentals || {}).blend || {}).precision_share || 0) * 100; | |
| 238 | + const att = B.web_attention || {}; | |
| 239 | + const nudgeMax = Math.max(0, ...Object.values((B.media_adjustment || {}).delta_pp || { x: 0 }).map(Math.abs)); | |
| 240 | + const mkt = B.market_ensemble; | |
| 241 | + document.getElementById("layer-state").innerHTML = [ | |
| 242 | + ["✅ Votes réels", `${B.n_byelections || 0} partielles dans le filtre`], | |
| 243 | + ["🏛️ Fondamentaux", `satisfaction ${sat.value != null ? sat.value.toFixed(0) + " %" : "—"} (${sat.method === "firecrawl" ? "veille live" : "repli"}) · poids ${wF.toFixed(1)} %`], | |
| 244 | + ["👁️ Attention web", att.available ? `z max ${att.z_max} → variance ×${att.drift_multiplier}` : "en constitution"], | |
| 245 | + ["📰 Médias", `nudge ±${nudgeMax.toFixed(2)} pp`], | |
| 246 | + ["📈 Marchés", mkt ? `${(mkt.weight_market * 100).toFixed(0)} % dans P(1er)` : "indisponible"], | |
| 247 | + ].map(([t, d]) => `<div class="kv"><span>${t}</span><b style="font-weight:500;color:var(--muted)">${d}</b></div>`).join(""); | |
| 248 | +})(); | |
| 249 | +</script> | |
| 250 | +</body> | |
| 251 | +</html> | |
modified
frontend/methodologie.html
+11 −0
@@ -99,6 +99,17 @@ | ||
| 99 | 99 | renormalisée — les intentions de vote ne sont pas modifiées</td> |
| 100 | 100 | <td>88 % modèle / 12 % marché</td></tr> |
| 101 | 101 | </table> |
| 102 | + <p><b>Signaux web avancés (v2.1)</b> — deux signaux issus de la littérature récente | |
| 103 | + complètent les couches : (a) l'<b>attention Wikipédia</b> (pageviews quotidiens des pages | |
| 104 | + des partis et des chef·fe·s, API Wikimedia) — la recherche montre qu'elle ajoute du pouvoir | |
| 105 | + prédictif au-delà des sondages et fondamentaux (Smith & Gustafson, <i>Public Opinion | |
| 106 | + Quarterly</i> 2017); comme un pic peut être bon ou mauvais pour un parti, le signal n'est | |
| 107 | + <b>jamais directionnel</b> : un pic global (z ≥ 2) gonfle la variance de dérive du forecast | |
| 108 | + (multiplicateur ≤ 1,45) sans toucher aucune moyenne; (b) un <b>sondage synthétique LLM</b> | |
| 109 | + (« silicon sampling », 96 strates démographiques du Québec poststratifiées, LLM local ancré | |
| 110 | + dans les manchettes réelles de notre veille) — <b>expérimental, poids strictement nul</b> : | |
| 111 | + un LLM ne collecte aucune donnée nouvelle (critique McKown-Dawson/Silver 2025); l'écart au | |
| 112 | + forecast est publié comme objet d'étude et sera évalué après le 5 octobre.</p> | |
| 102 | 113 | <p>Une <b>veille web continue</b> (Firecrawl) alimente ces couches à chaque cycle : |
| 103 | 114 | radar des nouveaux sondages dans la presse (alerte de provenance, aucune ingestion |
| 104 | 115 | automatique de chiffres), extraction du % de satisfaits envers le gouvernement, et |
| 105 | 116 | |