Skip to content

fix: LogGaussianPrior declares its own (0, inf) support - #1527

Merged
Jammy2211 merged 1 commit into
mainfrom
feature/loggaussian-prior-support
Aug 25, 2026
Merged

fix: LogGaussianPrior declares its own (0, inf) support#1527
Jammy2211 merged 1 commit into
mainfrom
feature/loggaussian-prior-support

Conversation

@Jammy2211

Copy link
Copy Markdown
Collaborator

Summary

LogGaussianPrior's support is (0, inf)log_prior_from_value returns -inf
for value <= 0 — but the prior reported (-inf, inf). Prior.__getattr__
delegates to a TransformedMessage whose limits default to ±inf and were never
set, so the one prior with a non-trivial support was the one prior that lied about it.

ClipperPriorBox worked around this with an isinstance switch (#1477, follow-up 3),
declaring the real bound in the clipper rather than on the prior. That workaround was
correct but misplaced: every other consumer of lower_limit was still being told a
strictly positive parameter could go negative.

This declares the support on the prior itself, adds a general strictness contract so
consumers can tell an exclusive bound from an inclusive one without a type switch, and
retires the clipper's special case.

Closes #1526.

API Changes

Prior gains two class attributes, lower_limit_strict / upper_limit_strict, both
False by default; LogGaussianPrior sets the lower one True. LogGaussianPrior
now reports lower_limit == 0.0 and limits == (0.0, inf) instead of ±inf.
Prior.limits is now derived from lower_limit / upper_limit rather than
returning a hardcoded (-inf, inf), so the two can no longer disagree — the root
cause of this bug class. GaussianPrior is unaffected and still reports (-inf, inf).

One downstream-visible consequence: prior passing on a LogGaussian parameter with no
config Limits entry now produces a lower-bounded prior, changing its unit-cube
mapping. See full details below.

Test Plan

  • Full suite: NUMBA_CACHE_DIR=… MPLCONFIGDIR=… python -m pytest test_autofit/
    2178 passed, 36 skipped (baseline on main: 2124 passed, 36 skipped).
  • The new property test fails on the parent commit for LogGaussianPrior
    alone and passes for the other five prior families — i.e. it is the general
    form of this bug, not a restatement of the fix.
  • Regression pins (log_prior_from_value, unit-cube mapping, identifier) pass on
    both sides of the change, which is what makes them meaningful.

Verified unchanged (measured, not argued)

Before/after over 37 behavioural probes against a running 3.12 install:

Probe Result
Identifier(prior) + full description byte-identical — no output directories re-key
Identifier(LBFGS(clipper=ClipperPriorBox())) byte-identical
log_prior_from_value at 11 points either side of 0 pointwise identical
value_for, unit_value_for round-trip, Model.vector_from_unit_vector identical over a 10-point unit grid — no stored nested-sampling result shifts
ClipperPriorBox bounds, projections, clipped-masks identical across 3 clipper configs × 3 input vectors
message.lower_limit (what EP/Laplace read) unchanged at ±inf
GaussianPrior / UniformPrior / LogUniformPrior / TruncatedGaussianPrior limits unchanged

34 of 37 probes identical; the 3 that moved are lower_limit under copy, pickle
and project — the fix itself.

Full API Changes (for automation & release notes)

Added

  • Prior.lower_limit_strict (bool, default False) — whether the support
    excludes lower_limit itself, i.e. value > limit rather than value >= limit.
  • Prior.upper_limit_strict (bool, default False) — the same for upper_limit.
  • LogGaussianPrior.lower_limit_strict = True.

Both are class attributes: they are a property of the prior family, never of an
instance, so they stay out of __dict__ and out of the identifier.

Changed Behaviour

  • LogGaussianPrior.lower_limit — was -inf, now 0.0.
  • LogGaussianPrior.upper_limit — was inf, unchanged in value but now declared on
    the prior rather than delegated.
  • LogGaussianPrior.limits — was (-inf, inf), now (0.0, inf).
  • Prior.limits — was a hardcoded (float("-inf"), float("inf")), now
    (float(self.lower_limit), float(self.upper_limit)). Priors that already override
    limits (UniformPrior, LogUniformPrior, TruncatedGaussianPrior) are
    unaffected; their overrides are now exact duplicates of the base and can be removed
    in a later tidy-up. GaussianPrior still resolves to (-inf, inf).
  • ClipperPriorBox._limits_from_model — reads prior.lower_limit_strict /
    prior.upper_limit_strict instead of isinstance(prior, LogGaussianPrior). Output
    is bit-identical; the type switch and its import are gone.

Migration

No migration is required for the prior API itself — the declaration is additive and
the density, unit-cube mapping and identifiers are unchanged.

Prior passing does change. Where a model's config supplies no Limits entry for a
LogGaussian parameter, AbstractPriorModel falls back to prior.limits:

  • Before: TruncatedGaussianPrior(mean, sigma, -inf, inf)value_for(0.001) was
    -0.545 and log_prior_from_value(-1.0) a finite -8.0, i.e. a strictly
    positive parameter was passed a prior that samples negative values.
  • After: TruncatedGaussianPrior(mean, sigma, 0.0, inf)value_for(0.001) is
    0.0089 and log_prior_from_value(-1.0) is -inf.

This is a correctness fix, but it changes the unit-cube mapping of the passed prior.
Downstream inter-phase prior passing in PyAutoGalaxy / PyAutoLens is worth a
spot-check.

Deliberately not done (follow-ups)

  • Declaring the limits on TransformedMessage instead of the prior. They would be
    dropped by with_base / copy / project / __call__, and would change what
    MeanField.lower_limit and LaplaceOptimiser(check_limits=True) feed to
    OptimisationState.valid — a live EP behaviour change outside the scope of this fix.
  • Removing the three now-redundant limits overrides.
  • line_search.OptimisationState.valid uses if self.lower_limit and …, which is
    falsy at 0.0. Pre-existing; untouched here.

Generated by the PyAutoLabs agent workflow.


Generated by Claude Code

`LogGaussianPrior`'s support is `(0, inf)` -- `log_prior_from_value` returns
`-inf` for `value <= 0` -- but the prior reported `(-inf, inf)`, because
`Prior.__getattr__` delegated to a `TransformedMessage` whose limits default to
`+/-inf` and were never set. `ClipperPriorBox` worked around it with an
`isinstance` switch (PyAutoFit#1477, follow-up 3); every other consumer of
`lower_limit` was simply told the wrong thing.

Declare the support on the prior itself, and give `Prior` a general strictness
contract so a consumer can tell an exclusive bound from an inclusive one without
a type switch:

- `Prior.lower_limit_strict` / `upper_limit_strict`, class attributes defaulting
  to `False`; `True` for `LogGaussianPrior`'s lower bound.
- `LogGaussianPrior` sets `lower_limit = 0.0` / `upper_limit = inf` in `__init__`.
- `Prior.limits` derives from `lower_limit`/`upper_limit` instead of returning a
  hardcoded `(-inf, inf)`, so the two notions cannot disagree by construction.
- `ClipperPriorBox._limits_from_model` reads the strictness flags; its
  `isinstance` block, import and workaround docstrings are retired.

The limits go on the prior rather than on the `TransformedMessage` deliberately.
On the message they would be dropped by `with_base`, `copy`, `project` and
`__call__`, and would change what `MeanField.lower_limit` and
`LaplaceOptimiser(check_limits=True)` feed to `OptimisationState.valid` -- a live
EP behaviour change well outside this fix.

Verified before/after over 37 behavioural probes: identifiers (prior and
search-with-clipper) byte-identical, so no output directory re-keys;
`log_prior_from_value` pointwise identical; the nested-sampler unit-cube mapping
identical; clipper bounds, projections and masks identical across 3 clipper
configurations x 3 input vectors.

One downstream-visible change: prior passing on a LogGaussian parameter with no
config `Limits` entry previously produced `TruncatedGaussian(-inf, inf)`, whose
`value_for(0.001)` was `-0.545` and whose `log_prior_from_value(-1.0)` was a
finite `-8.0`. It is now lower-bounded at `0.0`.

Tests: the reported support, the strictness flags, regression pins for
`log_prior_from_value` / the unit-cube mapping / the identifier, and a
parametrised property over every prior family asserting that a prior's reported
support matches its actual support -- the general form of this bug, which fails
on the parent commit for `LogGaussianPrior` alone.

Closes #1526

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXEqAUkXK7UeXw5jGh2i4q
@Jammy2211 Jammy2211 added the pending-release PR queued for the next release build label Aug 25, 2026 — with Claude
@Jammy2211
Jammy2211 merged commit 34d6dff into main Aug 25, 2026
4 checks passed
@Jammy2211
Jammy2211 deleted the feature/loggaussian-prior-support branch August 26, 2026 20:07
Jammy2211 pushed a commit to trexfr-ops/PyAutoFit that referenced this pull request Aug 27, 2026
…ts overrides

Three items left behind by PyAutoFit#1527, plus one live bug found while doing
the first.

VariableData.any dispatched through var_all
-------------------------------------------
`VariableData.any` reduced via `var_all`, making it "is there a variable whose
elements are ALL True" rather than "is ANY element True". For an array like
`[True, False]` it answered False.

That is a correctness bug in `OptimisationState.valid`, which asks
`(parameters < lower_limit).any()`: a parameter vector with SOME components
outside their limits was reported valid, and only a variable violating on
EVERY component was caught. `MeanField`'s `valid.any()` under-reported the same
way. Both call sites want a real `any`; the other four `.any()` call sites in
the library are on numpy arrays and are untouched.

OptimisationState.valid guards on truthiness
--------------------------------------------
`if self.lower_limit and ...` now `is not None`. `lower_limit`/`upper_limit` are
VariableData (a dict keyed by free variable) or None, so the old form worked
only by accident of dict truthiness being non-emptiness. PyAutoLabs#1527's follow-up list
read it as a scalar test and recorded a `0.0` bug that does not exist — but the
guard was one type change away from making it real.

Behaviour-preserving: the only case whose guard differs is the empty
VariableData, where the comparison is empty and `.any()` is False either way.

Redundant limits overrides removed
----------------------------------
PyAutoLabs#1527 made `Prior.limits` derive from `lower_limit`/`upper_limit`, leaving the
`UniformPrior`, `LogUniformPrior` and `TruncatedGaussianPrior` overrides exact
duplicates. The base coerces with `float()` and the overrides did not, so this
was a possible type change — measured, and it is not one: all three store
Python floats already, and under `jax.jit` a prior never reaches `limits`
anyway (`tree_unflatten` -> `__init__` calls `float()` on the tracer and raises
first, with or without this change). Now-unused `Tuple` imports dropped.

Tests
-----
`OptimisationState.valid` and `VariableData.any` had NO test coverage, so the
suite would have stayed green through any change to either — the process lesson
from PyAutoLabs#1477. New test_autofit/graphical/test_optimisation_state_valid.py covers
both, verified by inversion: reverting `var_any` to `var_all` fails 3 of the 8.

Full suite: 2186 passed, 36 skipped (baseline 2178/36, +8 new).

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pending-release PR queued for the next release build

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: LogGaussianPrior declares its own (0, inf) support

2 participants