SPB Git

spb/qc-election

Public

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

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 chiffres; (b) extraction du % de satisfaits (baromètres Léger); (c) presse élargie 101 chiffres; (b) extraction du % de satisfaits (baromètres Léger); (c) presse élargie
102 au-delà des RSS. Toutes les pages brutes sont archivées (`data/raw/firecrawl/`). 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 **Décomposition** : à chaque run, le forecast est reconstruit couche par couche 124 **Décomposition** : à chaque run, le forecast est reconstruit couche par couche
105 (sondages seuls → + partielles → + fondamentaux → + médias) avec 8 000 simulations par 125 (sondages seuls → + partielles → + fondamentaux → + médias) avec 8 000 simulations par
106 variante; les Δ de vote et de probabilité de chaque couche sont publiés. 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 return {"id": row.id, "status": "enregistré"} 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 @router.get("/logs", dependencies=[Depends(require_admin)]) 184 @router.get("/logs", dependencies=[Depends(require_admin)])
164 def logs(db: Session = Depends(get_db), limit: int = 100): 185 def logs(db: Session = Depends(get_db), limit: int = 100):
165 rows = (db.query(Mo.PipelineLog).order_by(Mo.PipelineLog.at.desc()).limit(limit).all()) 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 for s in rows]} 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 @router.get("/polls") 317 @router.get("/polls")
223 def polls(db: Session = Depends(get_db), 318 def polls(db: Session = Depends(get_db),
224 date_from: date | None = None, date_to: date | None = None, 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 class Settings(BaseSettings): 36 class Settings(BaseSettings):
37 # --- Général --- 37 # --- Général ---
38 app_name: str = "QC Élection Forecast" 38 app_name: str = "QC Élection Forecast"
39− model_version: str = "2.0.0" 39+ model_version: str = "2.1.0"
40 database_url: str = f"sqlite:///{BASE_DIR / 'qc_election.db'}" 40 database_url: str = f"sqlite:///{BASE_DIR / 'qc_election.db'}"
41 admin_token: str = os.environ.get("ADMIN_TOKEN", "change-me") 41 admin_token: str = os.environ.get("ADMIN_TOKEN", "change-me")
42 port: int = 8140 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 return {"status": "ok", "app": settings.app_name, "version": settings.model_version} 78 return {"status": "ok", "app": settings.app_name, "version": settings.model_version}
79 79
80 80
81−PAGES = ["index", "carte", "sondages", "signaux", "simulateur", "intelligence", 81+PAGES = ["index", "aujourdhui", "carte", "sondages", "signaux", "simulateur",
82− "methodologie", "backtest", "admin"] 82+ "intelligence", "methodologie", "backtest", "admin"]
83 for page in PAGES: 83 for page in PAGES:
84 path = "/" if page == "index" else f"/{page}" 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 def forecast(trend: TrendResult, as_of: date, election_day: date, 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 rng = rng or np.random.default_rng(2) 65 rng = rng or np.random.default_rng(2)
63 days = max(0, (election_day - as_of).days) 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 # + erreur systémique de sondage (corrélée entre partis via la composition) : 68 # + erreur systémique de sondage (corrélée entre partis via la composition) :
66 # même la veille du vote, l'industrie entière peut se tromper de ~2-3 pp. 69 # même la veille du vote, l'industrie entière peut se tromper de ~2-3 pp.
67 P_f = trend.P + (drift_var + settings.industry_error_sd ** 2) * np.eye(M) 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 if trend is None: 116 if trend is None:
117 raise RuntimeError("Pas assez de sondages pour ajuster le modèle") 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 now_d = nc(trend, as_of) 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 # --- couche 3 : prior de fondamentaux (poids décroissant vers le scrutin) --- 133 # --- couche 3 : prior de fondamentaux (poids décroissant vers le scrutin) ---
123 result_2022 = (db.query(Mo.Election) 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 s = SIM.run_simulation(inp, n_sims=8000, 162 s = SIM.run_simulation(inp, n_sims=8000,
152 rng=np.random.default_rng(seed)) 163 rng=np.random.default_rng(seed))
153 return {p: s.seats[p]["prob_most"] for p in settings.parties} 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 variants = [ 166 variants = [
156 ("sondages", "Sondages seuls (modèle v1)", fc_a.x, fc_a.P), 167 ("sondages", "Sondages seuls (modèle v1)", fc_a.x, fc_a.P),
157 ("partielles", "+ votes réels des partielles", fc_b.x, fc_b.P), 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 beyond = { 219 beyond = {
209 "decomposition": decomposition, 220 "decomposition": decomposition,
221+ "web_attention": {k: v for k, v in attention.items() if k != "sparkline_dates"},
210 "fundamentals": fund_diag, 222 "fundamentals": fund_diag,
211 "byelections_used": [{"pollster": b["pollster"], 223 "byelections_used": [{"pollster": b["pollster"],
212 "field_end": b["field_end"].isoformat(), 224 "field_end": b["field_end"].isoformat(),
@@ -293,6 +305,21 @@ def run_pipeline(full_refresh: bool = True) -> dict:
293 except Exception as e: 305 except Exception as e:
294 report["veille"] = f"échec: {e}" 306 report["veille"] = f"échec: {e}"
295 _log(db, "veille-firecrawl", "error", traceback.format_exc()) 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 try: 323 try:
297 run = run_forecast(db) 324 run = run_forecast(db)
298 report["forecast_run_id"] = run.id 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 "88/12 avec Polymarket sur P(plus de sièges), veille web continue " 149 "88/12 avec Polymarket sur P(plus de sièges), veille web continue "
150 "Firecrawl (radar sondages + satisfaction + presse élargie), " 150 "Firecrawl (radar sondages + satisfaction + presse élargie), "
151 "décomposition du forecast par couche, backtest comparatif v2."))) 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 # Sources 161 # Sources
153 for name, url, kind in [ 162 for name, url, kind in [
154 ("Wikipédia — sondages 2026", "https://en.wikipedia.org/wiki/2026_Quebec_general_election", "polls"), 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
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
modified frontend/assets/app.js +2 −2
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added frontend/aujourdhui.html +251 −0
Large diff (251 lines) is not loaded by default to keep the page fast.Load diff
modified frontend/methodologie.html +11 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme