# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026 # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # https://www.qc-election.com """Simulateur Monte Carlo de l'élection — erreurs corrélées en couches. Chaque simulation tire : 1. le vote national (posterior du forecast) — erreur GLOBALE partagée; 2. une erreur RÉGIONALE par région de modélisation (partagée par les circonscriptions de la région — partial pooling implicite); 3. une erreur PROPRE à chaque circonscription. Le vote d'une circonscription = softmax( alr(référence 2022 locale) + swing national (alr) + ε_région + ε_circonscription − pénalités ). Swing proportionnel en espace log-ratio (pas de swing uniforme naïf). """ from __future__ import annotations from dataclasses import dataclass import numpy as np from ..config import settings from .compositions import alr, close, inv_alr from .trend import M CATEGORY_BOUNDS = [(0.95, "Solide"), (0.80, "Probable"), (0.60, "Favorisé")] def category_for(p: float) -> str: for bound, label in CATEGORY_BOUNDS: if p >= bound: return label return "Chaudement disputé" @dataclass class SimulationInput: x_mean: np.ndarray # état national (5,) P: np.ndarray # covariance nationale (5,5) baseline_national: np.ndarray # parts nationales élection de référence (6,) district_names: list[str] district_baselines: np.ndarray # (D, 6) parts locales élection de référence district_regions: list[str] retirement_flags: np.ndarray # (D,) 1 si le sortant ne se représente pas incumbent_party_idx: np.ndarray # (D,) index parti sortant, -1 si aucun majority_seats: int n_sims: int = 0 @dataclass class SimulationResult: n_sims: int seats: dict # {party: {mean, p05..p95, hist, prob_most, prob_majority, prob_minority_win}} national_vote: dict # {party: mean} moyenne des tirages districts: list # [{district, region, favorite, category, win_probs, expected}] seat_matrix_summary: dict raw_winners: np.ndarray | None = None # (S,D) indices — pour analyses pivots raw_shares: np.ndarray | None = None # (S,D,K) def tipping_points(winners: np.ndarray, shares: np.ndarray, district_names: list[str], majority: int, parties: list[str]) -> dict: """Circonscriptions pivots (v3 « 127 », §24) — méthode des simulations : dans chaque simulation où le parti de tête atteint la majorité, on classe SES circonscriptions gagnées par marge décroissante; la `majority`-ième est LA circonscription qui fait basculer la majorité. P(pivot) = fréquence.""" S, D = winners.shape # marge du vainqueur dans chaque (sim, circ.) part = np.partition(shares, -2, axis=-1) margins = part[..., -1] - part[..., -2] # (S,D) seat_counts = np.zeros((S, len(parties)), dtype=np.int32) for k in range(len(parties)): seat_counts[:, k] = (winners == k).sum(axis=1) top = seat_counts.argmax(axis=1) maj_tips = np.zeros(D) n_maj = 0 for s in range(S): k = top[s] if seat_counts[s, k] < majority: continue n_maj += 1 won = np.flatnonzero(winners[s] == k) order = won[np.argsort(-margins[s, won])] maj_tips[order[majority - 1]] += 1 out = [] if n_maj: for d in np.argsort(-maj_tips)[:20]: if maj_tips[d] == 0: break out.append({"district": district_names[d], "prob_majority_tipping": round(float(maj_tips[d] / n_maj), 4)}) return {"majority_tipping": out, "n_sims_with_majority": int(n_maj), "n_sims": int(S)} def _scenario_alr_delta(base_shares: np.ndarray, pp_delta: dict[str, float]) -> np.ndarray: """Delta en pp sur les parts → delta additif en espace alr.""" p0 = close(base_shares) p1 = p0.copy() for party, dpp in pp_delta.items(): if party in settings.parties: p1[settings.parties.index(party)] += dpp / 100.0 p1 = close(np.clip(p1, 0.001, None)) return alr(p1) - alr(p0) def run_simulation(inp: SimulationInput, n_sims: int | None = None, scenario: dict | None = None, rng: np.random.Generator | None = None, keep_raw: bool = False) -> SimulationResult: """scenario (optionnel) : national_pp : {party: ±pp} — choc national regional_pp : {region: {party: ±pp}} — choc régional turnout_mult : {party: facteur} — participation différentielle poll_error_pp : {party: ±pp} — les sondages se trompent de X """ rng = rng or np.random.default_rng(20261005) S = n_sims or settings.n_simulations D = len(inp.district_names) K = M + 1 scenario = scenario or {} x_mean = inp.x_mean.copy() for key in ("national_pp", "poll_error_pp"): if scenario.get(key): x_mean = x_mean + _scenario_alr_delta(inv_alr(inp.x_mean), scenario[key]) # 1. erreur globale : tirages du vote national L = np.linalg.cholesky(inp.P + 1e-10 * np.eye(M)) x_nat = x_mean + rng.standard_normal((S, M)).astype(np.float64) @ L.T # (S,5) nat_shares = inv_alr(x_nat) # (S,6) # swing national en alr par rapport à l'élection de référence delta_nat = x_nat - alr(inp.baseline_national) # (S,5) # 2. erreurs régionales corrélées regions = sorted(set(inp.district_regions)) reg_idx = np.array([regions.index(r) for r in inp.district_regions]) eps_reg = rng.standard_normal((S, len(regions), M)) * settings.regional_error_sd # chocs régionaux de scénario reg_scenario = np.zeros((len(regions), M)) for region, pp in (scenario.get("regional_pp") or {}).items(): if region in regions: mask = np.array([r == region for r in inp.district_regions]) base_reg = close(inp.district_baselines[mask].mean(axis=0)) reg_scenario[regions.index(region)] = _scenario_alr_delta(base_reg, pp) # 3. erreurs propres aux circonscriptions eps_riding = (rng.standard_normal((S, D, M)) * settings.riding_error_sd).astype(np.float32) base_alr = alr(inp.district_baselines) # (D,5) # pénalité de retraite du sortant (appliquée au parti sortant, espace alr) retire_pen = np.zeros((D, M)) for d in range(D): pi = inp.incumbent_party_idx[d] if inp.retirement_flags[d] and 0 <= pi < K: if pi == 0: # parti de référence alr : pénaliser = bonifier les autres retire_pen[d, :] += settings.incumbent_retirement_penalty else: retire_pen[d, pi - 1] -= settings.incumbent_retirement_penalty y = (base_alr[None, :, :] + retire_pen[None, :, :] + delta_nat[:, None, :] + eps_reg[:, reg_idx, :] + reg_scenario[None, reg_idx, :] + eps_riding) # (S,D,5) shares = inv_alr(y) # (S,D,6) if scenario.get("turnout_mult"): mult = np.array([scenario["turnout_mult"].get(p, 1.0) for p in settings.parties]) shares = shares * mult[None, None, :] shares = shares / shares.sum(axis=-1, keepdims=True) winners = shares.argmax(axis=-1) # (S,D) seat_counts = np.zeros((S, K), dtype=np.int32) for k in range(K): seat_counts[:, k] = (winners == k).sum(axis=1) most = seat_counts.argmax(axis=1) # (S,) seats = {} for k, party in enumerate(settings.parties): sc = seat_counts[:, k] hist = np.bincount(sc, minlength=D + 1) seats[party] = { "mean": round(float(sc.mean()), 1), "p05": int(np.percentile(sc, 5)), "p25": int(np.percentile(sc, 25)), "p50": int(np.percentile(sc, 50)), "p75": int(np.percentile(sc, 75)), "p95": int(np.percentile(sc, 95)), "prob_most": round(float((most == k).mean()), 4), "prob_majority": round(float((sc >= inp.majority_seats).mean()), 4), "prob_minority_win": round(float(((most == k) & (sc < inp.majority_seats)).mean()), 4), "hist": {str(i): int(c) for i, c in enumerate(hist) if c > 0}, } districts = [] for d in range(D): wp = {settings.parties[k]: round(float((winners[:, d] == k).mean()), 4) for k in range(K)} exp = {settings.parties[k]: round(float(shares[:, d, k].mean() * 100), 2) for k in range(K)} lo = {settings.parties[k]: round(float(np.percentile(shares[:, d, k], 2.5) * 100), 2) for k in range(K)} hi = {settings.parties[k]: round(float(np.percentile(shares[:, d, k], 97.5) * 100), 2) for k in range(K)} fav = max(wp, key=wp.get) districts.append({ "district": inp.district_names[d], "region": inp.district_regions[d], "favorite": fav, "category": category_for(wp[fav]), "win_probs": wp, "expected": exp, "lo95": lo, "hi95": hi, }) nat = {settings.parties[k]: round(float(nat_shares[:, k].mean() * 100), 2) for k in range(K)} hung = float((seat_counts.max(axis=1) < inp.majority_seats).mean()) return SimulationResult( n_sims=S, seats=seats, national_vote=nat, districts=districts, seat_matrix_summary={"prob_no_majority": round(hung, 4), "majority_threshold": inp.majority_seats, "total_seats": D}, raw_winners=winners if keep_raw else None, raw_shares=shares if keep_raw else None)