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