Skip to content

UniformPrior bounds are not enforced in the objective on the NumPy path #1484

Description

@Jammy2211

Overview

UniformPrior.log_prior_from_value short-circuits to return 0.0 whenever
xp is np, without ever evaluating the bound. The JAX path immediately below
it does the right thing. So a UniformPrior box is enforced in the objective
under JAX and silently not enforced under NumPy — which is the path Emcee,
Zeus, Drawer and LBFGS take.

This is not a regression. git log -L on the method puts the unconditional
NumPy return 0.0 at be2dbd0c (2025-11-30); the xp-dispatch commit that made
the JAX side strict is 2e35407 (2026-05-14). The asymmetry was created by
tightening JAX, not by loosening NumPy.

Plan

  • Reproduce first. Run an Emcee fit with a deliberately unconstrained
    parameter under a narrow UniformPrior and show samples landing outside the
    box. If it cannot be reproduced, say so — the mechanism is still worth closing,
    but the framing changes.
  • Choose the fix deliberately: a strict NumPy path (behaviour change for every
    existing Emcee/Zeus/LBFGS run — needs a measured before/after), versus a
    Clipper-style opt-in for the NumPy searches, versus making LBFGS default to a
    real clipper. These are not equivalent and the choice is a human one.
  • Correct the phase-1 record, which is cited elsewhere as the reason the clipper
    was scoped narrowly.
Detailed implementation plan

The code

autofit/mapper/prior/uniform.py:

def log_prior_from_value(self, value, xp=np):
    if xp is np:
        return 0.0                      # <-- bound never evaluated
    in_bounds = (value >= self.lower_limit) & (value <= self.upper_limit)
    return xp.where(in_bounds, xp.zeros_like(value), -xp.inf)

Outside the box: NumPy sum(log_prior) == 0.0; JAX -inf.

Per prior type, NumPy path, outside support

prior NumPy result penalised?
UniformPrior 0.0 no
LogUniformPrior above upper_limit finite -log(value) no
LogUniformPrior below 0 -inf yes
TruncatedGaussianPrior -inf yes
GaussianPrior unbounded by design n/a

LogUniformPrior's own docstring already says this outright — "The NumPy path is
otherwise unnormalised and unbounded … The JAX path additionally returns
-inf outside [lower_limit, upper_limit]". The asymmetry is documented there
and undocumented for UniformPrior.

No other guard exists

instance_from_vector accepts an out-of-box vector without raising; Emcee and
Zeus have no bounds handling of their own; and the strict logpdf (which does
return -inf) is used only by the messages / EP machinery, never by the search
fitness path.

Who is exposed (fom_is_log_likelihood=False)

search UniformPrior box enforced?
Emcee, Zeus, Drawer no
LBFGS / BFGS only with a clipper set — the default ClipperNone passes bounds=None, so no
BlackJAXNUTS, MultiStartGradient (JAX) yes (-inf)
Nautilus, Dynesty unaffected — they propose in the unit cube

This corrects the phase-1 record

