SPB Git forge

spb/qc-election

Public
20commits 1branches 0releases
4.9 MBsize
maindefault branch
20 days agolast push
Python 66.6% HTML 24.8% CSS 4.9% JavaScript 3.6%
15.4 KB · 272 lines python
Raw Blame History
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