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%
5.9 KB · 153 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"""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