|
| 1 | +"""Issue #135: scale-safe Gaussian likelihood and degenerate RSS semantics.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import math |
| 6 | + |
| 7 | +import numpy as np |
| 8 | +import pytest |
| 9 | + |
| 10 | +from liouscope.diagnostics import relaxation as relaxation_mod |
| 11 | +from liouscope.fitting.aicc import aicc, choose_model, gaussian_log_likelihood |
| 12 | +from liouscope.fitting.bootstrap import parametric_bootstrap |
| 13 | +from liouscope.fitting.gls import GLSFitOutput, fit_gls_ar1 |
| 14 | +from liouscope.fitting.models import M0 |
| 15 | +from liouscope.numerics.norms import scaled_log_sum_squares |
| 16 | + |
| 17 | + |
| 18 | +def test_scaled_log_rss_spans_underflow_and_overflow_regimes(): |
| 19 | + tiny = np.array([1.0e-320, -2.0e-320]) |
| 20 | + huge = np.array([1.0e308, -1.0e308]) |
| 21 | + log_tiny = scaled_log_sum_squares(tiny) |
| 22 | + log_huge = scaled_log_sum_squares(huge) |
| 23 | + assert np.isfinite(log_tiny) |
| 24 | + assert np.isfinite(log_huge) |
| 25 | + assert log_tiny == pytest.approx(math.log(5.0) + 2.0 * math.log(1.0e-320), rel=2e-5) |
| 26 | + assert log_huge == pytest.approx(math.log(2.0) + 2.0 * math.log(1.0e308), rel=1e-14) |
| 27 | + assert scaled_log_sum_squares(np.zeros(4)) == float("-inf") |
| 28 | + |
| 29 | + |
| 30 | +def test_profile_loglikelihood_is_scale_covariant_and_delta_invariant(): |
| 31 | + x = np.linspace(0.2, 1.2, 64) |
| 32 | + r0 = 0.7 + x + 0.03 * np.sin(5.0 * x) |
| 33 | + r1 = 0.9 + 1.1 * x - 0.02 * np.cos(3.0 * x) |
| 34 | + reference_delta = None |
| 35 | + reference_winner = None |
| 36 | + for scale in (1.0e-150, 1.0e-40, 1.0, 1.0e40, 1.0e150): |
| 37 | + ll0 = gaussian_log_likelihood(scale * r0) |
| 38 | + ll1 = gaussian_log_likelihood(scale * r1) |
| 39 | + assert np.isfinite(ll0) and np.isfinite(ll1) |
| 40 | + delta = ll1 - ll0 |
| 41 | + scores = { |
| 42 | + "M0": aicc(ll0, k=2, n_eff=64.0), |
| 43 | + "M1": aicc(ll1, k=3, n_eff=64.0), |
| 44 | + } |
| 45 | + winner = choose_model(scores) |
| 46 | + if reference_delta is None: |
| 47 | + reference_delta = delta |
| 48 | + reference_winner = winner |
| 49 | + else: |
| 50 | + assert delta == pytest.approx(reference_delta, rel=1e-11, abs=1e-10) |
| 51 | + assert winner == reference_winner |
| 52 | + |
| 53 | + |
| 54 | +def test_profile_loglikelihood_handles_true_rss_above_float_range(): |
| 55 | + ll = gaussian_log_likelihood(np.array([1.0e308, -1.0e308])) |
| 56 | + assert np.isfinite(ll) |
| 57 | + |
| 58 | + |
| 59 | +def test_zero_rss_unknown_sigma_is_unavailable_but_known_sigma_is_valid(): |
| 60 | + residuals = np.zeros(8) |
| 61 | + assert np.isnan(gaussian_log_likelihood(residuals)) |
| 62 | + ll = gaussian_log_likelihood(residuals, sigma=2.0) |
| 63 | + expected = -0.5 * residuals.size * math.log(2.0 * math.pi * 4.0) |
| 64 | + assert ll == pytest.approx(expected, rel=1e-14, abs=1e-14) |
| 65 | + |
| 66 | + |
| 67 | +def test_gls_exact_fit_marks_likelihood_degenerate_and_bootstrap_refuses(): |
| 68 | + t = np.linspace(0.0, 4.0, 48) |
| 69 | + p0 = np.array([1.25, 0.6]) |
| 70 | + y = M0(t, p0) |
| 71 | + with pytest.warns(RuntimeWarning, match="likelihood/AICc/CI evidence is unavailable"): |
| 72 | + fit = fit_gls_ar1(M0, t, y, p0, n_iters=1) |
| 73 | + assert not fit.success |
| 74 | + assert fit.likelihood_degenerate |
| 75 | + assert not fit.degenerate |
| 76 | + assert np.isnan(fit.sigma) |
| 77 | + assert np.isnan(fit.log_likelihood) |
| 78 | + # The repository treats unexpected warnings as errors. Here the warning is |
| 79 | + # part of the intended public contract: the base fit first reports why its |
| 80 | + # likelihood evidence is unusable, then bootstrap refuses the non-estimate. |
| 81 | + with pytest.warns(RuntimeWarning, match="likelihood/AICc/CI evidence is unavailable"): |
| 82 | + with pytest.raises(RuntimeError, match="likelihood scale is degenerate"): |
| 83 | + parametric_bootstrap(M0, t, y, p0, B=4) |
| 84 | + |
| 85 | + |
| 86 | +def test_ordinary_noisy_gls_positive_control_is_not_likelihood_degenerate(rng): |
| 87 | + t = np.linspace(0.0, 4.0, 80) |
| 88 | + p = np.array([1.25, 0.6]) |
| 89 | + y = M0(t, p) + 1.0e-3 * rng.standard_normal(t.size) |
| 90 | + fit = fit_gls_ar1(M0, t, y, p, n_iters=1) |
| 91 | + assert fit.success |
| 92 | + assert not fit.likelihood_degenerate |
| 93 | + assert np.isfinite(fit.sigma) and fit.sigma > 0.0 |
| 94 | + assert np.isfinite(fit.log_likelihood) |
| 95 | + |
| 96 | + |
| 97 | +def test_likelihood_degenerate_state_reaches_fitresult_and_is_nonselectable(monkeypatch): |
| 98 | + t = np.linspace(0.0, 1.0, 16) |
| 99 | + y = np.exp(-t) |
| 100 | + fake = GLSFitOutput( |
| 101 | + params=np.array([1.0, 1.0]), |
| 102 | + residuals=np.zeros_like(y), |
| 103 | + rho_ar1=0.0, |
| 104 | + sigma=float("nan"), |
| 105 | + log_likelihood=float("nan"), |
| 106 | + success=False, |
| 107 | + likelihood_degenerate=True, |
| 108 | + ) |
| 109 | + monkeypatch.setattr(relaxation_mod, "fit_gls_ar1", lambda *args, **kwargs: fake) |
| 110 | + fit_result, _ = relaxation_mod._fit_with_model("M0", t, y) |
| 111 | + assert not fit_result.success |
| 112 | + assert fit_result.likelihood_degenerate |
| 113 | + assert np.isinf(fit_result.aicc) |
0 commit comments