spb/qc-election
Public
Python 66.6%
HTML 24.8%
CSS 4.9%
JavaScript 3.6%
1# QC Élection Forecast — Plateforme de prévision électorale du Québec 20262# Auteur : Simon-Pierre Boucher3# Contact : contact@spboucher.ai4# https://www.qc-election.com5"""Chargement des CSV normalisés (data/) vers la base — idempotent (upsert)."""6from __future__ import annotations78from datetime import date, datetime910import pandas as pd11from sqlalchemy.orm import Session1213from .config import DATA_DIR, settings14from .db import SessionLocal, init_db15from . import models as Mo1617PARTY_COLORS = { # teintes usuelles des partis, ajustées et validées (CVD, contraste)18 "CAQ": "#0097cf", "PLQ": "#e5484d", "PQ": "#3f7ad6",19 "QS": "#e06c1f", "PCQ": "#7069d6", "AUT": "#ad8226",20}21LEADERS = {22 "CAQ": "Christine Fréchette", "PLQ": "Charles Milliard",23 "PQ": "Paul St-Pierre Plamondon", "QS": "Ruba Ghazal et Sol Zanetti",24 "PCQ": "Éric Duhaime", "AUT": None,25}26RESULT_2022 = {"CAQ": 40.98, "PLQ": 14.37, "QS": 15.43, "PQ": 14.61, "PCQ": 12.91, "AUT": 1.70}27RESULT_2018 = {"CAQ": 37.42, "PLQ": 24.82, "QS": 16.10, "PQ": 17.06, "PCQ": 1.46, "AUT": 3.14}2829# Dates officielles — Élections Québec (electionsquebec.qc.ca)30KEY_EVENTS = [31 (date(2026, 8, 27), "Début de la période de campagne (préparatifs)", "campagne", 0.7),32 (date(2026, 8, 29), "Déclenchement officiel de l'élection (décret prévu)", "campagne", 0.9),33 (date(2026, 9, 16), "Date limite — inscription au vote hors Québec", "échéance", 0.5),34 (date(2026, 9, 25), "Début du vote par anticipation et du vote au bureau du directeur du scrutin (jusqu'au 1er oct.)", "vote", 0.8),35 (date(2026, 10, 5), "Jour du scrutin — 127 circonscriptions, ~6,8 M d'électeurs admissibles", "élection", 1.0),36]373839def get_or_create(db: Session, model, defaults=None, **kw):40 obj = db.query(model).filter_by(**kw).first()41 if obj:42 return obj, False43 obj = model(**kw, **(defaults or {}))44 db.add(obj)45 db.flush()46 return obj, True474849def seed_all(db: Session | None = None) -> dict:50 init_db()51 own = db is None52 db = db or SessionLocal()53 stats = {}54 try:55 # Élections56 el26, _ = get_or_create(db, Mo.Election, name="Élection générale du Québec 2026",57 defaults=dict(election_date=settings.election_date,58 total_seats=127, majority_seats=64, is_target=True))59 el22, _ = get_or_create(db, Mo.Election, name="Élection générale du Québec 2022",60 defaults=dict(election_date=date(2022, 10, 3), total_seats=125,61 majority_seats=63, actual_result=RESULT_2022))62 el18, _ = get_or_create(db, Mo.Election, name="Élection générale du Québec 2018",63 defaults=dict(election_date=date(2018, 10, 1), total_seats=125,64 majority_seats=63, actual_result=RESULT_2018))65 # Partis (mise à jour des chefs/couleurs si déjà présents)66 for code in settings.parties:67 party, _ = get_or_create(db, Mo.Party, code=code,68 defaults=dict(name=settings.party_names[code]))69 party.name = settings.party_names[code]70 party.leader = LEADERS.get(code)71 party.color = PARTY_COLORS.get(code)72 # Circonscriptions73 ddf = pd.read_csv(DATA_DIR / "districts_2026.csv")74 for _, r in ddf.iterrows():75 base = {p: float(r[f"b_{p}"]) for p in settings.parties}76 d, created = get_or_create(db, Mo.District, name=r["district"],77 defaults=dict(region=r["region"], baseline_shares=base))78 d.region = r["region"]; d.baseline_shares = base79 d.baseline_source = r["baseline_source"]80 d.incumbent_party = None if pd.isna(r["incumbent_party"]) else r["incumbent_party"]81 d.incumbent_running = bool(r["incumbent_running"])82 d.turnout_2022 = None if pd.isna(r["turnout_2022"]) else float(r["turnout_2022"])83 d.is_new_2026 = bool(r["is_new_2026"])84 stats["districts"] = len(ddf)8586 # Résultats historiques par circonscription (2022 et 2018 reconstruit)87 rdf = pd.read_csv(DATA_DIR / "riding_results_2022.csv")88 for _, r in rdf.iterrows():89 for p in settings.parties:90 if p in r and not pd.isna(r[p]):91 obj, _ = get_or_create(db, Mo.HistoricalDistrictResult,92 election_id=el22.id, district_name=r["district"],93 party=p, defaults=dict(pct=float(r[p])))94 obj.pct = float(r[p])9596 # Purge des doublons hérités de l'ancienne clé (maison, fin, taille) :97 # un même sondage réingéré quand Wikipédia complétait sa taille.98 n_dupes = 099 seen_polls: dict[tuple, Mo.Poll] = {}100 for poll in db.query(Mo.Poll).order_by(Mo.Poll.id.asc()).all():101 key = (poll.pollster_id, poll.field_end, poll.election_id)102 kept = seen_polls.get(key)103 if kept is None:104 seen_polls[key] = poll105 else: # garder la version la plus complète (taille connue, + de partis)106 a = (kept.sample_size is not None, len(kept.results), -kept.id)107 b = (poll.sample_size is not None, len(poll.results), -poll.id)108 if b > a:109 seen_polls[key], poll = poll, kept110 db.delete(poll)111 n_dupes += 1112 # même maison, même taille d'échantillon, fins de terrain à ±1 jour :113 # même sondage daté différemment par deux agrégateurs (EN vs FR)114 by_house: dict[tuple, list] = {}115 for poll in db.query(Mo.Poll).order_by(Mo.Poll.field_end.asc()).all():116 if poll.sample_size:117 by_house.setdefault((poll.pollster_id, poll.election_id,118 poll.sample_size), []).append(poll)119 for group in by_house.values():120 for a, b in zip(group, group[1:]):121 if abs((b.field_end - a.field_end).days) <= 1 and a in db:122 db.delete(a) # garder la date la plus récente (FR corrige)123 n_dupes += 1124 if n_dupes:125 db.flush()126 stats["dupes_purgés"] = n_dupes127128 # Sondages129 n_polls, n_updated = 0, 0130 for fname, el in (("polls_2026.csv", el26), ("polls_2022.csv", el22)):131 f = DATA_DIR / fname132 if not f.exists():133 continue134 pdf = pd.read_csv(f)135 for (pollster, fe), grp in pdf.groupby(["pollster", "field_end"]):136 # dédoublonnage : le même sondage peut être publié dans plusieurs137 # tables/sites (clé = maison + fin de terrain + parti)138 grp = grp.drop_duplicates(subset="party", keep="first")139 pol, _ = get_or_create(db, Mo.Pollster, name=pollster)140 first = grp.iloc[0]141 ss = None if pd.isna(first["sample_size"]) else int(first["sample_size"])142 # correspondance par (maison, fin de terrain ±1 j) SANS la143 # taille : Wikipédia complète n/MoE après coup, et les144 # agrégateurs EN/FR datent parfois le même sondage à ±1 jour.145 fe_d = date.fromisoformat(fe)146 from datetime import timedelta as _td147 poll = (db.query(Mo.Poll)148 .filter(Mo.Poll.pollster_id == pol.id,149 Mo.Poll.field_end >= fe_d - _td(days=1),150 Mo.Poll.field_end <= fe_d + _td(days=1))151 .first())152 if poll is None:153 poll = Mo.Poll(154 election_id=el.id, pollster_id=pol.id,155 field_start=date.fromisoformat(first["field_start"]),156 field_end=date.fromisoformat(fe), sample_size=ss,157 population=first["population"],158 region=first["region"], source_name=first["source_name"],159 accessed_at=datetime.fromisoformat(first["accessed_at"]))160 db.add(poll); db.flush()161 n_polls += 1162 # métadonnées raffinées à chaque passage (n, MoE, mode, indécis, URL)163 if ss is not None:164 poll.sample_size = ss165 if not pd.isna(first["moe"]):166 poll.moe = float(first["moe"])167 if "undecided" in first and not pd.isna(first.get("undecided")):168 poll.undecided = float(first["undecided"])169 if first["mode"] and str(first["mode"]) != "nan" and (170 poll.mode in (None, "unknown") or poll.mode != first["mode"]):171 poll.mode = first["mode"]172 if not pd.isna(first["source_url"]):173 poll.source_url = first["source_url"]174 # résultats : création OU correction si Wikipédia a révisé un chiffre175 total = grp["raw_value"].sum()176 by_party = {pr.party: pr for pr in poll.results}177 changed = False178 for _, row in grp.iterrows():179 raw = float(row["raw_value"])180 normalized = round(raw * 100.0 / total, 3)181 pr = by_party.get(row["party"])182 if pr is None:183 poll.results.append(Mo.PollResult(184 party=row["party"], raw_value=raw,185 normalized_value=normalized))186 elif abs(pr.raw_value - raw) > 1e-9:187 pr.raw_value = raw188 pr.normalized_value = normalized189 changed = True190 if changed:191 n_updated += 1192 stats["new_polls"] = n_polls193 stats["updated_polls"] = n_updated194195 # Événements clés196 for d, title, kind, imp in KEY_EVENTS:197 get_or_create(db, Mo.NewsEvent, event_date=d, title=title,198 defaults=dict(kind=kind, importance=imp, detected_by="seed"))199 # Version du modèle200 get_or_create(db, Mo.ModelVersion, version="1.0.0",201 defaults=dict(changelog="Modèle initial : DLM Kalman (alr), "202 "house effects 2022, Monte Carlo corrélé."))203 get_or_create(db, Mo.ModelVersion, version="2.0.0",204 defaults=dict(changelog=(205 "« Au-delà des sondages » : votes réels des partielles injectés "206 "dans le filtre (swing rétréci ×0,6), prior de fondamentaux "207 "(satisfaction/vote précédent/usure, régression 1998-2022, poids "208 "décroissant w(t)), ajustement médias borné ±0,35 pp, ensemble "209 "88/12 avec Polymarket sur P(plus de sièges), veille web continue "210 "Firecrawl (radar sondages + satisfaction + presse élargie), "211 "décomposition du forecast par couche, backtest comparatif v2.")))212 get_or_create(db, Mo.ModelVersion, version="2.1.0",213 defaults=dict(changelog=(214 "Signaux web avancés : attention Wikipédia (pageviews partis+chefs, "215 "littérature Smith & Gustafson 2017) → détecteur de turbulence qui "216 "gonfle la variance de campagne (×≤1,45, jamais la moyenne); sondage "217 "synthétique LLM (« silicon sampling », 96 strates poststratifiées, "218 "ancré dans l'actualité réelle) — EXPÉRIMENTAL, poids nul; page "219 "quotidienne « Le point du jour » (/aujourdhui, API /api/today) : "220 "forecast daté, Δ vs veille, journal 24 h.")))221 get_or_create(db, Mo.ModelVersion, version="3.0.0",222 defaults=dict(changelog=(223 "v3 « 127 » Phases A+B : cadre de validation (scoring, ablation "224 "par variante, flags conservateurs), Social Intelligence Layer "225 "(acteur Apify maison multi-plateformes + Reddit/Mastodon/Lemmy, "226 "métriques normalisées attention_z/velocity_z/stance, filtrage de "227 "coordination, observabilité apify_runs), volatilité de campagne "228 "unifiée (wiki + social, bornée ≤1,5, variance seulement), Event "229 "Engine à confirmation croisée, circonscriptions pivots de la "230 "majorité (méthode des simulations), page /circonscriptions "231 "(forecast par comté), API v3 (/api/v3/*), data-health admin.")))232 get_or_create(db, Mo.ModelVersion, version="3.1.0",233 defaults=dict(changelog=(234 "Méthodologie ultra avancée — première vague : REPLAY HISTORIQUE "235 "multi-élections (2007/2008/2012/2014/2018/2022, 462 sondages, "236 "LOEO strict : house effects, fondamentaux et σ appris sur les "237 "autres élections; rolling origin J-120→J-1; CRPS + log score); "238 "modèle d'erreur des sondeurs (erreur d'industrie par élection, "239 "house effects hiérarchiques rétrécis n/(n+k)); σ_industrie "240 "APPRIS (LOEO ≈0,21 vs 0,12 manuel) adopté après validation : "241 "log score −3,80 vs −5,33, CRPS 2,64 vs 2,67, couverture 95 % "242 "83 % vs 76,5 %, EAM inchangée. Cœur alr paramétré par ères de "243 "partis (ADQ 2007-2008). Audits : METHODOLOGY_AUDIT, "244 "STATISTICAL_DEBT, V3_MODEL_ARCHITECTURE, HISTORICAL_REPLAY_PLAN.")))245 get_or_create(db, Mo.ModelVersion, version="3.2.0",246 defaults=dict(changelog=(247 "Release produit (distribution du forecast INCHANGÉE) : feature "248 "store démographique officiel (classeur Élections Québec, "249 "Recensement 2021 × 127 circ. 2026, similarité top-10), modèle du "250 "soir d'élection (mise à jour bayésienne hiérarchique, calling "251 "conservateur, dormant), scénarios partageables par URL, fiches "252 "enrichies (sensibilité au swing, chemin de bascule, démographie), "253 "baselines dans le replay, Course à 64, connecteurs primaires 7 "254 "firmes avec file de révision, favicon + og:image, feuille de "255 "route Compas électoral (COMPAS-PROGRAMMES.md).")))256 # Sources257 for name, url, kind in [258 ("Wikipédia — sondages 2026", "https://en.wikipedia.org/wiki/2026_Quebec_general_election", "polls"),259 ("Wikipédia — élection 2022", "https://en.wikipedia.org/wiki/2022_Quebec_general_election", "results"),260 ]:261 get_or_create(db, Mo.DataSource, name=name, defaults=dict(url=url, kind=kind))262 db.commit()263 stats["polls_total"] = db.query(Mo.Poll).count()264 return stats265 finally:266 if own:267 db.close()268269270if __name__ == "__main__":271 print(seed_all())272