From e675e619da3a03b2f192f5446363570a8a515cde Mon Sep 17 00:00:00 2001 From: Marco Hostettler Date: Tue, 1 Sep 2026 20:56:49 +0200 Subject: [PATCH 1/2] fix(#125): fail closed on degenerate BCa uncertainty --- src/liouscope/fitting/bootstrap.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/liouscope/fitting/bootstrap.py b/src/liouscope/fitting/bootstrap.py index b936ee5..6bca6ce 100644 --- a/src/liouscope/fitting/bootstrap.py +++ b/src/liouscope/fitting/bootstrap.py @@ -13,6 +13,7 @@ from __future__ import annotations +import warnings from collections.abc import Callable import numpy as np @@ -168,6 +169,14 @@ def bca_ci( Returns ``(p, 2)`` array of ``(lo, hi)`` per parameter at level ``1 - alpha``. + + A parameter whose bootstrap distribution is degenerate, or whose adjusted + quantiles collapse to an exactly zero-width interval, is reported as + ``(NaN, NaN)`` with a warning. This follows the fail-closed semantics used + by SciPy's BCa implementation for degenerate bootstrap distributions: + arithmetic concentration is not promoted to a claim of perfect certainty + when the resampling model contains no measurable spread (issue #125). + Other non-degenerate parameters in the same fit remain reportable. """ samples = np.asarray(samples, dtype=float) theta_hat = np.asarray(theta_hat, dtype=float) @@ -177,6 +186,16 @@ def bca_ci( z_alpha_hi = ndtri(1.0 - alpha / 2.0) for j in range(p): boot_j = np.sort(samples[:, j]) + if boot_j.size == 0 or np.all(boot_j == boot_j[0]): + warnings.warn( + f"bca_ci: bootstrap distribution for parameter {j} is " + "degenerate; uncertainty is unavailable rather than a " + "zero-width confidence claim (issue #125)", + RuntimeWarning, + stacklevel=2, + ) + cis[j] = (float("nan"), float("nan")) + continue # Bias-correction z0 with half-correction for ties (Efron 1987, S3 # audit 2026-06-04). Using a strict ``<`` only would send the # proportion to 0 (hence z0 -> -inf, clamped) whenever many bootstrap @@ -214,6 +233,16 @@ def adjusted(z_alpha: float, z0_: float = z0, a_: float = a) -> float: # continuous-percentile estimator. lo = float(np.quantile(boot_j, q_lo, method="linear")) hi = float(np.quantile(boot_j, q_hi, method="linear")) + if lo == hi: + warnings.warn( + f"bca_ci: confidence interval for parameter {j} collapsed to " + "exactly zero width; uncertainty is unavailable rather than " + "perfectly known (issue #125)", + RuntimeWarning, + stacklevel=2, + ) + cis[j] = (float("nan"), float("nan")) + continue cis[j] = (lo, hi) return cis From b46daa8ca0642beb836783247ce47960cb7411d3 Mon Sep 17 00:00:00 2001 From: Marco Hostettler Date: Tue, 1 Sep 2026 20:57:05 +0200 Subject: [PATCH 2/2] test(#125): discriminate degenerate from ordinary BCa uncertainty --- tests/test_issue125_degenerate_bca.py | 57 +++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 tests/test_issue125_degenerate_bca.py diff --git a/tests/test_issue125_degenerate_bca.py b/tests/test_issue125_degenerate_bca.py new file mode 100644 index 0000000..7e2fa40 --- /dev/null +++ b/tests/test_issue125_degenerate_bca.py @@ -0,0 +1,57 @@ +"""Regression tests for issue #125: degenerate BCa uncertainty claims.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from liouscope.fitting.bootstrap import bca_ci + + +def test_degenerate_bootstrap_distribution_is_unavailable_not_zero_width() -> None: + samples = np.ones((200, 1), dtype=float) + theta_hat = np.array([1.0]) + + with pytest.warns(RuntimeWarning, match="degenerate"): + ci = bca_ci(samples, theta_hat) + + assert np.isnan(ci[0, 0]) + assert np.isnan(ci[0, 1]) + + +def test_degenerate_parameter_does_not_withhold_an_independent_parameter() -> None: + x = np.linspace(-1.0, 1.0, 200) + samples = np.column_stack([np.ones_like(x), x]) + theta_hat = np.array([1.0, 0.0]) + + with pytest.warns(RuntimeWarning, match="parameter 0"): + ci = bca_ci(samples, theta_hat) + + assert np.all(np.isnan(ci[0])) + assert np.all(np.isfinite(ci[1])) + assert ci[1, 0] < ci[1, 1] + + +def test_ordinary_nondegenerate_distribution_keeps_a_finite_interval() -> None: + rng = np.random.default_rng(125) + samples = rng.normal(loc=0.7, scale=0.03, size=(1000, 1)) + theta_hat = np.array([0.7]) + + ci = bca_ci(samples, theta_hat) + + assert np.all(np.isfinite(ci)) + assert ci[0, 0] < ci[0, 1] + assert ci[0, 0] < theta_hat[0] < ci[0, 1] + + +def test_zero_width_after_adjusted_quantiles_is_not_reported_as_certainty() -> None: + # Non-identical distribution, but >97.5% of its mass sits on one value. + # The BCa adjusted endpoints can therefore collapse to that value. The + # claim boundary, not merely the raw distribution, must catch the collapse. + samples = np.concatenate([np.ones(199), np.array([2.0])]).reshape(-1, 1) + theta_hat = np.array([1.0]) + + with pytest.warns(RuntimeWarning, match="zero width"): + ci = bca_ci(samples, theta_hat) + + assert np.all(np.isnan(ci[0]))