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"""Tests du cœur statistique : transformations, pondération, tendance, simulation."""6import sys7from datetime import date, timedelta8from pathlib import Path910import numpy as np11import pytest1213sys.path.insert(0, str(Path(__file__).resolve().parent.parent))1415from app.config import settings16from app.modeling.compositions import alr, alr_obs_cov, close, inv_alr17from app.modeling.house_effects import PollsterProfile, adjust_shares, compute_profiles18from app.modeling.simulate import SimulationInput, category_for, run_simulation19from app.modeling.trend import fit_trend, shares_vector20from app.modeling.weights import display_weight, effective_n, time_weight212223# ---------- compositions ----------24def test_alr_roundtrip():25 p = np.array([0.30, 0.25, 0.20, 0.13, 0.09, 0.03])26 assert np.allclose(inv_alr(alr(p)), p, atol=1e-9)272829def test_close_sums_to_one():30 p = close(np.array([40.0, 30.0, 20.0, 5.0, 4.0, 0.0]))31 assert p.sum() == pytest.approx(1.0)32 assert (p > 0).all() # plancher appliqué333435def test_obs_cov_psd_and_scales_with_n():36 p = np.array([0.30, 0.25, 0.20, 0.13, 0.09, 0.03])37 small = alr_obs_cov(p, 400)38 big = alr_obs_cov(p, 4000)39 assert np.all(np.linalg.eigvalsh(small) > 0)40 assert np.trace(big) < np.trace(small) # plus d'échantillon → moins de variance414243# ---------- pondération ----------44def test_time_weight_decay():45 today = date(2026, 8, 27)46 assert time_weight(today, today) == 1.047 w12 = time_weight(today - timedelta(days=12), today)48 assert w12 == pytest.approx(0.5, abs=1e-9)495051def test_effective_n_cap_and_design_effect():52 assert effective_n(1_000_000, "web") == settings.max_effective_n53 assert effective_n(1300, "web") == pytest.approx(1000, rel=0.01)545556def test_display_weight_monotone_in_n():57 today = date(2026, 8, 27)58 w_small = display_weight(today, today, 400, "web")59 w_big = display_weight(today, today, 2000, "web")60 assert w_big > w_small616263# ---------- house effects ----------64def test_house_effect_shrinkage_and_clip():65 polls = [{"pollster": "X", "field_end": date(2022, 9, 30),66 "shares": {"CAQ": 49.0, "PLQ": 10.0, "PQ": 14.0, "QS": 15.0, "PCQ": 11.0}}]67 elections = [{"date": date(2022, 10, 3),68 "result": {"CAQ": 41.0, "PLQ": 14.4, "PQ": 14.6, "QS": 15.4, "PCQ": 12.9}}]69 profs = compute_profiles(polls, elections)70 he = profs["X"].house_effects71 # biais brut CAQ = +8 → rétréci ×0,5 = +4 → borné à +372 assert he["CAQ"] == pytest.approx(settings.house_effect_max_pp)73 assert he["PLQ"] < 0747576def test_adjust_shares_renormalizes():77 prof = PollsterProfile(name="X", house_effects={"CAQ": 2.0})78 adj = adjust_shares({"CAQ": 40.0, "PQ": 30.0, "PLQ": 30.0}, prof)79 assert sum(adj.values()) == pytest.approx(100.0)80 assert adj["CAQ"] < 40.0818283# ---------- tendance (Kalman) ----------84def _fake_polls(level, n=12, end=date(2026, 8, 24)):85 return [{"pollster": f"P{i % 3}", "field_end": end - timedelta(days=4 * i),86 "sample_size": 1000, "mode": "web", "shares": dict(level)}87 for i in range(n)]888990def test_trend_recovers_stable_level():91 level = {"CAQ": 22.0, "PCQ": 14.0, "PLQ": 23.0, "PQ": 30.0, "QS": 9.0, "AUT": 2.0}92 tr = fit_trend(_fake_polls(level), {}, date(2026, 8, 27))93 est = inv_alr(tr.x) * 10094 for k, p in enumerate(settings.parties):95 assert est[k] == pytest.approx(level[p], abs=1.2)969798def test_trend_requires_minimum_polls():99 assert fit_trend(_fake_polls({"CAQ": 30, "PLQ": 25, "PQ": 25, "QS": 10, "PCQ": 8}, n=2),100 {}, date(2026, 8, 27)) is None101102103# ---------- simulation ----------104def _sim_input(D=20):105 rng = np.random.default_rng(7)106 base = rng.dirichlet(np.array([30, 15, 15, 15, 13, 2]), size=D)107 return SimulationInput(108 x_mean=alr(shares_vector({"CAQ": 22, "PCQ": 14, "PLQ": 23, "PQ": 30, "QS": 9, "AUT": 2})),109 P=np.eye(5) * 0.004,110 baseline_national=shares_vector({"CAQ": 41, "PLQ": 14.4, "QS": 15.4, "PQ": 14.6,111 "PCQ": 12.9, "AUT": 1.7}),112 district_names=[f"D{i}" for i in range(D)],113 district_baselines=base,114 district_regions=["R1" if i < D // 2 else "R2" for i in range(D)],115 retirement_flags=np.zeros(D, dtype=int),116 incumbent_party_idx=np.full(D, -1),117 majority_seats=D // 2 + 1)118119120def test_simulation_invariants():121 sim = run_simulation(_sim_input(), n_sims=2000)122 total_mean = sum(sim.seats[p]["mean"] for p in settings.parties)123 # les sièges se conservent (tolérance = arrondi à 0,1 par parti)124 assert total_mean == pytest.approx(20.0, abs=0.3 + 0.05 * len(settings.parties))125 assert sum(sim.seats[p]["prob_most"] for p in settings.parties) >= 0.999126 for d in sim.districts:127 assert sum(d["win_probs"].values()) == pytest.approx(1.0, abs=0.01)128 assert sum(d["expected"].values()) == pytest.approx(100.0, abs=0.5)129130131def test_scenario_shifts_seats():132 inp = _sim_input()133 base = run_simulation(inp, n_sims=4000)134 up = run_simulation(inp, n_sims=4000, scenario={"national_pp": {"PLQ": 6.0}})135 assert up.seats["PLQ"]["mean"] > base.seats["PLQ"]["mean"]136 assert up.seats["PQ"]["mean"] < base.seats["PQ"]["mean"]137138139def test_turnout_multiplier_shifts_votes():140 inp = _sim_input()141 base = run_simulation(inp, n_sims=3000)142 up = run_simulation(inp, n_sims=3000, scenario={"turnout_mult": {"QS": 1.2}})143 d_qs = np.mean([d2["expected"]["QS"] - d1["expected"]["QS"]144 for d1, d2 in zip(base.districts, up.districts)])145 assert d_qs > 0.5146147148def test_categories():149 assert category_for(0.97) == "Solide"150 assert category_for(0.85) == "Probable"151 assert category_for(0.65) == "Favorisé"152 assert category_for(0.50) == "Chaudement disputé"153