Skip to content

Commit 561c2bb

Browse files
fix(#135): make Gaussian profile likelihood scale-safe
1 parent 59f861a commit 561c2bb

7 files changed

Lines changed: 243 additions & 11 deletions

File tree

src/liouscope/_types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ class FitResult:
187187
n_eff: float
188188
residual_ar1_rho: float
189189
success: bool
190+
likelihood_degenerate: bool = False
190191

191192

192193
@dataclass(frozen=True, slots=True, kw_only=True)

src/liouscope/diagnostics/relaxation.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ def _fit_with_model(
215215
n_eff=n_eff,
216216
residual_ar1_rho=fit.rho_ar1,
217217
success=fit.success,
218+
likelihood_degenerate=fit.likelihood_degenerate,
218219
)
219220
return fit_result, fit.params
220221

src/liouscope/fitting/aicc.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@
1111

1212
from __future__ import annotations
1313

14+
import math
1415
from collections.abc import Mapping
1516

1617
import numpy as np
1718

19+
from ..numerics.norms import scaled_log_sum_squares
20+
1821

1922
def aicc(log_likelihood: float, k: int, n_eff: float) -> float:
2023
"""Return ``AICc`` for given log-likelihood, parameter count, and N_eff."""
@@ -29,18 +32,48 @@ def gaussian_log_likelihood(
2932
*,
3033
sigma: float | None = None,
3134
) -> float:
32-
"""Gaussian log-likelihood for ``y - y_hat``.
35+
"""Gaussian log-likelihood for ``y - y_hat`` without absolute RSS floors.
3336
34-
Uses MLE sigma if ``sigma`` is omitted.
37+
When ``sigma`` is omitted, evaluate the profile likelihood at the Gaussian
38+
MLE ``sigma_hat**2 = RSS / n`` directly in log-RSS space. Exact zero RSS has
39+
no finite interior MLE for the positive scale parameter and therefore
40+
returns NaN (model-selection likelihood unavailable) instead of inventing
41+
an absolute epsilon variance. With an explicitly supplied finite positive
42+
``sigma``, zero residuals remain a valid finite likelihood.
3543
"""
3644
residuals = np.asarray(residuals, dtype=float)
3745
n = residuals.size
38-
rss = float(np.dot(residuals, residuals))
46+
if n == 0:
47+
return float("nan")
48+
49+
log_rss = scaled_log_sum_squares(residuals)
50+
if math.isnan(log_rss):
51+
return float("nan")
52+
53+
log_2pi = math.log(2.0 * math.pi)
3954
if sigma is None:
40-
sigma_sq = max(rss / n, 1.0e-30)
55+
if log_rss == float("-inf"):
56+
return float("nan")
57+
if log_rss == float("inf"):
58+
return float("-inf")
59+
return float(-0.5 * n * (log_2pi + 1.0 + log_rss - math.log(n)))
60+
61+
sigma = float(sigma)
62+
if not math.isfinite(sigma) or sigma <= 0.0:
63+
return float("nan")
64+
log_sigma = math.log(sigma)
65+
if log_rss == float("-inf"):
66+
standardised_rss = 0.0
67+
elif log_rss == float("inf"):
68+
return float("-inf")
4169
else:
42-
sigma_sq = sigma * sigma
43-
return float(-0.5 * n * (np.log(2.0 * np.pi * sigma_sq) + rss / (n * sigma_sq)))
70+
log_standardised_rss = log_rss - 2.0 * log_sigma
71+
if log_standardised_rss > math.log(np.finfo(float).max):
72+
return float("-inf")
73+
standardised_rss = math.exp(log_standardised_rss)
74+
return float(
75+
-0.5 * n * log_2pi - n * log_sigma - 0.5 * standardised_rss
76+
)
4477

4578

4679
def choose_model(aiccs: Mapping[str, float]) -> str:

src/liouscope/fitting/bootstrap.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ def parametric_bootstrap(
6565
+ (f" (saturated: {', '.join(base.saturated)})" if base.saturated else "")
6666
+ (" (the curve carries no resolvable variation, issue #123)"
6767
if base.degenerate else "")
68+
+ (" (the residual likelihood scale is degenerate, issue #135)"
69+
if base.likelihood_degenerate else "")
6870
+ "; a bootstrap around a non-estimate has no meaning"
6971
)
7072
theta_hat = base.params

src/liouscope/fitting/gls.py

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@
1212

1313
from __future__ import annotations
1414

15+
import math
1516
import warnings
1617
from collections.abc import Callable
1718
from dataclasses import dataclass
1819

1920
import numpy as np
2021
from scipy.optimize import least_squares
2122

23+
from ..numerics.norms import scaled_log_sum_squares
2224
from .aicc import gaussian_log_likelihood
2325
from .models import saturation_watch
2426
from .neff import _AR1_SMALL_N, ar1_correlation_corrected
@@ -41,6 +43,11 @@ class GLSFitOutput:
4143
#: reports a fit that ran and ended on a magnitude plateau; here there was
4244
#: nothing to fit. Implies ``success`` is False and ``params`` is NaN.
4345
degenerate: bool = False
46+
#: True when the residual Gaussian scale has no finite usable MLE for model
47+
#: selection (issue #135). Distinct from ``degenerate`` above: the curve
48+
#: may carry variation and the optimiser may have run, but exact-zero RSS
49+
#: (or an unrepresentable positive MLE scale) cannot support AICc/CI claims.
50+
likelihood_degenerate: bool = False
4451

4552

4653
def _whiten(y: np.ndarray, rho: float) -> np.ndarray:
@@ -192,14 +199,55 @@ def residual(params: np.ndarray, rho_local: float = rho) -> np.ndarray:
192199
)
193200
whitened = _whiten(residuals_raw, rho)
194201
n = whitened.size
195-
sigma = float(np.sqrt(max(np.dot(whitened, whitened) / max(n, 1), 1.0e-30)))
202+
log_rss = scaled_log_sum_squares(whitened)
203+
if log_rss == float("-inf") or not math.isfinite(log_rss):
204+
warnings.warn(
205+
"fit_gls_ar1: residual Gaussian scale has no finite positive MLE "
206+
"for model selection; likelihood/AICc/CI evidence is unavailable "
207+
"(issue #135).",
208+
RuntimeWarning,
209+
stacklevel=2,
210+
)
211+
return GLSFitOutput(
212+
params=p,
213+
residuals=residuals_raw,
214+
rho_ar1=rho,
215+
sigma=float("nan"),
216+
log_likelihood=float("nan"),
217+
success=False,
218+
saturated=tuple(sorted(fired)),
219+
likelihood_degenerate=True,
220+
)
221+
222+
log_sigma = 0.5 * (log_rss - math.log(n))
223+
try:
224+
sigma = float(math.exp(log_sigma))
225+
except OverflowError:
226+
sigma = float("inf")
227+
if not math.isfinite(sigma) or sigma <= 0.0:
228+
warnings.warn(
229+
"fit_gls_ar1: positive residual MLE scale is not representable as "
230+
"float64; likelihood/AICc/CI evidence is unavailable (issue #135).",
231+
RuntimeWarning,
232+
stacklevel=2,
233+
)
234+
return GLSFitOutput(
235+
params=p,
236+
residuals=residuals_raw,
237+
rho_ar1=rho,
238+
sigma=float("nan"),
239+
log_likelihood=float("nan"),
240+
success=False,
241+
saturated=tuple(sorted(fired)),
242+
likelihood_degenerate=True,
243+
)
244+
196245
# Prais-Winsten exact AR(1) likelihood: _whiten keeps observation 0
197246
# (scaled by sqrt(1-rho^2)), so the transform has log-Jacobian
198-
# 0.5*log(1-rho^2). Omitting it makes the reported value a hybrid of the
199-
# exact and conditional likelihoods and biases cross-model AICc (each
200-
# model fits its own rho) toward under-fitting high-rho models.
247+
# 0.5*log(1-rho^2). The profile likelihood is evaluated directly from
248+
# log(RSS), not by squaring ``sigma`` or materialising RSS.
201249
jac = 0.5 * float(np.log(max(1.0 - rho * rho, 1.0e-12)))
202-
log_lik = gaussian_log_likelihood(whitened, sigma=sigma) + jac
250+
log_lik = gaussian_log_likelihood(whitened) + jac
203251
return GLSFitOutput(
204252
params=p,
205253
residuals=residuals_raw,

src/liouscope/numerics/norms.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,40 @@ def scaled_euclidean_norm(values: np.ndarray) -> float:
7373
return float(np.ldexp(scaled_norm, exponent))
7474

7575

76+
def scaled_log_sum_squares(values: np.ndarray) -> float:
77+
"""Return ``log(sum(abs(values)**2))`` without spurious under/overflow.
78+
79+
The return value is ``-inf`` for an exact all-zero input, ``nan`` when any
80+
component is NaN, and ``inf`` when any component is infinite. For finite,
81+
non-zero float64 input the logarithm remains finite even when the true sum
82+
of squares (or its square root) is outside the representable float64 range.
83+
84+
This is the likelihood-facing companion to :func:`scaled_euclidean_norm`:
85+
both use the same exact power-of-two scaling contract, but this function
86+
never reconstructs RSS in ordinary floating-point units.
87+
"""
88+
arr = np.asarray(values)
89+
if arr.size == 0:
90+
return float("-inf")
91+
92+
real = np.asarray(np.real(arr), dtype=float)
93+
imag = np.asarray(np.imag(arr), dtype=float)
94+
if np.any(np.isnan(real)) or np.any(np.isnan(imag)):
95+
return float("nan")
96+
if np.any(np.isinf(real)) or np.any(np.isinf(imag)):
97+
return float("inf")
98+
99+
scaled = _finite_component_scale(arr)
100+
if scaled is None:
101+
return float("-inf")
102+
scaled_real, scaled_imag, exponent = scaled
103+
sumsq = float(
104+
np.sum(scaled_real * scaled_real, dtype=float)
105+
+ np.sum(scaled_imag * scaled_imag, dtype=float)
106+
)
107+
return float(math.log(sumsq) + 2.0 * exponent * math.log(2.0))
108+
109+
76110
def scaled_cancellation_ratio(values: np.ndarray) -> float:
77111
"""Return ``abs(sum(values)) / sum(abs(values))`` scale-safely.
78112
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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

Comments
 (0)