Pure numpy function that computes bootstrap confidence intervals for party centroid vectors. Handles N>=2 (bootstrap), N=1 (degenerate CI), and N=0 (excluded) cases. Uses np.random.default_rng for reproducibility.main
parent
ef96edf478
commit
cd8aeec997
@ -0,0 +1,121 @@ |
||||
"""Tests for compute_party_bootstrap_cis in analysis.political_axis.""" |
||||
|
||||
import numpy as np |
||||
|
||||
from analysis.political_axis import compute_party_bootstrap_cis |
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────── |
||||
|
||||
|
||||
def _make_party_vectors(n_mps: int, dim: int = 50, seed: int = 0) -> list: |
||||
"""Generate a list of random MP vectors for a single party.""" |
||||
rng = np.random.default_rng(seed) |
||||
return [rng.standard_normal(dim) for _ in range(n_mps)] |
||||
|
||||
|
||||
# ── Tests ──────────────────────────────────────────────────────────────────── |
||||
|
||||
|
||||
class TestBootstrapDeterministic: |
||||
def test_same_seed_gives_identical_output(self): |
||||
"""Same inputs + same seed -> identical outputs.""" |
||||
vecs = _make_party_vectors(10, dim=5, seed=99) |
||||
party_vectors = {"PartyA": vecs} |
||||
|
||||
result1 = compute_party_bootstrap_cis(party_vectors, n_boot=200, seed=42) |
||||
result2 = compute_party_bootstrap_cis(party_vectors, n_boot=200, seed=42) |
||||
|
||||
np.testing.assert_array_equal( |
||||
result1["PartyA"]["centroid"], result2["PartyA"]["centroid"] |
||||
) |
||||
np.testing.assert_array_equal( |
||||
result1["PartyA"]["ci_lower"], result2["PartyA"]["ci_lower"] |
||||
) |
||||
np.testing.assert_array_equal( |
||||
result1["PartyA"]["ci_upper"], result2["PartyA"]["ci_upper"] |
||||
) |
||||
np.testing.assert_array_equal( |
||||
result1["PartyA"]["std"], result2["PartyA"]["std"] |
||||
) |
||||
assert result1["PartyA"]["n_mps"] == result2["PartyA"]["n_mps"] |
||||
|
||||
|
||||
class TestBootstrapSingleMP: |
||||
def test_single_mp_collapses_ci(self): |
||||
"""Party with 1 MP -> ci_lower == ci_upper == centroid, std == 0.""" |
||||
vec = np.array([1.0, 2.0, 3.0]) |
||||
party_vectors = {"Solo": [vec]} |
||||
|
||||
result = compute_party_bootstrap_cis(party_vectors, n_boot=500) |
||||
entry = result["Solo"] |
||||
|
||||
np.testing.assert_array_equal(entry["centroid"], vec) |
||||
np.testing.assert_array_equal(entry["ci_lower"], vec) |
||||
np.testing.assert_array_equal(entry["ci_upper"], vec) |
||||
np.testing.assert_array_equal(entry["std"], np.zeros_like(vec)) |
||||
assert entry["n_mps"] == 1 |
||||
|
||||
|
||||
class TestBootstrapCIWidthScalesWithN: |
||||
def test_larger_party_has_narrower_ci(self): |
||||
"""Party with 3 MPs should have wider CIs than party with 30 MPs |
||||
when sampled from the same distribution.""" |
||||
rng = np.random.default_rng(123) |
||||
dim = 10 |
||||
# Same underlying distribution, different sample sizes |
||||
small_vecs = [rng.standard_normal(dim) for _ in range(3)] |
||||
large_vecs = [rng.standard_normal(dim) for _ in range(30)] |
||||
|
||||
party_vectors = {"Small": small_vecs, "Large": large_vecs} |
||||
result = compute_party_bootstrap_cis(party_vectors, n_boot=2000, seed=42) |
||||
|
||||
small_width = result["Small"]["ci_upper"] - result["Small"]["ci_lower"] |
||||
large_width = result["Large"]["ci_upper"] - result["Large"]["ci_lower"] |
||||
|
||||
# On average, the small party's CI should be wider |
||||
assert np.mean(small_width) > np.mean(large_width) |
||||
|
||||
|
||||
class TestBootstrapEmptyParty: |
||||
def test_empty_list_excluded(self): |
||||
"""Party with empty list -> excluded from output.""" |
||||
party_vectors = { |
||||
"HasMPs": _make_party_vectors(5, dim=4), |
||||
"Empty": [], |
||||
} |
||||
|
||||
result = compute_party_bootstrap_cis(party_vectors, n_boot=100) |
||||
|
||||
assert "HasMPs" in result |
||||
assert "Empty" not in result |
||||
|
||||
|
||||
class TestBootstrapCIContainsCentroid: |
||||
def test_centroid_within_ci_bounds(self): |
||||
"""ci_lower <= centroid <= ci_upper for each dimension.""" |
||||
party_vectors = {"A": _make_party_vectors(15, dim=8, seed=7)} |
||||
result = compute_party_bootstrap_cis(party_vectors, n_boot=1000, seed=42) |
||||
|
||||
entry = result["A"] |
||||
assert np.all(entry["ci_lower"] <= entry["centroid"]) |
||||
assert np.all(entry["centroid"] <= entry["ci_upper"]) |
||||
|
||||
|
||||
class TestBootstrapCustomCILevel: |
||||
def test_wider_ci_at_higher_level(self): |
||||
"""ci=99 produces wider intervals than ci=90.""" |
||||
party_vectors = {"X": _make_party_vectors(20, dim=6, seed=55)} |
||||
|
||||
result_90 = compute_party_bootstrap_cis( |
||||
party_vectors, n_boot=2000, ci=90.0, seed=42 |
||||
) |
||||
result_99 = compute_party_bootstrap_cis( |
||||
party_vectors, n_boot=2000, ci=99.0, seed=42 |
||||
) |
||||
|
||||
width_90 = result_90["X"]["ci_upper"] - result_90["X"]["ci_lower"] |
||||
width_99 = result_99["X"]["ci_upper"] - result_99["X"]["ci_lower"] |
||||
|
||||
# 99% CI should be wider than 90% CI on every dimension |
||||
assert np.all(width_99 >= width_90) |
||||
Loading…
Reference in new issue