bayesscenparams / backend /tests /test_phase2_modules.py
Jingrui77's picture
deploy: sync BayesScenParams Agent (2026-05-17T16:01:00Z)
f8a3ca2
Raw
History Blame Contribute Delete
8.6 kB
"""Sanity tests for the Phase 2 domain modules.
These don't try to match the JS implementation byte-for-byte because we
deliberately swapped some approximations for scipy's exact versions
(Jarque-Bera p-value, KS test, chi-squared p-value, etc.). Instead they check
mathematical properties (monotonicity, symmetry, ranges) and a few golden
hand-calculated values.
"""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import pytest
from app.domain.multi_expert import (
JUDGMENT_PRESETS,
Expert,
fuse_dempster_shafer,
fuse_weighted_arithmetic_mean,
fuse_weighted_geometric_mean,
kendall_w,
)
from app.domain.multi_param import (
Parameter,
batch_compute,
compare_scenarios,
correlation_matrix,
js_divergence,
kl_divergence,
pearson_correlation,
spearman_correlation,
wasserstein_distance,
)
from app.domain.preprocessing import (
boxcox_transform,
detect_outliers_iqr,
detect_outliers_zscore,
jarque_bera,
ks_normal,
log_transform,
optimal_bin_count,
shapiro_wilk,
winsorize,
)
SAMPLES_DIR = Path(__file__).resolve().parents[1] / "app" / "data_sources" / "samples"
def _gdp():
return json.loads((SAMPLES_DIR / "sample-gdp.json").read_text())["values"]
# --------------------------------------------------------------------------- #
# Multi-expert
# --------------------------------------------------------------------------- #
def test_expert_validates_judgments():
with pytest.raises(ValueError):
Expert("A", judgments=[0, 1, 2, 3]) # only 4
with pytest.raises(ValueError):
Expert("B", judgments=[0, 1, 2, 3, 5]) # 5 out of range
def test_geometric_fusion_equals_single_expert():
"""With one expert, geometric mean == that expert's likelihood."""
e = Expert("solo", weight=2.0, judgments=[0, 1, 2, 3, 4])
fused = fuse_weighted_geometric_mean([e], R=10)
expected = [10 ** k for k in (-2, -1, 0, 1, 2)]
for a, b in zip(fused, expected, strict=True):
assert a == pytest.approx(b, rel=1e-9)
def test_geometric_vs_arithmetic_two_experts_uniform_weights():
"""For identical experts, both fusion methods agree."""
e1 = Expert("A", judgments=[0, 1, 2, 3, 4])
e2 = Expert("B", judgments=[0, 1, 2, 3, 4])
g = fuse_weighted_geometric_mean([e1, e2], R=10)
a = fuse_weighted_arithmetic_mean([e1, e2], R=10)
for gi, ai in zip(g, a, strict=True):
assert gi == pytest.approx(ai, rel=1e-9)
def test_dempster_shafer_asymmetric_conflict():
"""Two non-symmetric experts: the one with extreme certainty should pull
the combined distribution toward its peak."""
e1 = Expert("A", judgments=[0, 1, 4, 4, 4]) # strong "high" + "very high"
e2 = Expert("B", judgments=[0, 1, 2, 3, 4]) # gradual increase
fused = fuse_dempster_shafer([e1, e2], R=5)
assert sum(fused) == pytest.approx(1.0, abs=1e-9)
# Combined mass should peak at "very high" (index 4)
assert fused.index(max(fused)) == 4
def test_dempster_shafer_symmetric_opposite_yields_uniform():
"""Mathematically correct: symmetric opposing likelihoods give uniform DS."""
e1 = Expert("A", judgments=[4, 3, 2, 1, 0])
e2 = Expert("B", judgments=[0, 1, 2, 3, 4])
fused = fuse_dempster_shafer([e1, e2], R=5)
assert sum(fused) == pytest.approx(1.0, abs=1e-9)
for w in fused:
assert w == pytest.approx(0.2, abs=1e-6)
def test_kendall_w_perfect_agreement():
e1 = Expert("A", judgments=[0, 1, 2, 3, 4])
e2 = Expert("B", judgments=[0, 1, 2, 3, 4])
e3 = Expert("C", judgments=[0, 1, 2, 3, 4])
r = kendall_w([e1, e2, e3])
assert r.W == pytest.approx(1.0, abs=1e-9)
assert r.p_value < 0.05
def test_kendall_w_perfect_disagreement():
# Three experts that disagree pairwise as much as possible
e1 = Expert("A", judgments=[0, 1, 2, 3, 4])
e2 = Expert("B", judgments=[4, 3, 2, 1, 0])
e3 = Expert("C", judgments=[2, 0, 4, 1, 3])
r = kendall_w([e1, e2, e3])
assert 0.0 <= r.W <= 1.0
# Likely no significant agreement
assert r.p_value > 0.05 or r.W < 0.5
def test_presets_well_formed():
for _k, p in JUDGMENT_PRESETS.items():
assert isinstance(p["label"], str)
assert len(p["judgments"]) == 5
# --------------------------------------------------------------------------- #
# Multi-parameter
# --------------------------------------------------------------------------- #
def test_pearson_perfect_positive():
x = list(range(10))
y = [2 * v + 1 for v in x]
assert pearson_correlation(x, y) == pytest.approx(1.0, abs=1e-9)
def test_pearson_perfect_negative():
x = list(range(10))
y = [-3 * v for v in x]
assert pearson_correlation(x, y) == pytest.approx(-1.0, abs=1e-9)
def test_spearman_monotonic_nonlinear():
x = list(range(1, 11))
y = [v**2 for v in x]
assert spearman_correlation(x, y) == pytest.approx(1.0, abs=1e-9)
def test_correlation_matrix_symmetry():
p1 = Parameter("a", data=list(range(10)))
p2 = Parameter("b", data=[v * 0.5 for v in range(10)])
p3 = Parameter("c", data=[10 - v for v in range(10)])
m = correlation_matrix([p1, p2, p3])
for i in range(3):
assert m[i][i] == pytest.approx(1.0)
for j in range(3):
assert m[i][j] == pytest.approx(m[j][i])
def test_batch_compute_runs():
gdp = _gdp()
p = Parameter("gdp", data=gdp, judgments=[0, 1, 2, 3, 4], R=10)
out = batch_compute([p])
assert out[0].result is not None
assert sum(out[0].result.posterior.weights) == pytest.approx(1.0, abs=1e-12)
def test_kl_zero_for_identical_distributions():
p = [0.1, 0.2, 0.4, 0.2, 0.1]
assert kl_divergence(p, p) == pytest.approx(0.0, abs=1e-9)
def test_js_symmetric():
a = [0.1, 0.2, 0.4, 0.2, 0.1]
b = [0.4, 0.3, 0.15, 0.1, 0.05]
assert js_divergence(a, b) == pytest.approx(js_divergence(b, a), abs=1e-12)
def test_wasserstein_known_value():
"""Two delta distributions one unit apart -> W1 = 1."""
values = [0.0, 1.0]
p = [1.0, 0.0]
q = [0.0, 1.0]
assert wasserstein_distance(p, q, values) == pytest.approx(1.0, abs=1e-9)
def test_compare_scenarios_runs():
from app.domain.bayesian import compute
gdp = _gdp()
r1 = compute(gdp, [0, 1, 2, 3, 4], 10.0)
r2 = compute(gdp, [4, 3, 2, 1, 0], 10.0)
d = compare_scenarios(r1, r2)
assert d.delta_mean < 0 # r2 (negative trend) should be lower
assert d.kl > 0
assert d.js > 0
# --------------------------------------------------------------------------- #
# Preprocessing
# --------------------------------------------------------------------------- #
def test_outliers_iqr_picks_extremes():
data = [0.0] * 100 + [100.0, -100.0]
r = detect_outliers_iqr(data)
assert 100.0 in r.values
assert -100.0 in r.values
assert r.count == 2
def test_outliers_zscore_picks_extremes():
np.random.seed(0)
data = [*np.random.normal(0, 1, 1000).tolist(), 50.0, -50.0]
r = detect_outliers_zscore(data, threshold=3.0)
assert 50.0 in r.values
assert -50.0 in r.values
def test_winsorize_clips_extremes():
data = list(range(100))
out = winsorize(data, 0.05, 0.95)
assert min(out) >= 4
assert max(out) <= 95
def test_jarque_bera_detects_normal():
np.random.seed(0)
data = np.random.normal(0, 1, 2000)
r = jarque_bera(data)
assert r.is_normal
def test_jarque_bera_detects_skewed():
np.random.seed(0)
data = np.random.exponential(1.0, 2000)
r = jarque_bera(data)
assert not r.is_normal
def test_shapiro_wilk_basic():
np.random.seed(0)
r = shapiro_wilk(np.random.normal(0, 1, 200))
assert r.is_normal
def test_ks_normal_basic():
np.random.seed(0)
r = ks_normal(np.random.normal(0, 1, 500))
assert r.is_normal
def test_log_transform_handles_non_positive():
data = [-2.0, -1.0, 0.0, 1.0, 2.0]
r = log_transform(data)
assert r.shifted_by == pytest.approx(3.0)
assert len(r.values) == 5
assert all(v == v for v in r.values) # no NaN
def test_boxcox_transform_falls_back_for_non_positive():
data = [-1.0, 0.0, 1.0, 2.0, 3.0]
r = boxcox_transform(data)
assert r.transform == "yeo-johnson"
def test_optimal_bin_count():
np.random.seed(0)
data = np.random.normal(0, 1, 500).tolist()
bins_fd = optimal_bin_count(data, "fd")
bins_sturges = optimal_bin_count(data, "sturges")
bins_scott = optimal_bin_count(data, "scott")
for b in (bins_fd, bins_sturges, bins_scott):
assert 1 <= b <= 200