Skip to content

fix: gradient-fit a piecewise model with a count likelihood#522

Merged
wshlavacek merged 1 commit into
mainfrom
fix/gradient-piecewise-and-zero-mean-count
Jul 26, 2026
Merged

fix: gradient-fit a piecewise model with a count likelihood#522
wshlavacek merged 1 commit into
mainfrom
fix/gradient-piecewise-and-zero-mean-count

Conversation

@wshlavacek

Copy link
Copy Markdown
Collaborator

Two independent defects made any gradient fit (lbfgs / trf / gntr) score inf or nan
everywhere on a model built from if()-gated rate laws with a negative-binomial count likelihood
— the shape of every COVID-19 compartmental job in the corpus (Lin 2021, Mallela 2024). Neither is
reachable from a .conf; both are ordinary bugs.

1. Over-requested output-sensitivity selectors

BngsimModel._extract_output_sensitivities asked bngsim for an expression: sensitivity for
every global function whenever print_functions was on. bngsim differentiates function bodies
symbolically and refuses, per function, any body carrying a non-differentiable construct — if(),
a comparison, min/max/abs/floor, a table function (lanl/bngsim#198) — and
Result.output_sensitivities raises ValueError if a refused function is among the requested
selectors.

has_sensitivities_expressions only says "an expression block exists", not "every expression is
differentiable"
, so it could not stand in for the per-function verdict. A piecewise model has
every function refused — all 14 of the Mallela 2024 model's are if() chains — so every
simulation died with ValueError and the fit's objective was inf at every point.

The request is now filtered to the differentiable expressions
(_differentiable_expression_names, reading bngsim's per-function verdict map). Notes:

  • consumers address the tensor by selector name (OutputSensitivities.slice_for), never
    positionally against the Data columns, so dropping a selector is safe;
  • stack_scan_sensitivities takes selectors from the first dose point and every point shares them
    (the verdict is a property of the model, not the point);
  • a scored column is essentially never one of these functions — the fit observable is an
    observable. If one ever were, its absence now surfaces as a clean "no sensitivity column"
    gradient error instead of a dead simulation;
  • no support map (an older bngsim, or a Result loaded from disk) ⇒ nothing to filter on ⇒
    behavior is exactly as before;
  • the same filter is applied on the KINSOL steady-state path, which builds the same selector list.
    The SBML path (bngsim_sbml_model.py) requests only species: selectors and is unaffected.

2. Divide-by-zero in the negative-binomial derivatives

d(data_fit)/d(mean) = r (mean − obs) / (mean (r + mean)) is 0/0 → nan (observed zero) or −inf
at a MEAN-centered prediction of exactly 0. That is not a pathological point: any model gated
off over part of the fit window predicts exactly zero there — an epidemic model before its start
time t0, a stimulus-driven readout before the stimulus — and data_fit scores such a point
finitely by clipping prob = r/(r+mean) at 1 − 1e-10. The nan propagated through the
gradient and out to the optimizer, which then tried to assign a free parameter nan:

pybnf.pset.OutOfBoundsException: Free parameter beta cannot be assigned the value nan

_mean_for_slope now mirrors the value path's clip on the mean side (prob ≤ 1 − _PROB_CLIP is
exactly mean ≥ r · _PROB_CLIP/(1 − _PROB_CLIP)), so every expression that divides by the mean
gets the finite slope belonging to the objective actually being scored. MEDIAN centering never
reaches the floor (_mean_for_median is strictly positive), so it is a no-op there.

location_fisher deliberately keeps its mean <= 0 → 0.0 guard rather than adopting the floor,
and now says why: the true I_mean diverges as 1/mean, so flooring it would put a
~1/(r·_PROB_CLIP) weight on every gated-off row and let those rows dominate the EFIM, crushing
the gntr trust-region step. Zero — "this row carries no information about the mean" — is the
stable reading, and the gradient (which does floor) supplies the direction.

Verification

  • Full suite: 3338 passed, 9 skipped (rebased on main).
  • New unit tests: three for the selector filter (one differentiable + one refused; all refused;
    no support map) and three for the count family at a zero prediction (finite slope and dispersion
    gradient for observed 0 / 1 / 4212; and the slope equals the un-clipped closed form at the very
    mean the value path's clip already pretends to score, so value and gradient stay consistent).
  • End to end on the Mallela et al. 2024 NYC job (42 species, 88 reactions, 14 if() functions,
    649 daily counts, neg_bin with a fixed dispersion): before, every start returned inf; after,
    L-BFGS-B converges to nll 5235.80, improving on the published MAP's 5313.95 in the same
    box. The same holds for all four MSAs in that paper — see
    pybnf-jobs/Mallela-2024/*/VALIDATION.md in the BNGL-Models corpus.

Follow-up (not in this PR)

bngsim exposes the per-function verdict only as Result._expression_sens_support; a public
accessor upstream would let this filter drop the private-attribute getattr.

Two independent defects made `fit_type = lbfgs` (or any gradient fit) score `inf`
or `nan` everywhere on a model built from `if()`-gated rate laws with a
negative-binomial count likelihood -- the shape of every COVID-19 compartmental
job in the corpus (Lin 2021, Mallela 2024).

1. Over-requested output-sensitivity selectors. `_extract_output_sensitivities`
   asked bngsim for an `expression:` sensitivity for EVERY global function
   whenever `print_functions` was on. bngsim differentiates function bodies
   symbolically and refuses, per function, any body carrying a non-differentiable
   construct -- `if()`, a comparison, min/max/abs/floor, a table function
   (lanl/bngsim#198) -- raising `ValueError` if such a selector is requested.
   `has_sensitivities_expressions` only says "an expression block exists", so it
   could not stand in for "this expression is differentiable". A piecewise model
   has every function refused, so every simulation died and the objective was
   `inf` at every point. Filter the request to the differentiable expressions
   (`_differentiable_expression_names`); a scored column is essentially never one
   of these, and if it were, its absence now surfaces as a clean gradient error
   rather than a dead simulation. No support map (older bngsim, a Result loaded
   from disk) => unchanged behavior.

2. Divide-by-zero in the negative-binomial derivatives. `d(data_fit)/d(mean) =
   r (mean - obs) / (mean (r + mean))` is `0/0 -> nan` (observed zero) or `-inf`
   at a MEAN-centered prediction of exactly 0. That is not a pathological point:
   any model gated off over part of the fit window predicts exactly zero there --
   an epidemic model before its start time t0 -- and `data_fit` scores it finitely
   by clipping `prob = r/(r+mean)` at `1 - 1e-10`. The `nan` propagated out to the
   optimizer, which then tried to assign a free parameter `nan`. Mirror the value
   path's clip on the mean side (`_mean_for_slope`) so every expression that
   divides by the mean gets the finite slope belonging to the objective actually
   being scored. MEDIAN centering never reaches the floor.

Verified end to end on the Mallela et al. 2024 NYC job (42 species, 88 reactions,
14 `if()` functions, 649 daily counts, neg_bin with a fixed dispersion): before,
every start returned `inf`; after, L-BFGS-B converges to nll 5235.80, improving on
the published MAP's 5285.16 in the same box.
@wshlavacek
wshlavacek merged commit 7913d03 into main Jul 26, 2026
9 checks passed
@wshlavacek
wshlavacek deleted the fix/gradient-piecewise-and-zero-mean-count branch July 26, 2026 00:40
wshlavacek added a commit to wshlavacek/BNGL-Models that referenced this pull request Jul 26, 2026
The two piecewise-gradient fixes these jobs depend on are now on PyBNF main:
lanl/PyBNF#522, merged as 7913d037. Link the PR from each slug's VALIDATION.md
(Toolchain requirement) and README (Run), and from the paper-level README,
instead of naming the commit subject.

Re-verified against merged main with no local patch on the path: the nyc fit
still converges to nll 5235.799376765947.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant