A positive-support prior family gets no wall at its support floor, so negative parameter values are accepted, simulated and scored
Nobody has claimed this yet.
- 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:
-
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 fromlb/ub(lo_theta = -np.inf if lb is None else float(lb)), and when both u-bounds are infinite it setsself.bounded = False; self.lower_bound = -np.inf; self.upper_bound = np.inf. The family's ownsupport_lo_u = 0.0(pybnf/priors/gamma.py:20 and the eight siblings) is never consulted here.support_lo_uhas exactly one consumer in the package: pybnf/config.py:3826 inside_graded_truncation_bounds, which beginsif lower is None: return lower, upper(config.py:3823-3824), so an unbounded declaration never reaches the floor logic. -
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. -
Upstream gate does NOT block the config.
Configuration._check_variable_keyword_combinationonly rejects an unbounded prior whenfit_type in start_point_types, i.e. when the fit type registeredrefiner=True. Grepping the registrations,refiner=Trueis 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. -
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). Noprior_logpdfappears 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 onhas_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.) -
No test pins the current behavior for a positive-support family. The only assertion of
lower_bound == -np.infin 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. -
The strongest evidence it is a gap rather than intent is the asymmetry inside
_graded_truncation_boundsitself: a user-written finitelowerbelow the support floor is a hard PybnfError described as "a finite wall in the zero-density region", andlower: -infis 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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