Skip to content

fix: UniformPrior bounds not enforced in NumPy-path objective #1489

Description

@Jammy2211

Overview

UniformPrior.log_prior_from_value short-circuits to return 0.0 on the NumPy path (if xp is np:) without evaluating the bound, while the JAX path immediately below returns -inf outside [lower_limit, upper_limit]. For every search with fom_is_log_likelihood=False on the NumPy path (Emcee, Zeus, Drawer, and LBFGS/BFGS under the default ClipperNone), the declared UniformPrior box is therefore not enforced anywhere in the objective — the sampled posterior may not be the declared model.

Reproduced. This issue includes the reproduction the Mind prompt asked for as step 1, and the effect is worse than "slow diffusion to the walls": with an unconstrained parameter under UniformPrior(-0.1, 0.1), a 30-walker × 600-step Emcee fit had 100% of accepted samples outside the box, with the parameter running away to |offset| ≈ 1e14 (max-likelihood sample at offset ≈ 2.1e10). Emcee's affine-invariant stretch move scales proposals by the walker spread, so an unpenalised flat direction grows exponentially, not as a random walk.

Plan

  • Reproduce on clean main — done (evidence below), on main @ fe9f813.
  • Human decision (required before any code): pick the fix shape — (A) strict NumPy path mirroring JAX, (B) clipper-style opt-in for the NumPy searches, (C) default LBFGS/BFGS to ClipperPriorBox. Recommendation below: A (+C as an orthogonal follow-up); B rejected for MCMC.
  • Implement the chosen fix with a measured before/after on a reference Emcee fit (chains, acceptance rate) — this changes behaviour for every existing Emcee/Zeus/LBFGS run and is a deliberate, measured change.
  • Correct the false phase-1 record: "the MCMC samplers reject -inf proposals so the walker simply stays put" — there is no -inf to reject on the NumPy path. The sentence lives in two places: PyAutoMind/complete/2026/08/prior-support-clipper.md (~line 290) and the autofit/non_linear/clipper.py module docstring (lines 20–23).
  • Unit + regression tests pinning bound enforcement on both paths.

Reproduction evidence

Environment: fresh clone of main @ fe9f813 (autofit 2026.8.17.1), Python 3.12, NumPy path throughout.

Part A — the objective directly (Fitness(model, analysis, fom_is_log_likelihood=False), the Emcee convention), 4-parameter 1D Gaussian model plus an offset parameter the likelihood ignores, declared UniformPrior(-0.1, 0.1):

log posterior, offset= 0.05 (in bounds) : 101.836275
log posterior, offset= 5.00 (OUT of box): 101.836275
identical (no penalty applied)          : True
out-of-box penalised with -inf?         : False
log_prior_list_from_vector(out-of-box)  : [0.0, 0.0, 0.0, 0.0]  (sum=0.0)
instance_from_vector raises?            : no (offset=5.0)

Part B — end-to-end Emcee (af.Emcee(nwalkers=30, nsteps=600), walkers initialised in-box from the priors):

total accepted samples                  : 450
samples with offset OUTSIDE [-0.1, 0.1] : 450 (100.0%)
min / max sampled offset                : -3.3e+14 / 4.6e+14
max-likelihood offset                   : 2.1e+10
Reproduction script (self-contained, runs on clean main)
import numpy as np

import autofit as af
from autofit.non_linear.fitness import Fitness


class GaussianWithOffset:
    def __init__(self, centre=50.0, normalization=1.0, sigma=5.0, offset=0.0):
        self.centre = centre
        self.normalization = normalization
        self.sigma = sigma
        self.offset = offset  # ignored by the likelihood: unconstrained

    def model_data_from(self, xvalues):
        transformed = xvalues - self.centre
        return (
            self.normalization
            / (self.sigma * np.sqrt(2.0 * np.pi))
            * np.exp(-0.5 * (transformed / self.sigma) ** 2)
        )


