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%
9.8 KB · 225 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"""Simulateur Monte Carlo de l'élection — erreurs corrélées en couches.67Chaque simulation tire :8  1. le vote national (posterior du forecast) — erreur GLOBALE partagée;9  2. une erreur RÉGIONALE par région de modélisation (partagée par les10     circonscriptions de la région — partial pooling implicite);11  3. une erreur PROPRE à chaque circonscription.1213Le vote d'une circonscription = softmax( alr(référence 2022 locale)14        + swing national (alr) + ε_région + ε_circonscription − pénalités ).15Swing proportionnel en espace log-ratio (pas de swing uniforme naïf).16"""17from __future__ import annotations1819from dataclasses import dataclass2021import numpy as np2223from ..config import settings24from .compositions import alr, close, inv_alr25from .trend import M2627CATEGORY_BOUNDS = [(0.95, "Solide"), (0.80, "Probable"), (0.60, "Favorisé")]282930def category_for(p: float) -> str:31    for bound, label in CATEGORY_BOUNDS:32        if p >= bound:33            return label34    return "Chaudement disputé"353637@dataclass38class SimulationInput:39    x_mean: np.ndarray            # état national (5,)40    P: np.ndarray                 # covariance nationale (5,5)41    baseline_national: np.ndarray  # parts nationales élection de référence (6,)42    district_names: list[str]43    district_baselines: np.ndarray  # (D, 6) parts locales élection de référence44    district_regions: list[str]45    retirement_flags: np.ndarray    # (D,) 1 si le sortant ne se représente pas46    incumbent_party_idx: np.ndarray  # (D,) index parti sortant, -1 si aucun47    majority_seats: int48    n_sims: int = 0495051@dataclass52class SimulationResult:53    n_sims: int54    seats: dict          # {party: {mean, p05..p95, hist, prob_most, prob_majority, prob_minority_win}}55    national_vote: dict  # {party: mean} moyenne des tirages56    districts: list      # [{district, region, favorite, category, win_probs, expected}]57    seat_matrix_summary: dict58    raw_winners: np.ndarray | None = None   # (S,D) indices — pour analyses pivots59    raw_shares: np.ndarray | None = None    # (S,D,K)606162def tipping_points(winners: np.ndarray, shares: np.ndarray,63                   district_names: list[str], majority: int,64                   parties: list[str]) -> dict:65    """Circonscriptions pivots (v3 « 127 », §24) — méthode des simulations :66    dans chaque simulation où le parti de tête atteint la majorité, on classe67    SES circonscriptions gagnées par marge décroissante; la `majority`-ième est68    LA circonscription qui fait basculer la majorité. P(pivot) = fréquence."""69    S, D = winners.shape70    # marge du vainqueur dans chaque (sim, circ.)71    part = np.partition(shares, -2, axis=-1)72    margins = part[..., -1] - part[..., -2]                    # (S,D)73    seat_counts = np.zeros((S, len(parties)), dtype=np.int32)74    for k in range(len(parties)):75        seat_counts[:, k] = (winners == k).sum(axis=1)76    top = seat_counts.argmax(axis=1)77    maj_tips = np.zeros(D)78    n_maj = 079    for s in range(S):80        k = top[s]81        if seat_counts[s, k] < majority:82            continue83        n_maj += 184        won = np.flatnonzero(winners[s] == k)85        order = won[np.argsort(-margins[s, won])]86        maj_tips[order[majority - 1]] += 187    out = []88    if n_maj:89        for d in np.argsort(-maj_tips)[:20]:90            if maj_tips[d] == 0:91                break92            out.append({"district": district_names[d],93                        "prob_majority_tipping": round(float(maj_tips[d] / n_maj), 4)})94    return {"majority_tipping": out,95            "n_sims_with_majority": int(n_maj), "n_sims": int(S)}969798def _scenario_alr_delta(base_shares: np.ndarray, pp_delta: dict[str, float]) -> np.ndarray:99    """Delta en pp sur les parts → delta additif en espace alr."""100    p0 = close(base_shares)101    p1 = p0.copy()102    for party, dpp in pp_delta.items():103        if party in settings.parties:104            p1[settings.parties.index(party)] += dpp / 100.0105    p1 = close(np.clip(p1, 0.001, None))106    return alr(p1) - alr(p0)107108109def run_simulation(inp: SimulationInput, n_sims: int | None = None,110                   scenario: dict | None = None,111                   rng: np.random.Generator | None = None,112                   keep_raw: bool = False) -> SimulationResult:113    """scenario (optionnel) :114      national_pp   : {party: ±pp}                — choc national115      regional_pp   : {region: {party: ±pp}}      — choc régional116      turnout_mult  : {party: facteur}            — participation différentielle117      poll_error_pp : {party: ±pp}                — les sondages se trompent de X118    """119    rng = rng or np.random.default_rng(20261005)120    S = n_sims or settings.n_simulations121    D = len(inp.district_names)122    K = M + 1123    scenario = scenario or {}124125    x_mean = inp.x_mean.copy()126    for key in ("national_pp", "poll_error_pp"):127        if scenario.get(key):128            x_mean = x_mean + _scenario_alr_delta(inv_alr(inp.x_mean), scenario[key])129130    # 1. erreur globale : tirages du vote national131    L = np.linalg.cholesky(inp.P + 1e-10 * np.eye(M))132    x_nat = x_mean + rng.standard_normal((S, M)).astype(np.float64) @ L.T   # (S,5)133    nat_shares = inv_alr(x_nat)                                             # (S,6)134135    # swing national en alr par rapport à l'élection de référence136    delta_nat = x_nat - alr(inp.baseline_national)                          # (S,5)137138    # 2. erreurs régionales corrélées139    regions = sorted(set(inp.district_regions))140    reg_idx = np.array([regions.index(r) for r in inp.district_regions])141    eps_reg = rng.standard_normal((S, len(regions), M)) * settings.regional_error_sd142143    # chocs régionaux de scénario144    reg_scenario = np.zeros((len(regions), M))145    for region, pp in (scenario.get("regional_pp") or {}).items():146        if region in regions:147            mask = np.array([r == region for r in inp.district_regions])148            base_reg = close(inp.district_baselines[mask].mean(axis=0))149            reg_scenario[regions.index(region)] = _scenario_alr_delta(base_reg, pp)150151    # 3. erreurs propres aux circonscriptions152    eps_riding = (rng.standard_normal((S, D, M)) * settings.riding_error_sd).astype(np.float32)153154    base_alr = alr(inp.district_baselines)                                  # (D,5)155156    # pénalité de retraite du sortant (appliquée au parti sortant, espace alr)157    retire_pen = np.zeros((D, M))158    for d in range(D):159        pi = inp.incumbent_party_idx[d]160        if inp.retirement_flags[d] and 0 <= pi < K:161            if pi == 0:  # parti de référence alr : pénaliser = bonifier les autres162                retire_pen[d, :] += settings.incumbent_retirement_penalty163            else:164                retire_pen[d, pi - 1] -= settings.incumbent_retirement_penalty165166    y = (base_alr[None, :, :] + retire_pen[None, :, :]167         + delta_nat[:, None, :] + eps_reg[:, reg_idx, :]168         + reg_scenario[None, reg_idx, :]169         + eps_riding)                                                       # (S,D,5)170    shares = inv_alr(y)                                                      # (S,D,6)171172    if scenario.get("turnout_mult"):173        mult = np.array([scenario["turnout_mult"].get(p, 1.0) for p in settings.parties])174        shares = shares * mult[None, None, :]175        shares = shares / shares.sum(axis=-1, keepdims=True)176177    winners = shares.argmax(axis=-1)                                         # (S,D)178    seat_counts = np.zeros((S, K), dtype=np.int32)179    for k in range(K):180        seat_counts[:, k] = (winners == k).sum(axis=1)181182    most = seat_counts.argmax(axis=1)                                        # (S,)183    seats = {}184    for k, party in enumerate(settings.parties):185        sc = seat_counts[:, k]186        hist = np.bincount(sc, minlength=D + 1)187        seats[party] = {188            "mean": round(float(sc.mean()), 1),189            "p05": int(np.percentile(sc, 5)), "p25": int(np.percentile(sc, 25)),190            "p50": int(np.percentile(sc, 50)), "p75": int(np.percentile(sc, 75)),191            "p95": int(np.percentile(sc, 95)),192            "prob_most": round(float((most == k).mean()), 4),193            "prob_majority": round(float((sc >= inp.majority_seats).mean()), 4),194            "prob_minority_win": round(float(((most == k) & (sc < inp.majority_seats)).mean()), 4),195            "hist": {str(i): int(c) for i, c in enumerate(hist) if c > 0},196        }197198    districts = []199    for d in range(D):200        wp = {settings.parties[k]: round(float((winners[:, d] == k).mean()), 4)201              for k in range(K)}202        exp = {settings.parties[k]: round(float(shares[:, d, k].mean() * 100), 2)203               for k in range(K)}204        lo = {settings.parties[k]: round(float(np.percentile(shares[:, d, k], 2.5) * 100), 2)205              for k in range(K)}206        hi = {settings.parties[k]: round(float(np.percentile(shares[:, d, k], 97.5) * 100), 2)207              for k in range(K)}208        fav = max(wp, key=wp.get)209        districts.append({210            "district": inp.district_names[d], "region": inp.district_regions[d],211            "favorite": fav, "category": category_for(wp[fav]),212            "win_probs": wp, "expected": exp, "lo95": lo, "hi95": hi,213        })214215    nat = {settings.parties[k]: round(float(nat_shares[:, k].mean() * 100), 2)216           for k in range(K)}217    hung = float((seat_counts.max(axis=1) < inp.majority_seats).mean())218    return SimulationResult(219        n_sims=S, seats=seats, national_vote=nat, districts=districts,220        seat_matrix_summary={"prob_no_majority": round(hung, 4),221                             "majority_threshold": inp.majority_seats,222                             "total_seats": D},223        raw_winners=winners if keep_raw else None,224        raw_shares=shares if keep_raw else None)225