lanl / lanl/PyBNF

A positive-support prior family gets no wall at its support floor, so negative parameter values are accepted, simulated and scored

Open
#711 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
Dominant language
Python
Stars
25
Forks
24
Avg merge
2h 6m
Merged PRs (30d)
95

Description

What happens

In FreeParameter.init the reflecting box for a family whose support is not the family's own box is built only from the user's truncation bounds: lo_theta = -np.inf if lb is None else float(lb) (line 2277), and when both sides come out infinite in u the parameter is left self.bounded = False; self.lower_bound = -np.inf; self.upper_bound = np.inf (lines 2296-2301). The families with support_lo_u = 0.0 (gamma, exponential, chisquare, rayleigh, weibull, inv_gamma, half_normal, half_cauchy, beta) therefore get NO wall at 0 unless the user happens to write lower:/upper: -- and config._graded_truncation_bounds, the one place that consults fam.support_lo_u (pybnf/config.py:3824-3826), returns immediately when lower is None, so the floor is never applied to an unbounded declaration. Consequence: set_value at line 2399 tests new_value < self.lower_bound against -inf, so a negative proposal is stored verbatim and handed to the simulator. Verified by running: FreeParameter('g','gamma_var',2.0,1.0,value=1.0) reports lower_bound=-inf, upper_bound=inf, prior_support()==(0.0, inf), set_value(-5.0).value == -5.0, and prior_logpdf(-5.0) == -inf. It is also silent: the only alarm, pybnf/algorithms/samplers/base.py:307 (if not np.isfinite(contribution) and prior_var.has_bounded_support), is gated on has_bounded_support, which is False for exactly these families, so no warning is logged. For a population optimizer (DE/PSO/scatter search, which config._check_variable_keyword_combination explicitly allows to carry an unbounded prior and which do not add the prior to the objective), the fit spends simulations in -- and can report a best fit from -- the region where the declared prior density is exactly zero, e.g. a negative rate constant.

Reproduction

Unit-level, executed:

cd /Users/l119605/Code/PyBNF
uv run --extra tests --extra petab python -c "
from pybnf.pset import FreeParameter
v = FreeParameter('k','gamma_var',2.0,1.0,value=1.0)
print(v.lower_bound, v.upper_bound, v.bounded, v.has_bounded_support)
print(v.prior_support(), v.set_value(-5.0).value, v.prior_logpdf(-5.0))"

Observed:
-inf inf False False
(0.0, inf) -5.0 -inf

Expected: either the box is floored at the family's support (lower_bound == 0.0, so set_value(-5.0) folds back inside), or set_value/the box-escape warning fires. Actual: -5.0 is stored verbatim with no warning, in a region whose declared prior logpdf is -inf.

Config-level (not executed, reachability established by reading config.py):
job_type = de
gamma_var = k__FREE 2 1 # no lower:/upper:
Passes _check_variable_keyword_combination because de is not registered with refiner=True, so the "Box-mode optimizer requires a bounded prior" branch cannot fire. DE mutation in u then has no wall at 0, and pybnf/algorithms/optimizers/ contains no prior_logpdf call, so a negative k is simulated and its objective competes for best fit.

Equivalent for exponential_var, chisquare_var, rayleigh_var, weibull_var, inv_gamma_var, half_normal_var, half_cauchy_var, beta_var (beta additionally has no ceiling at 1).

Verification notes

I tried to kill this and could not. What I checked:

  1. The mechanism is exactly as claimed. /Users/l119605/Code/PyBNF/pybnf/pset.py lines ~2275-2301: for a family with has_bounded_support == False, the reflecting box comes only from lb/ub (lo_theta = -np.inf if lb is None else float(lb)), and when both u-bounds are infinite it sets self.bounded = False; self.lower_bound = -np.inf; self.upper_bound = np.inf. The family's own support_lo_u = 0.0 (pybnf/priors/gamma.py:20 and the eight siblings) is never consulted here. support_lo_u has exactly one consumer in the package: pybnf/config.py:3826 inside _graded_truncation_bounds, which begins if lower is None: return lower, upper (config.py:3823-3824), so an unbounded declaration never reaches the floor logic.

  2. I ran the repro (uv run, read-only script in the scratchpad). Output: lb,ub: -inf inf bounded: False / support: (0.0, inf) has_bounded_support: False / set_value(-5): -5.0 / logpdf: -inf. So a negative value is stored in a parameter whose declared prior density there is exactly zero.

  3. Upstream gate does NOT block the config. Configuration._check_variable_keyword_combination only rejects an unbounded prior when fit_type in start_point_types, i.e. when the fit type registered refiner=True. Grepping the registrations, refiner=True is on simplex, cmaes, powell, ms, gntr, trf, lbfgs only — de/pso/ss are not refiners, so for them the method returns after the 'point' check and an unbounded positive-support prior is accepted.

  4. No downstream normalization for the population optimizers. prior_support() is consumed only by local_base.py (the refiners' box), hmc.py (bijectors), profile_likelihood.py (a diagnostic) and algorithms/base.py:2345 (the start-point record). No prior_logpdf appears anywhere under pybnf/algorithms/optimizers/, so DE/PSO/SS do not add the prior to the objective and nothing rejects a zero-density point. The one alarm, samplers/base.py:307, is gated on has_bounded_support, which is False for exactly these families. (For the samplers themselves the -inf contribution makes the acceptance ratio reject the proposal, so the MCMC math stays correct; the exposure is the prior-ignoring population optimizers.)

  5. No test pins the current behavior for a positive-support family. The only assertion of lower_bound == -np.inf in tests/test_priors.py is line 461, test_laplace_is_unbounded_no_reflecting_box, which is a genuinely doubly-unbounded family — correct there, and not a statement about gamma/half_normal/etc.

  6. The strongest evidence it is a gap rather than intent is the asymmetry inside _graded_truncation_bounds itself: a user-written finite lower below the support floor is a hard PybnfError described as "a finite wall in the zero-density region", and lower: -inf is warned and canonicalized up to the floor. So the design explicitly treats the zero-density region as off limits — but omitting the bounds entirely, the documented "untruncated prior" shorthand, silently yields a (-inf, inf) box for a family whose untruncated support is (0, inf).

What I could not do in the time budget: run an actual DE fit and observe a negative proposal being simulated and scored. Reachability there rests on code reading (DE mutation arithmetic is unconstrained for a parameter with no box, and set_value at pset.py:2399 compares against -inf), not on an executed end-to-end run — hence "likely" rather than "certain".

This lands in committed code (pybnf/pset.py and pybnf/config.py are not among the dirty files).

Where

pybnf/pset.py:2277 — severity medium, bug class silent-incorrectness.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with pybnf/pset.py around FreeParameter.init and set_value, then inspect _graded_truncation_bounds in pybnf/config.py and the existing tests/test_priors.py coverage. Run the supplied gamma_var reproduction and add regression coverage for the listed positive-support families. Done means their support floors are respected and negative values cannot be silently stored, simulated, or scored without the relevant warning or rejection.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.