class AnalysisIgnoringOffset(af.Analysis):
    _use_jax = False  # NumPy path

    def __init__(self, data, noise_map):
        self.data = data
        self.noise_map = noise_map
        self.xvalues = np.arange(data.shape[0], dtype=float)

    def log_likelihood_function(self, instance):
        model_data = instance.model_data_from(self.xvalues)
        residual_map = self.data - model_data
        chi_squared = float(np.sum((residual_map / self.noise_map) ** 2.0))
        noise_normalization = float(np.sum(np.log(2 * np.pi * self.noise_map**2.0)))
        return -0.5 * (chi_squared + noise_normalization)


rng = np.random.default_rng(1)
xvalues = np.arange(100, dtype=float)
truth = GaussianWithOffset(centre=50.0, normalization=25.0, sigma=5.0)
noise = 0.1
data = truth.model_data_from(xvalues) + rng.normal(0.0, noise, size=xvalues.shape)
noise_map = np.full(xvalues.shape, noise)

model = af.Model(GaussianWithOffset)
model.centre = af.UniformPrior(lower_limit=0.0, upper_limit=100.0)
model.normalization = af.UniformPrior(lower_limit=1e-2, upper_limit=1e2)
model.sigma = af.UniformPrior(lower_limit=0.1, upper_limit=25.0)
model.offset = af.UniformPrior(lower_limit=-0.1, upper_limit=0.1)  # the narrow box

analysis = AnalysisIgnoringOffset(data=data, noise_map=noise_map)

# Part A: direct objective check (Emcee convention: fom_is_log_likelihood=False)
fitness = Fitness(model=model, analysis=analysis, paths=None, fom_is_log_likelihood=False)

in_bounds = [50.0, 25.0, 5.0, 0.05]
out_bounds = [50.0, 25.0, 5.0, 5.0]  # offset 50x outside the box

fom_in = fitness(parameters=in_bounds)
fom_out = fitness(parameters=out_bounds)
print(fom_in, fom_out, fom_in == fom_out, np.isneginf(fom_out))
print(model.log_prior_list_from_vector(vector=out_bounds, xp=np))

# Part B: end-to-end Emcee fit
search = af.Emcee(
    name="repro_uniform_bounds",
    nwalkers=30,
    nsteps=600,
    iterations_per_full_update=1_000_000,
    number_of_cores=1,
)
result = search.fit(model=model, analysis=analysis)

params = np.asarray(result.samples.parameter_lists)
offset_samples = params[:, 3]
outside = (offset_samples < -0.1) | (offset_samples > 0.1)
print(offset_samples.size, int(outside.sum()), offset_samples.min(), offset_samples.max())

The fix decision (human-required)

Three shapes were scoped in the Mind prompt; they are not equivalent:

A. Strict NumPy path (recommended). Make UniformPrior.log_prior_from_value evaluate the bound on the NumPy path exactly as the JAX path does (0.0 in bounds, -inf outside), and close the same gap in LogUniformPrior above upper_limit (its NumPy path currently returns a finite -log(value) there; below 0 it is already -inf). Emcee and Zeus natively treat a -inf log-probability as a rejected proposal, so this makes the phase-1 record's rejection sentence true going forward — rejection is precisely the restoring mechanism MCMC should have, per clipper.py's own doctrine. This is a behaviour change for every existing Emcee/Zeus/Drawer/LBFGS run (chains, acceptance rates and stored results all move), hence the measured before/after. Two knock-ons to handle:

  • Fitness.log_likelihood_from inverts fom - sum(log_prior); with both -inf that is NaN. Guard the inversion so bookkeeping (history, quick-update, resume sanity check) stays finite.
  • For the gradient searches a -inf objective re-creates the dead-lane failure the phase-1 clipper work characterised — which is exactly what ClipperPriorBox exists for; that pairs with C rather than blocking A.

B. Clipper-style opt-in for the NumPy MCMC searches (rejected). Projecting MCMC proposals onto the box breaks detailed balance (probability mass piles up on the boundary) — the sampled distribution would still not be the declared model, just wrong in a subtler way. Rejection (A) is the correct MCMC mechanism; Clipper remains the right tool for the gradient searches only.