The prior-support clipper record (#1477) claims "the MCMC samplers reject -inf
proposals so the walker simply stays put". There is no -inf to reject for a
UniformPrior on the NumPy path.
That sentence is the load-bearing
justification for scoping the clipper to the gradient searches only, and it is
wrong for the reason above. Whatever lands here should correct it.

Severity, stated honestly

Not "results are wrong" — "the sampled posterior may not be the declared
model"
. Walkers are initialised within limits and the likelihood usually falls
away outside the sensible region, so the exposure is for poorly-constrained
parameters
— exactly the ones that diffuse to the walls. Nautilus and Dynesty
are unaffected and are the production workhorses, so the blast radius is
Emcee / Zeus / Drawer / LBFGS users.

NOT VERIFIED: whether any real past fit actually drifted outside a box. The
mechanism is unguarded; that it has bitten is unproven. Establishing that is task
one, not an assumption.

Why it is not fixed inline

Making the NumPy path strict changes behaviour for every existing Emcee / Zeus /
LBFGS run
. A walker that currently wanders outside a box and comes back would
start being rejected; chains, acceptance rates and stored results all move.

Orthogonal to #1483 — keep them straight

Emcee is immune to the per-parameter step-scaling problem (affine invariance)
and fully exposed to this one. A search can be immune to one and exposed to
the other. This must not be absorbed into #1483.

Key Files

  • autofit/mapper/prior/uniform.py — the short-circuit
  • autofit/mapper/prior/log_uniform.py — the same asymmetry, already documented
  • autofit/non_linear/clipper.py — the existing opt-in mechanism, if that route wins
  • autofit/non_linear/search/mle/bfgs/search.pybounds=None under ClipperNone

Original Prompt

Click to expand starting prompt

UniformPrior bounds are not enforced in the objective on the NumPy path

Type: bug
Target: autofit
Repos:

  • PyAutoFit
    Difficulty: medium
    Autonomy: human-required
    Priority: high
    Status: formalised

What this is

UniformPrior.log_prior_from_value short-circuits to return 0.0 whenever
xp is np, without ever evaluating the bound
(autofit/mapper/prior/uniform.py, in the if xp is np: branch). The JAX path
immediately below it does the right thing:

def log_prior_from_value(self, value, xp=np):
    if xp is np:
        return 0.0
    in_bounds = (value >= self.lower_limit) & (value <= self.upper_limit)
    return xp.where(in_bounds, xp.zeros_like(value), -xp.inf)

So for a value outside the box:

  • NumPy: sum(log_prior) == 0.0not penalised
  • JAX: -inf — correctly penalised

Per prior type on the NumPy path, outside support:

prior NumPy result outside support penalised?
UniformPrior 0.0 no
LogUniformPrior above upper_limit finite -log(value) no
LogUniformPrior below 0 -inf yes
TruncatedGaussianPrior -inf yes
GaussianPrior unbounded by design n/a

LogUniformPrior's own docstring already states this outright — "The NumPy path
is otherwise unnormalised and unbounded … The JAX path additionally
returns -inf outside [lower_limit, upper_limit]" — so the asymmetry is
documented there and undocumented for UniformPrior.

No other guard exists. instance_from_vector accepts an out-of-box vector
without raising; Emcee and Zeus have no bounds handling of their own; and the
strict logpdf (which does return -inf) is used only by the messages / EP
machinery, never by the search fitness path.

Who is exposed

For searches with fom_is_log_likelihood=False:

search UniformPrior box enforced?
Emcee, Zeus, Drawer no
LBFGS / BFGS only if a clipper is set — the default ClipperNone passes bounds=None, so no
BlackJAXNUTS, MultiStartGradient (JAX) yes (-inf)
Nautilus, Dynesty unaffected — they propose in the unit cube

This corrects the phase-1 record

complete/2026/08/prior-support-clipper.md claims "the MCMC samplers reject
-inf proposals so the walker simply stays put". There is no -inf to reject
for a UniformPrior on the NumPy path.
That sentence is the load-bearing
justification for why the clipper was scoped to the gradient searches only, and
it is wrong for the reason above.

Severity, honestly

Not "results are wrong" — "the sampled posterior may not be the declared model".
Walkers are initialised within limits and the likelihood usually falls away
outside the sensible region, so the exposure is for poorly-constrained
parameters
— exactly the ones that diffuse to the walls. Nautilus and Dynesty
are unaffected and are the production workhorses, so the blast radius is
Emcee / Zeus / Drawer / LBFGS users.

NOT VERIFIED: whether any real past fit actually drifted outside a box. The
mechanism is unguarded; that it has bitten is unproven. Establishing that is the
first task, not an assumption.

Not a regression

Git history puts the NumPy return 0.0 before the May-2026 JAX
xp-dispatch commit that made the JAX side strict. The asymmetry was created by
tightening JAX, not by loosening NumPy. This is long-standing behaviour.

Why this is not fixed inline

Making the NumPy path strict changes behaviour for every existing Emcee / Zeus
/ LBFGS run
. A walker that currently wanders outside a box and comes back would
start being rejected; chains, acceptance rates and stored results all move. That
is a deliberate, measured change with its own before/after, not a drive-by.

Orthogonal to per-parameter step scaling — keep them straight

Emcee is immune to the step-scaling problem (affine invariance) and fully
exposed
to this one. A search can be immune to one and exposed to the other. Do
not let this be absorbed into active/per_parameter_step_scaling.md.

Suggested shape of the work

  1. Reproduce first. Run an Emcee fit with a deliberately unconstrained
    parameter under a narrow UniformPrior and show samples outside the box. If
    it cannot be reproduced, say so — the mechanism would still be worth closing,
    but the framing changes.
  2. Decide the fix: strict NumPy path (behaviour change, needs a measured
    before/after) versus a Clipper-style opt-in for the NumPy searches versus
    making LBFGS default to a real clipper. These are not equivalent and the
    choice is a human one.
  3. Whichever lands, correct the phase-1 record's "MCMC samplers reject -inf"
    sentence — it is cited elsewhere as a reason the clipper was scoped narrowly.

Out of scope

  • Per-parameter step scaling (active/per_parameter_step_scaling.md).
  • Changing Prior classes in a way that alters the nested samplers, where the
    hard box currently works correctly.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions