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%
8.6 KB · 193 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"""Sondage synthétique LLM (« silicon sampling ») — signal EXPÉRIMENTAL, poids nul.67Méthode (littérature 2024-2025 : Argyle et al.; arXiv:2411.01582; critique de8McKown-Dawson/Silver — « les sondages IA ne sont pas des sondages ») :910  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 un13     électeur type de cette strate, ancré dans l'actualité RÉELLE de la campagne14     (titres des 14 derniers jours issus de notre propre veille — retrieval-15     augmented, jamais de chiffres de sondage dans le prompt), et retourne une16     distribution de probabilités de vote.17  3. **Poststratification** : moyenne des distributions pondérée par la18     population de chaque strate (MRP-lite).1920**Statut : expérimental, poids STRICTEMENT NUL dans le forecast.** Un LLM ne21collecte aucune donnée nouvelle : c'est un modèle, pas un échantillon. On publie22l'écart au forecast comme objet d'étude (le signal sera évalué après le 5 octobre),23avec provenance complète (modèle, date, prompts archivés).24"""25from __future__ import annotations2627import json28import logging29import re30from datetime import date, datetime, timedelta, timezone3132import httpx33import numpy as np34from sqlalchemy.orm import Session3536from ..config import DATA_DIR, settings37from .. import models as Mo3839log = logging.getLogger("synthetic-poll")4041OUT_DIR = DATA_DIR / "synthetic_polls"42INDICATOR = "synthetic_poll"43REFRESH_DAYS = 6          # au plus un sondage synthétique par ~semaine4445# Strates : (région groupée, poids pop., âge, poids âge, genre, langue, poids langue)46REGIONS = [("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)]50AGES = [("18-34 ans", 0.26), ("35-54 ans", 0.33), ("55 ans et plus", 0.41)]51GENDERS = [("femme", 0.51), ("homme", 0.49)]52LANGS = [("francophone", 0.82), ("anglophone ou allophone", 0.18)]5354PROMPT = """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}5960Contexte : élection générale du Québec le 5 octobre 2026. La campagne est en cours.61Actualité récente de la campagne (titres réels des derniers jours) :62{headlines}6364Partis : CAQ (Coalition avenir Québec, Christine Fréchette, sortant après 2 mandats),65PLQ (Parti libéral, Charles Milliard), PQ (Parti québécois, Paul St-Pierre Plamondon),66QS (Québec solidaire, Ruba Ghazal et Sol Zanetti), PCQ (Parti conservateur, Éric Duhaime).6768En te basant sur ce que voterait TYPIQUEMENT l'ensemble des électeurs de cette strate69(pas un individu), donne la répartition probable de leur vote.70Réponds UNIQUEMENT en JSON : {{"CAQ": x, "PLQ": x, "PQ": x, "QS": x, "PCQ": x, "AUT": x}}71(pourcentages, somme = 100)."""727374def _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 None78    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 None89    except Exception as e:90        log.warning("llm strate: %s", e)91        return None929394def 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.url102        if k in seen:103            continue104        seen.add(k)105        titles.append(d.title.strip())106        if len(titles) >= n:107            break108    return titles109110111def 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 = 1120121122def 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.0129        for p in settings.parties:130            agg[p] += c["weight"] / tot_w * max(0.0, float(d.get(p, 0.0))) * 100.0 / s131    return {p: round(v, 1) for p, v in agg.items()}132133134def 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)141142143def 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 = method159    row.extra = extra160    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        pass169170171def 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 = [], 0180    for st in strata():181        d = _llm(PROMPT.format(headlines=head_txt, **st))182        if d is None:183            failed += 1184            if failed >= 5 and not cells:      # endpoint mort → abandon propre185                return {"skipped": "LLM indisponible — sondage synthétique reporté"}186            continue187        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}193