C. Default LBFGS/BFGS to ClipperPriorBox (orthogonal; can land with A or separately). The default ClipperNone passes bounds=None to scipy, so the box is unenforced there too. Making ClipperPriorBox the default gives scipy declarative bounds with no change to the objective. Interacts with A: with a strict prior and no clipper, an LBFGS line-search step that leaves the box sees an infinite objective — C prevents that class of failure.

Detailed implementation plan

Click to expand

Affected Repositories

  • PyAutoFit (primary)
  • PyAutoMind (phase-1 record correction — complete/2026/08/prior-support-clipper.md)

Branch Survey (web session — fresh clones)

Repository Current Branch Dirty?
PyAutoFit main @ fe9f813 clean

Suggested branch: feature/uniform-prior-bounds-numpy-path

Blocked: PyAutoFit is currently claimed by stored-sample-reconstruction-guard (feature/stored-sample-reconstruction-guard) and version-stamp-sync-guards (feature/version-stamp-sync-guards) — this task is registered in PyAutoMind/planned.md and cannot start until those ship.

Implementation Steps (written for option A + C; adjust to the decision)

  1. autofit/mapper/prior/uniform.pylog_prior_from_value: evaluate the bound on the NumPy path (0.0 if lower_limit <= value <= upper_limit else -np.inf; scalar-friendly), keep the xp.where JAX branch. Update the docstring ("always zero, provided the value is between the lower and upper limit" — make the proviso real).
  2. autofit/mapper/prior/log_uniform.py — enforce upper_limit on the NumPy path (currently finite -log(value) above it); rewrite the docstring that documents the asymmetry as intended behaviour.
  3. autofit/non_linear/fitness.pylog_likelihood_from: guard the -inf - (-inf) = NaN inversion (e.g. only subtract the prior where the prior sum is finite; a -inf FoM with -inf prior is a rejected point, not a likelihood).
  4. autofit/non_linear/clipper.py — correct the module docstring (lines 20–23): under the strict path the MCMC rejection sentence becomes true; rephrase to describe the actual mechanism, citing this issue.
  5. (Option C) autofit/non_linear/search/mle/bfgs/search.py — default clipper=ClipperPriorBox() for LBFGS (and decide for plain BFGS, which ignores bounds — warn per phase-1 finding).
  6. Tests (test_autofit): unit tests for log_prior_from_value in/out of bounds on the NumPy path for UniformPrior + LogUniformPrior (parity with JAX path where jax is installed); a seeded fast Emcee regression asserting all accepted samples of an unconstrained parameter stay inside the declared box.
  7. Before/after measurement on a reference Emcee fit (acceptance fraction, posterior on constrained parameters) recorded in the PR body.
  8. PyAutoMind: correct the sentence in complete/2026/08/prior-support-clipper.md (record-correction commit referencing this issue), per the prompt's step 3.

Key Files

  • autofit/mapper/prior/uniform.py — the unguarded NumPy branch (the bug).
  • autofit/mapper/prior/log_uniform.py — same gap above upper_limit.
  • autofit/non_linear/fitness.py — objective assembly (fom_is_log_likelihood=False) + log_likelihood_from inversion.
  • autofit/non_linear/clipper.py — phase-1 doctrine + the false rejection sentence; ClipperPriorBox for option C.
  • autofit/non_linear/search/mle/bfgs/search.py — LBFGS bounds plumbing (option C).
  • PyAutoMind/complete/2026/08/prior-support-clipper.md — the record to correct.

Out of scope (per the Mind prompt)

  • Per-parameter step scaling (active/per_parameter_step_scaling.md) — Emcee is immune to that and fully exposed to this; keep them straight.
  • Changing Prior classes in ways that alter the nested samplers (Nautilus/Dynesty propose in the unit cube and are unaffected; log_prior_from_value does not enter their objective, but any wider Prior surgery is off the table).

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