PEtab export ignores the global noise_location = mean key, so a mean-centered lnnormal fit is exported as PEtab's median-centered log-normal with no refusal
Nobody has claimed this yet.
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 85/100
Research direction
Start in pybnf/petab/export.py at _resolve_noise and compare its handling with the existing refusal in _reduce_noise_spec. Read the related cases in tests/test_petab_export.py and add coverage for a global noise_location = mean with objective or whole-fit lnnormal. Done means the export refuses this configuration consistently while existing median-centered exports remain unchanged.
Written by the indexing model from the issue text.
Description
_resolve_noise (pybnf/petab/export.py:877-893) takes the whole-fit location only from the parsed noise spec. For objective = <token> that spec comes from _OBJECTIVE_DESUGAR (pybnf/objective.py:2808-2820), which always returns None for the location (lnnormal is at line 2813). A whole-fit noise_model = ... line without a location field also carries None. The exporter's only mean-centering refusal is in _reduce_noise_spec (export.py:1022), and it fires only when location == 'mean' appears in the spec itself. Nothing under pybnf/petab/ reads conf['noise_location']. The fitter does read it: Configuration._load_obj_func (pybnf/config.py:2986-2996) calls obj.set_default_location(location) (pybnf/objective.py:1198-1206). On a log scale this subtracts the moment correction, so PyBNF fits a mean-centered likelihood while the exported problem declares PEtab's median-centered log-normal.
The two spellings of the same thing are handled differently. A location = mean field on a whole-fit noise_model line is refused for every family: tests/test_petab_export.py:2274 checks this on a gaussian line, and an lnnormal line is refused the same way. The documented whole-fit equivalent, noise_location = mean (docs/config_keys.rst:435), is dropped without a warning.
Failure scenario
An edition-2 job with objective = lnnormal and noise_location = mean, exported with pybnf.petab.export.export_job. The export completes, and every file it writes is byte-identical to the export of the same job without noise_location. For the same simulation, PyBNF scores the two jobs differently (0.5752 vs 0.1348 below), but a PEtab tool reading either export scores the median version. At fixed sigma, a mean-centered lnnormal shifts the fitted prediction by a factor of exp(sigma^2/2). A PEtab tool fitting the export therefore estimates different parameters from the ones PyBNF estimates, and nothing reports the change of likelihood.
Reproduction
Copy examples/demo/parabola_v2.bngl into an empty directory and add these files.
positive.exp:
# time y y_SD
11 5.0 0.5
12 8.0 0.5
13 12.0 0.5
14 17.0 0.5
15 22.0 0.5
median.conf:
edition = 2
job_type = de
population_size = 8
max_iterations = 5
model: parabola_v2.bngl
experiment: positive, data: positive.exp
objective = lnnormal
uniform_var = v1 0.1 10
uniform_var = v2 0.1 10
uniform_var = v3 0.1 10
mean.conf is median.conf plus one line:
noise_location = mean
repro.py:
import filecmp, os
import numpy as np
from pybnf.parse import load_config
from pybnf.data import Data
from pybnf.petab.export import export_job
# A fixed simulation of y at the data times (x = t - 10, y = 0.5 x^2 + x + 3).
t = np.arange(11.0, 16.0); x = t - 10; f = 0.5 * x**2 + x + 3
sim = Data(); sim.cols = {'time': 0, 'x': 1, 'y': 2}; sim.headers = {0: 'time', 1: 'x', 2: 'y'}
sim.data = np.column_stack([t, x, f])
exp = Data(file_name='positive.exp')
yobs, s = exp.data[:, 1], 0.5
for tag, shift in (('median', 0.0), ('mean', s**2 / 2)):
conf = load_config(f'{tag}.conf')
ref = np.sum(0.5 * ((np.log(yobs) - (np.log(f) - shift)) / s) ** 2)
print(f'[{tag}] PyBNF objective = {conf.obj.evaluate(sim, exp):.6f} by hand = {ref:.6f}')
export_job(f'{tag}.conf', f'out_{tag}')
names = sorted(os.listdir('out_median'))
print('exports byte-identical:',
all(filecmp.cmp(f'out_median/{n}', f'out_mean/{n}', shallow=False) for n in names))
print(open('out_mean/observables.tsv').read())
Run it with BNGPATH pointing at a BioNetGen install (load_config requires one): python repro.py. Output:
[median] PyBNF objective = 0.134830 by hand = 0.134830
[mean] PyBNF objective = 0.575182 by hand = 0.575182
exports byte-identical: True
observableId observableFormula noiseFormula noiseDistribution noisePlaceholders
func_y y noiseParameter1_func_y log-normal noiseParameter1_func_y
The fitter builds the mean-centered objective, and it matches the hand formula with mu = ln f - sigma^2/2. The export of the mean job is identical to the export of the median job. The correct behaviour is the refusal the spec-level form already gets: "the whole-fit noise model is mean-centered (location = mean); PEtab v2 takes the prediction as the distribution median ...".
ADR-0031 calls mean "the explicit, native-only opt-in" and says the exporter always emits median. That sentence says what PEtab can represent. It is not a reason to write a mean-centered fit as a median one. The exporter's own refusal of the spec-level form cites ADR-0031, and a median-centered export of a mean-centered fit has a different optimum.
I also checked a whole-fit noise_model = lnnormal, sigma = fix_at 0.5 line with noise_location = mean. The fitter's noise model is mean-centered, and the export again writes func_y y 0.5 log-normal without a refusal. The same line with , location = mean added is refused.
Reachability
Any edition-2 job that sets noise_location = mean and uses a log-scale family the exporter accepts, i.e. lnnormal, by objective = lnnormal or by a whole-fit noise_model = lnnormal, ... line. The linear gaussian and laplace families give the same numbers, because mean equals median there. lognormal (log10) and neg_bin are refused for other reasons before the location matters. Per-observable noise_model <obs> = ... overrides are not affected: set_default_location changes only the class-default noise model, and the exporter already refuses a location = mean field on an override. The earlier export silent-drop fixes (#719, #733, #736, #738) covered the structural config entries. noise_location is a plain global key (accepted at pybnf/parse.py:192), so it was not covered by that work.
The fix is to have _resolve_noise raise the same NotImplementedError when conf.get('noise_location') == 'mean'.
Where
pybnf/petab/export.py:877-893(_resolve_noise: the location comes only from the spec)pybnf/petab/export.py:1022(the only mean-centering refusal, spec-level)pybnf/objective.py:2808-2820(_OBJECTIVE_DESUGAR: location alwaysNone)pybnf/config.py:2986-2996(the fitter appliesnoise_location)pybnf/objective.py:1198-1206(set_default_location)docs/config_keys.rst:435(noise_locationdocumented)docs/adr/0031-median-is-the-universal-centering-default-objective-surface-is-edition-gated.md(mean is the native-only opt-in)
Related: #899, #901, #896, #894, #738.
Found in a whole-codebase audit for silently wrong results (2026-09-23); the reproduction above was re-run independently of the original finding.
- Dominant language
- Python
- Stars
- 25
- Forks
- 25
- Avg merge
- 2h 26m
- Merged PRs (30d)
- 83
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.
More from lanl/PyBNF
-
documentation
Difficulty 1/5 Under an hour Newbie friendliness 91/100
-
bug silent-incorrectness
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
-
bug silent-incorrectness
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
-
bug silent-incorrectness
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
-
bug silent-incorrectness
Difficulty 2/5 1-3 hours Newbie friendliness 80/100
Similar issues
-
agent-ready documentation needs-triage
Difficulty 1/5 1-3 hours Newbie friendliness 88/100
-
workflow-status page template still says reusable workflows are "triggered only by workflow_call:" Open
Difficulty 1/5 Under an hour Newbie friendliness 92/100
-
instance instance add
Difficulty 1/5 Under an hour Newbie friendliness 72/100
searxng/searx-instances#939 · 1 comment ·
-
area-deployment area-integrations triage:bot-seen
Difficulty 2/5 Half a day Newbie friendliness 86/100
-
external
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
langchain-ai/langgraph#9074 · 1 comment ·