From 1e3b1a018481f1002074ed3a8ac113698f7cc326 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 17:40:17 +0000 Subject: [PATCH] fix: natural_logpdf clamped a genuine -inf to -1.8e308 `AbstractMessage.natural_logpdf` reduced through xp.nan_to_num(log_base + eta_t - log_partition, nan=-xp.inf) The `nan=-xp.inf` is deliberate: an out-of-support NaN (log of a negative value under a transformed message) is zero density. But `posinf`/`neginf` were left at their DEFAULTS, and `nan_to_num` replaces a genuine `-inf` with negative float max. So the call did the opposite of its intent for the inputs that already had the right answer: value reaching the reduction | intended | actual NaN | -inf | -inf -inf | -inf | -1.7976931348623157e+308 Measured on main @ 6e2d8c8, LogGaussianPrior(0.4, 1.3): message.logpdf(-1.0) = -inf (log(-1) is NaN) message.logpdf( 0.0) = -1.7976931348623157e+308 (log(0) is -inf) That asymmetry is the proof of mechanism, and the second value is exactly -sys.float_info.max. It matters because -1.8e308 is FINITE, and `isfinite` is what `optax.apply_if_finite` and `autofit.non_linear.clipper` branch on to detect a lane leaving the prior support -- clipper.py's entire premise is that leaving the box makes the objective non-finite. Two such terms summed overflow to -inf while one does not, so the behaviour depended on how many parameters were out of support. Fix: pass `neginf`/`posinf` through, so only NaN is replaced. Tests: new test_autofit/messages/test_natural_logpdf_neginf.py (13), covering both halves of the reduction, in-support values unchanged, and the general property that no prior family reports a finite density off its support. Verified by inversion: reverting the fix fails 2 of the 13. Full suite: 2203 passed, 36 skipped (baseline on 6e2d8c8: 2190 / 36, measured). Co-Authored-By: Claude --- autofit/messages/interface.py | 14 +++- .../messages/test_natural_logpdf_neginf.py | 77 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 test_autofit/messages/test_natural_logpdf_neginf.py diff --git a/autofit/messages/interface.py b/autofit/messages/interface.py index 3a0410624..24ae2ea08 100644 --- a/autofit/messages/interface.py +++ b/autofit/messages/interface.py @@ -95,7 +95,19 @@ def log_partition(self, xp=np) -> np.ndarray: @classmethod def natural_logpdf(cls, eta, t, log_base, log_partition, xp=np): eta_t = xp.multiply(eta, t).sum(0) - return xp.nan_to_num(log_base + eta_t - log_partition, nan=-xp.inf) + # Only NaN is replaced. ``nan=-inf`` maps an out-of-support NaN (e.g. + # ``log`` of a negative value under a transformed message) to zero + # density, which is intended -- but ``nan_to_num``'s DEFAULT ``neginf`` + # replaces a genuine ``-inf`` with ``-1.8e308``, turning a zero-density + # point into a finite one. That is the opposite of the intent, and it + # matters: ``isfinite`` is what ``optax.apply_if_finite`` and + # ``non_linear.clipper`` branch on to detect a lane leaving the support. + return xp.nan_to_num( + log_base + eta_t - log_partition, + nan=-xp.inf, + neginf=-xp.inf, + posinf=xp.inf, + ) def numerical_logpdf_gradient( self, x: np.ndarray, eps: float = 1e-6 diff --git a/test_autofit/messages/test_natural_logpdf_neginf.py b/test_autofit/messages/test_natural_logpdf_neginf.py new file mode 100644 index 000000000..01435a3f9 --- /dev/null +++ b/test_autofit/messages/test_natural_logpdf_neginf.py @@ -0,0 +1,77 @@ +""" +``AbstractMessage.natural_logpdf`` must not clamp a genuine ``-inf``. + +The reduction maps NaN to ``-inf`` on purpose -- an out-of-support NaN (``log`` of a +negative value under a transformed message) is zero density. But ``nan_to_num``'s +DEFAULT ``neginf`` replaces a real ``-inf`` with ``-1.7976931348623157e+308``, so the +call did the opposite of its intent for exactly the inputs that were already correct. + +``-1.8e308`` is finite, and ``isfinite`` is what ``optax.apply_if_finite`` and +``autofit.non_linear.clipper`` branch on to decide a lane has left the prior support. +""" + +import sys + +import numpy as np +import pytest + +import autofit as af + + +@pytest.fixture(name="log_gaussian") +def make_log_gaussian(): + return af.LogGaussianPrior(mean=0.4, sigma=1.3) + + +def test__genuine_neginf_survives_the_reduction(log_gaussian): + """ + ``log(0) = -inf`` reaches the reduction as ``-inf``; it must come back as ``-inf``, + not as negative float max. + """ + value = log_gaussian.message.logpdf(np.array(0.0)) + + assert value == -np.inf + assert not np.isfinite(value) + assert value != -sys.float_info.max + + +def test__nan_still_maps_to_neginf(log_gaussian): + """ + The other half of the same call, which was always correct: ``log(-1) = NaN``, and an + out-of-support NaN is zero density. Pinned so a fix to one half cannot break the other. + """ + value = log_gaussian.message.logpdf(np.array(-1.0)) + + assert value == -np.inf + assert not np.isnan(value) + + +@pytest.mark.parametrize("value", [1e-9, 0.001, 0.1, 0.5, 1.0, 2.0, 10.0, 1000.0]) +def test__in_support_values_are_unchanged(log_gaussian, value): + """ + The fix must touch only the non-finite branch. Every in-support point is finite before + and after, and the density is the one the prior reports. + """ + assert np.isfinite(log_gaussian.message.logpdf(np.array(value))) + + +@pytest.mark.parametrize( + "prior, outside", + [ + (af.UniformPrior(lower_limit=0.0, upper_limit=2.0), [-1.0, 3.0]), + (af.LogUniformPrior(lower_limit=0.01, upper_limit=100.0), [-1.0, 0.0, 1000.0]), + (af.LogGaussianPrior(mean=0.4, sigma=1.3), [-1.0, 0.0]), + ], + ids=["Uniform", "LogUniform", "LogGaussian"], +) +def test__no_prior_family_reports_a_finite_density_off_its_support(prior, outside): + """ + The general form. A finite log density outside the support is a licence for any + ``isfinite`` consumer to treat an impossible point as merely a bad one. + + ``TruncatedGaussianPrior`` is deliberately absent: its message returns finite values + outside its limits for an unrelated reason (``TruncatedNormalMessage`` does not gate on + them), which this fix does not address and must not be read as covering. + """ + for value in outside: + assert prior.message.logpdf(np.array(value)) == -np.inf