Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/liouscope/fitting/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from __future__ import annotations

import warnings
from collections.abc import Callable

import numpy as np
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
57 changes: 57 additions & 0 deletions tests/test_issue125_degenerate_bca.py
Original file line number Diff line number Diff line change
@@ -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]))
Loading