"""Shared scaffolding for the gradient-based local optimizers (TRF/LM + L-BFGS-B, #386).
The metaheuristic fit types (de, pso, ss, cmaes, …) only ever ask each evaluated
``PSet`` for its scalar objective value. A **gradient** optimizer instead consumes
the residual vector + residual-Jacobian (TRF / Levenberg–Marquardt) or the scalar
gradient (L-BFGS-B) that #385 assembles from bngsim's forward output-sensitivity
tensor. :class:`GradientOptimizer` factors out everything a new gradient method
needs so a leaf (``trf.py``, ``lbfgs.py``) implements only its step math --
mirroring how :class:`StartPointOptimizer` factors the start-point / ``u`` ↔ ``PSet``
plumbing out of Powell and CMA-ES.
What this base provides
-----------------------
* **The edition + capability gate** (:meth:`_gate_gradient_supported`). Gradient
fitting consumes the edition-2 surface (bind-by-id routing ADR-0034, the
``noise_model`` / measurement layer) and bngsim's forward sensitivities, so the
fit is refused on a legacy (edition < 2) config, a non-bngsim model, or a bngsim
build without the ``output_sensitivities`` feature -- with a message that names the
condition that fired and *then* points at a metaheuristic ``job_type`` (#527).
Never a silent finite-difference fallback.
* **The gradient path activation** (:meth:`_setup_gradient_path`). Builds each
experiment's :class:`~pybnf.gradient.routing.ExperimentRouting` **once** (it
depends only on model structure, conditions, and free-parameter ids -- never on
the parameter values, #449) and ``apply_routing``\\ s the union request onto every
model, so each simulated :class:`~pybnf.data.Data` carries its sensitivity tensor.
Run before the model scatter (from ``start_run``); the request rides the pickle to
the workers (``BngsimModel.__getstate__`` keeps it, rebuilding only the engine).
* **Master-side scoring** (``requires_master_scoring = True``). The worker scoring
path nulls ``res.simdata`` after scoring (#385/#388), which would discard the
sensitivity tensors; the flag makes ``Algorithm.run`` keep scoring on the master so
every ``Result`` returns with its full simdata for :meth:`gradient_at`.
* **The per-evaluation assembly** (:meth:`gradient_at`). Aligns a Result's
``simdata`` with ``exp_data`` and the prebuilt routings, and returns the assembled
:class:`~pybnf.gradient.assembly.GradientResult` (objective gradient + residual
Jacobian, in sampling space ``u``), folding in any constraint-penalty gradient.
* **The ``u``-space box** (:meth:`_u_bounds`). The finite reflecting box for bounded
(``uniform_var`` / ``loguniform_var``) priors, ``±inf`` for an unbounded point
start -- the bounds the leaf's step projects/reflects into.
The search runs in sampling space ``u`` (``StartPointOptimizer``), and #385 already
delivers the gradient transformed into ``u`` once (ADR-0029), so a leaf never
re-transforms. Leaves own their ``start_run`` / ``got_result`` state machine and
must be picklable for backup/resume, exactly like Powell and CMA-ES (ADR-0007).
"""
import logging
import numpy as np
from .concurrent_multistart import DONE, ConcurrentMultiStartOptimizer
from ... import _bngsim_caps
from ...gradient import (
GradientNotSupported,
apply_routings,
assemble_constraint_gradient,
assemble_gaussian_gradient,
assemble_marginal_time_gradient,
route_for_model,
)
from ...printing import PybnfError, print0, print1, print2
# ``DONE`` is the shared multi-start sentinel, re-exported here so a gradient leaf's
# ``from .gradient_base import DONE`` keeps resolving to the one object the base's
# ``got_result`` identity-checks (#500).
__all__ = ['DONE', 'GradientRunner', 'GradientOptimizer']
logger = logging.getLogger(__name__)
[docs]
class GradientRunner:
"""Headless, picklable per-start step machine in sampling space ``u`` (#386).
A gradient leaf's step math, factored out of the optimizer so a single fit can
run ``N`` of them **concurrently** -- local multi-start, the diversity a purely
local gradient method otherwise lacks (it only ever descends into the one basin
its start lands in). A runner owns one start's entire mutable state -- the
iterate, the curvature / trust-region model, the reflecting box, the tunables --
and is pure ``numpy``: it knows nothing about :class:`~pybnf.pset.PSet`\\ s, the
objective, routing, backup, or dask. :class:`GradientOptimizer` drives it::
u0 = runner.start() # the first point to evaluate
nxt = runner.got(u_point, score, grad) # consume one completed evaluation
where ``u_point`` is the realized (box-projected) ``u``-vector of the completed
evaluation, ``score`` its objective value, and ``grad`` the assembled
:class:`~pybnf.gradient.assembly.GradientResult` at it; ``got`` returns the next
``u`` to evaluate, or the :data:`DONE` sentinel. Because a runner holds only
plain ``float`` / ``ndarray`` / ``list``, the optimizer that owns the list of
runners pickles for backup/resume exactly like the single-start machine did
(ADR-0007). Being backend-free, a runner is also unit-testable offline by feeding
it scores + gradients from an analytic function (no bngsim) -- how the step math
is validated against a scipy oracle and how the multi-start win is demonstrated.
A leaf subclass (``trf.py`` / ``lbfgs.py``) sets up its own model state in
``__init__`` and implements :meth:`got`; the orchestrator only ever calls
:meth:`start`, :meth:`got`, and reads :attr:`iteration` / :attr:`fval` /
:attr:`stop_reason` / :meth:`progress_detail` for reporting.
"""
#: How a leaf names the local model it steps from, quoted in the :meth:`_failed_model`
#: stop reason (``trf``: the residual model; ``gntr``: the Fisher model; ``lbfgs``: the
#: gradient) so a terminated start says which object was unusable (#528).
_model_label = 'local model'
def __init__(self, u0, lower, upper, max_iterations):
self.point = np.array(u0, dtype=float) # current iterate (u-space)
self._u_lower = lower # reflecting box (constant per fit)
self._u_upper = upper
self.n = len(self.point)
self.max_iterations = max_iterations
self.iteration = 0 # accepted steps so far (drives reporting / budget)
self.phase = 'init' # 'init' until the start point is evaluated
self.fval = None # objective F(point)
self.grad = None # scalar gradient dF/du at point (u-space)
self.stop_reason = None # set to a human string when got() returns DONE
# Why this start produced no fit, for a consumer that has to say so in its own words
# (the profile-likelihood track): None if it ran normally, 'simulation' if its start
# point did not evaluate (_failed_start), 'model' if it evaluated but its derivatives
# were unusable (_failed_model). Both leave ``fval`` at the inf penalty (#492/#528).
self.failure = None
[docs]
def start(self):
"""The first ``u``-point to evaluate (the start point)."""
return self.point
[docs]
def got(self, u_point, score, grad):
"""Consume one completed evaluation and return the next ``u`` or :data:`DONE`.
The orchestrator passes ``grad = None`` with a non-finite ``score`` for a **failed
simulation** (a non-integrable candidate point). A leaf's ``got`` must tolerate that:
mid-search its ``isfinite(score)`` guard already rejects the trial without
dereferencing the gradient (the fit backs off); at the start point (``phase ==
'init'``, ``grad is None``) it must terminate via :meth:`_failed_start`. See
:meth:`GradientOptimizer._advance` (#492).
A point that *did* evaluate can still hand back an unusable **model**
(:meth:`_model_is_usable`); a leaf handles that the same two ways -- back off
mid-search, :meth:`_failed_model` at the start point (#528)."""
raise NotImplementedError
@staticmethod
def _all_finite(*arrays):
"""True when every supplied array is present and entirely finite."""
return all(a is not None and bool(np.all(np.isfinite(a))) for a in arrays)
def _model_is_usable(self, grad):
"""Whether the local model assembled at an evaluated point is one this method can
actually step from -- i.e. every array the leaf reads off it is finite (#528).
A point can score finitely and still hand back non-finite *derivatives*: a stiff
parameter set whose ODE solve completes while its forward sensitivities diverge, an
overflow in the chain rule, a vanishing prediction in a denominator. There is no step
to take from such a model, and taking one anyway does not degrade gracefully -- it
aborts the **whole** fit, not just the start that met the bad point. LAPACK refuses to
factorize a non-finite matrix (``LinAlgError: SVD did not converge`` out of the
trust-region subproblem, the reported crash of #528), and a quasi-Newton direction
built from a NaN gradient is itself NaN, so the next proposed point is NaN and dies as
an ``OutOfBoundsException`` when the orchestrator builds its PSet. Either exception
unwinds through ``got_result`` and out of the run loop, discarding every *other*
concurrent start's progress with it.
This base implementation checks the scalar gradient every assembled
:class:`~pybnf.gradient.assembly.GradientResult` carries (what ``lbfgs`` consumes); a
leaf whose model is built from other fields -- ``trf``'s residual + Jacobian,
``gntr``'s Fisher Hessian -- overrides to check those instead."""
return grad is not None and self._all_finite(grad.gradient)
def _failed_model(self, detail):
"""Terminate this start: the start point evaluated and scored, but the local model
assembled there is not one this method can descend from (:meth:`_model_is_usable`),
and at the start point there is no earlier iterate to back off to. The sibling of
:meth:`_failed_start`, which handles the point that did not evaluate at all (#528).
``fval`` is set to the same ``inf`` penalty :meth:`_failed_start` records even though
this point *did* score. What the start-point score is not is a **fit**: no step was
ever taken from it. Reporting it as this start's objective would let a consumer read
an unoptimized value as an optimized one -- concretely, the profile-likelihood grid
point in :meth:`ProfileLikelihoodAlgorithm._profile_got`, where an un-minimized upper
bound entered as the profile inflates that point's Δχ², which can fabricate a
threshold crossing and report a confidence interval narrower than the data supports.
:attr:`failure` records *which* of the two failures this was, so a consumer that has
to explain the stop can say the accurate thing rather than "simulation failed".
Only this start stops; concurrent multi-start keeps every other start running, and
the trajectory (which holds every evaluated point, including this one, at its real
score) keeps the global best."""
self.fval = float('inf')
self.failure = 'model'
self.stop_reason = '%s; no usable local model to descend from' % detail
return DONE
def _failed_start(self):
"""Terminate this start: its start point did not simulate (a non-integrable point --
a bngsim CVODE failure, a NaN/Inf), so there is no finite objective or gradient to
model the local surface from and descend. A gradient method needs a viable start;
with none, this start ends. Concurrent multi-start keeps every *other* start's
progress and the global best, so only this start stops (a single-start fit ends here,
the failed point left in the trajectory at the ``inf`` penalty). Fed by the
orchestrator as ``grad is None`` at ``phase == 'init'``; see
:meth:`GradientOptimizer._advance` (#492). ``fval`` is set to the ``inf`` penalty so a
consumer that reads the terminated runner's objective (e.g. the profile-likelihood
grid point in :meth:`ProfileLikelihoodAlgorithm._profile_got`) sees a non-finite value
rather than the ``None`` a never-evaluated runner starts with."""
self.fval = float('inf')
self.failure = 'simulation'
self.stop_reason = ('start point failed to simulate (a non-integrable point); '
'no objective/gradient to descend from')
return DONE
[docs]
def progress_detail(self):
"""A short method-specific status suffix for the per-iteration report."""
return ''
# The one-line suggestion every gradient-path refusal ends with, so a user whose
# model/config cannot be differentiated is pointed straight at a working job_type
# rather than left to guess. Gradient fitting is strictly opt-in (job_type = trf /
# lbfgs); a metaheuristic always works on the same config. Passed as PybnfError's
# ``hint=``, never as its ``user_message``: the hint is a *suffix* to the specific
# diagnosis, not a substitute for it (#527) -- four unrelated conditions refuse here,
# and which one fired is the whole of what the user needs to know.
_FALLBACK_HINT = (
"Use a metaheuristic job_type instead (e.g. job_type = de, the default, or "
"pso / ss / cmaes), which needs no gradient."
)
[docs]
class GradientOptimizer(ConcurrentMultiStartOptimizer):
"""The gradient-based leg of the concurrent multi-start base (#386/#500).
A leaf subclass supplies only its per-start step math as a :class:`GradientRunner`
(Levenberg–Marquardt for ``trf``, L-BFGS-B for ``lbfgs``) via :meth:`_make_runner`; the
shared
:class:`~pybnf.algorithms.optimizers.concurrent_multistart.ConcurrentMultiStartOptimizer`
owns the ``start_run`` / ``got_result`` orchestration -- seeding the runners, the name
routing, reporting, and the multi-start ``STOP`` coordination -- and this class fills in
what the gradient path does differently: the pre-flight gates + master scoring, the
``u`` <-> PSet plumbing, the sensitivity-path activation (:meth:`_setup_gradient_path`,
hung on :meth:`_pre_seed`), the :meth:`gradient_at` assembly, and the gradient-consuming
:meth:`_advance`. The leaf must set :attr:`START_POINT_KEY` like any
:class:`StartPointOptimizer`, plus :attr:`_method_label` and :meth:`_start_banner` for
its messages.
Local multi-start (#386). A box-start gradient fit runs ``N`` independent starts
concurrently (``N`` reuses ``population_size`` -- the gradient path predates the
``n_starts`` field, hence :attr:`_n_starts_key`) -- start 0 from the box center
(preserving the deterministic single-start behavior), the rest from Latin-hypercube
samples across the prior box -- and keeps the global best. Every evaluated PSet across
all starts lands in the trajectory (``add_to_trajectory`` runs before ``got_result``),
so ``trajectory.best_fit()`` is the global best for free -- each runner only tracks its
own best for its own convergence test.
"""
#: Keep objective scoring on the master so every Result returns with its
#: simdata (the sensitivity tensors the gradient assembly reads); see
#: ``Algorithm.run``. Without this the worker path nulls ``res.simdata``.
requires_master_scoring = True
#: Human label for the method in the per-iteration progress messages; set by
#: each leaf (e.g. ``'L-BFGS-B'`` / ``'TRF'``).
_method_label = 'gradient'
#: The gradient path predates the ``n_starts`` field and reuses ``population_size`` as
#: the box-fit start count (consistent with the metaheuristics, where it is the
#: parallel-population size, and ``population_size = 1`` reproduces the historical
#: single start).
_n_starts_key = 'population_size'
#: The verb the base logs when a start terminates (a gradient start "stops"; a
#: derivative-free start "finishes") -- cosmetic, preserved verbatim.
_stop_verb = 'stopping'
# --- construction / reset hooks ---------------------------------------- #
def _check_config_supported(self, config):
"""Refuse a legacy (edition < 2) config before the base builds a single model --
the cheapest gate, before the expensive network generation in ``Algorithm.__init__``
(a legacy-edition config can never carry the gradient surface)."""
self._require_edition_2(config)
def _after_init(self):
"""The gradient path's construction extras, run after the models are built and
before start resolution: the sensitivity-backend and differentiability gates, and
the reflecting box + (empty) per-experiment routings."""
# Per-experiment routing, keyed by (model_name, suffix); built lazily in
# _setup_gradient_path (needs the initialized models). None until then, and
# restored as None by reset() so a bootstrap refit rebuilds it.
self._routings = None
# Whether _report_sensitivity_rhs has already spoken. Deliberately NOT reset by
# _after_reset: the routings are rebuilt per bootstrap refit, but which
# sensitivity RHS each model runs on is not a function of the resampled data
# (#606).
self._sens_rhs_reported = False
# Backend gate: every model must expose bngsim's forward-sensitivity hooks
# (the capability gate itself fires later, at apply_routing).
self._require_sensitivity_backend()
# Differentiability gate: a discrete-event model needs a bngsim that
# differentiates the jump; on a build that does not, refuse now rather than
# run to completion on a silently-wrong gradient (#461/#536).
self._require_differentiable_dynamics()
# The reflecting box in sampling space u (the leaf's step projects/reflects into it).
self._u_lower, self._u_upper = self._u_bounds()
def _after_reset(self):
"""Rebuild the reflecting box and drop the routings so a bootstrap refit rebuilds
them; the gates already passed at construction and never regress on a refit."""
self._routings = None
self._u_lower, self._u_upper = self._u_bounds()
# --- run-loop hooks ---------------------------------------------------- #
def _pre_seed(self):
"""Activate the gradient path (enable sensitivities + build routings) before the
runners are seeded -- and so before the model scatter, so the request rides the
pickle to the workers."""
self._setup_gradient_path()
def _build_runners(self):
"""One :class:`GradientRunner` per start, seeded at each start PSet's ``u``-vector.
The gradient step is deterministic, so (unlike the local path) no per-start rng is
provisioned -- keeping the base rng-agnostic (#500)."""
return [self._make_runner(self._u_from_pset(p)) for p in self.start_psets]
def _seed(self, idx, runner):
"""Start ``idx``'s single opening evaluation (its start point)."""
return [self._dispatch(idx, runner.start())]
def _advance(self, idx, runner, res):
"""Assemble the gradient at the completed ``res``, feed ``(u, score, grad)`` to
start ``idx``'s runner, and return its next PSet -- or :data:`DONE` once it
terminates. The realized (box-projected) ``u`` of the evaluated point is read back
off the PSet so the runner's internal iterate is a genuinely evaluated point.
A **failed simulation** (a non-integrable candidate point: a bngsim CVODE failure, a
NaN/Inf, ...) returns with ``res.simdata is None`` and ``res.score`` already the
``inf`` penalty (set in ``add_to_trajectory``); there is no trajectory data to
assemble a gradient from. Feed the runner that non-finite evaluation with **no
gradient** (``grad = None``): mid-search the runner's own ``isfinite(score)`` guard
rejects the step / shrinks its trust region and proposes a shorter one -- it never
dereferences the gradient on a rejected trial -- so the fit *backs off* rather than
aborting; at the start point there is no basin to descend from, so the runner
terminates that start (:meth:`GradientRunner._failed_start`), leaving every other
concurrent start and the trajectory's global best untouched. Mirrors the scalar
path's ``inf`` penalty for a failed simulation and the ``res.simdata is None`` guard
added for the sampler / constraint-tracking path in #480 -- this is the gradient
path's analogous unguarded case (#492)."""
u_point = self._u_from_pset(res.pset)
if res.simdata is None:
grad, score = None, float('inf')
else:
grad, score = self.gradient_at(res), float(res.score)
nxt = runner.got(u_point, score, grad)
if nxt is DONE:
return DONE
return [self._dispatch(idx, nxt)]
def _dispatch(self, idx, u):
"""Wrap a runner's proposed ``u``-point as a uniquely named PSet bound to its
owning start in :attr:`pending`, and return it for submission. The name carries a
single global counter (``<fit_type>_<k>``), so a single-start fit reproduces the
historical ``<fit_type>_1``, ``<fit_type>_2``, … sequence exactly while every
name stays unique across concurrent starts (the routing key)."""
self.probe_counter += 1
name = '%s_%i' % (self.fit_type, self.probe_counter)
return self._route(idx, self._pset_from_u(u, name=name))
def _report(self, runner):
"""Per-iteration progress for one start (mirrors the single-start report); the
method-specific suffix comes from the runner."""
if runner.iteration % self.config.config['output_every'] == 0:
self.output_results()
msg = 'Completed %i of %i %s iterations' % (
runner.iteration, runner.max_iterations, self._method_label)
(print1 if runner.iteration % 10 == 0 else print2)(msg)
print2('Current best objective: %f, %s' % (runner.fval, runner.progress_detail()))
# --- gates ------------------------------------------------------------- #
# The gradient path is gated in four places, each as early as it can be (a fifth
# check, :meth:`_report_sensitivity_rhs`, sits beside them and is a *report* unless
# the user asks it to be a gate -- see its own docstring):
#
# * **edition** (:meth:`_require_edition_2`, before model build) -- the gradient
# consumes the edition-2 surface (bind-by-id routing, the noise-model /
# measurement layer), absent under legacy edition 1;
# * **backend** (:meth:`_require_sensitivity_backend`, after model build) -- every
# model must expose bngsim's forward-sensitivity hooks; a non-bngsim (e.g.
# RoadRunner/SBML) model has no sensitivity tensor here;
# * **differentiability** (:meth:`_require_differentiable_dynamics`, after model
# build, #461/#536) -- a discrete-event model needs a bngsim whose forward
# sensitivities survive the jump *and* which refuses the event subclasses it
# cannot cross; on an older build the refusal is blanket and fires here rather
# than mid-run, and on a current one the model passes straight through;
# * **capability** (deferred to :meth:`_setup_gradient_path`'s ``apply_routing``,
# #447) -- raises if the bngsim build lacks the ``output_sensitivities`` feature.
#
# Every one of those is a property of the BUILD or of the CONFIG, which is why each
# can be decided from a module-level flag or a config read. The fifth is not: whether
# bngsim supplies an analytic ``∂f/∂p`` for a given model is a property of the
# (build, model) pair, decided at codegen, so :meth:`_report_sensitivity_rhs` has to
# build a Simulator to find out (#606, ADR-0121). It warns by default rather than
# refusing, because the fallback is correct -- just N times the cost -- and only
# refuses under ``sensitivity_fallback = error``.
#
# The per-evaluation gate (an unsupported *objective* -- Laplace residual,
# estimated scale, … raising :class:`GradientNotSupported`) is caught at the first
# assembly in :meth:`gradient_at`. A non-ODE simulation *method* (SSA / NFsim), and a
# carried-state pre-equilibration ``parameter_scan`` (#474), are likewise
# non-differentiable, but the method is an action-level property (a model can mix
# actions) rather than a model-structure one, so they are not hoisted here. They keep
# a per-evaluation refusal in the backend (``_sensitivity_request_kwargs`` /
# ``_scan_carried_state`` raise a clean :class:`PybnfError`, not a raw backend
# traceback) -- but *only when that action's output is a scored gradient target*
# (#475): an incidental/unscored non-ODE or carried-state action needs no
# sensitivities, so it runs sensitivity-free instead of aborting a fit whose scored
# objective is fully differentiable. :meth:`_setup_gradient_path` declares each
# model's scored suffixes so the backend can make that per-action distinction. Events,
# by contrast, are a build-time structural signal and so *can* be a pre-flight gate --
# which is why the one subclass of them a current bngsim still declines (a delayed or
# non-relational trigger) keeps its refusal in the backend, where the whole event is
# in view, instead of being re-derived here (#536).
def _require_edition_2(self, config):
"""Refuse a legacy (edition < 2) config before any model is built."""
edition = config.config.get('edition')
if not edition or edition < 2:
raise PybnfError(
"Gradient-based fitting (job_type = %s) requires the edition-2 "
"config surface, but this fit is %s." % (
self._fit_type_label(),
"edition 1 (legacy)" if not edition else "edition %d" % edition),
hint=["Opt into edition 2 ('edition = 2') and declare the fit on the "
"new-era surface (experiment: / data: / noise_model, bind-by-id "
"parameters).",
_FALLBACK_HINT])
def _require_sensitivity_backend(self):
"""Refuse a model whose backend has no forward-sensitivity hooks."""
for model in self.model_list:
if not hasattr(model, 'enable_output_sensitivities'):
raise PybnfError(
"Gradient-based fitting (job_type = %s) requires the bngsim "
"backend's forward sensitivities, but model '%s' uses a backend "
"that does not provide them." % (
self._fit_type_label(), getattr(model, 'name', '?')),
hint=["Simulate the model through bngsim (an SBML model needs "
"'sbml_backend = bngsim'), which provides them.",
_FALLBACK_HINT])
def _require_differentiable_dynamics(self):
"""Refuse a discrete-event model on a build that cannot differentiate one (#461/#536).
A discrete event is a discrete jump in the dynamics: it reinitialises the
integrator state discontinuously, so a forward-sensitivity vector carried
across it is right only if the solver applies the event's own jump
.. math::
s^+ = \\frac{\\partial h}{\\partial x}
\\left(s^- + f^-\\frac{\\partial t^*}{\\partial p}\\right)
+ \\frac{\\partial h}{\\partial p}
- f^+\\frac{\\partial t^*}{\\partial p}
at each fire. Originally it never did -- the vectors were carried straight
through and went silently stale, so bngsim refused sensitivities on any
event-bearing model and #461 hoisted that refusal here, as a **blanket**
pre-flight gate, rather than let it surface mid-run at the first
sensitivity-bearing ``simulate()``.
bngsim applies the jump now. On a build
:data:`~pybnf._bngsim_caps.BNGSIM_HAS_EVENT_SENS` reports it also *classifies*
each event honestly -- differentiating the subclasses it covers (a fixed
trigger time; a trigger thresholding a fitted constant, lanl/bngsim#49; a
state-dependent trigger whose crossing it differentiates in flight,
lanl/bngsim#144) and refusing the rest (an execution delay; a trigger that
does not reduce to a single relational comparison). On such a build this
stops being a gate: the model is allowed through and bngsim's own per-model
refusal covers what it cannot cross, re-raised as a clean
:class:`~pybnf.printing.PybnfError` by
``BngsimSbmlModelNoTimeout.execute`` -- the SBML/Antimony backend being the
only one an event can reach PyBNF through, since a ``.net`` model cannot
author one.
Otherwise the refusal stays, and stays blanket, because such a build does
not merely lack a subclass -- it answers one *wrongly and quietly*: a
trigger reading the state came back as a finite tensor missing the event's
contribution instead of being refused (lanl/bngsim#52), and an event
assignment that reads the state dropped its carried term altogether
(lanl/bngsim#144). Refusing up front beats a fit that runs to completion on
a wrong gradient.
The message says both what to install and *how the flag decided*
(:func:`~pybnf._bngsim_caps.event_sens_probe`), because since #558 the two
can disagree: the flag reads a capability rather than a version, so a
reader whose bngsim already reports a new enough number needs to be told
that the number was not the evidence -- otherwise the refusal reads as a
version complaint they have already answered.
Models whose backend exposes no event count (``has_discrete_events``
absent) pass through untouched."""
if _bngsim_caps.BNGSIM_HAS_EVENT_SENS:
return
for model in self.model_list:
if getattr(model, 'has_discrete_events', False):
raise PybnfError(
"Gradient-based fitting (job_type = %s) needs forward "
"sensitivities that survive a discrete event (a discrete jump in "
"the dynamics), and model '%s' contains one. The installed bngsim "
"(%s) can still answer such an event wrongly without saying so -- "
"a tensor missing the event's contribution rather than a refusal "
"-- so the gradient there would be silently wrong." % (
self._fit_type_label(), getattr(model, 'name', '?'),
_bngsim_caps.BNGSIM_VERSION or 'version unknown'),
hint=["Install bngsim >= %s, which differentiates the event "
"subclasses it supports and refuses the rest. This gate "
"reads a capability, not a version -- it decided from: %s "
"-- so a build whose version already reads new enough is "
"one that does not publish the capability, which a "
"from-source build ahead of (or behind) its own release "
"number can be."
% (_bngsim_caps.event_sens_min_version(),
_bngsim_caps.event_sens_probe()),
_FALLBACK_HINT])
def _fit_type_label(self):
"""The fit_type code for messages (the leaf's registered name, best-effort)."""
return getattr(self, 'fit_type', type(self).__name__)
# --- gradient-path activation ------------------------------------------ #
def _setup_gradient_path(self):
"""Enable forward sensitivities on every model and build the per-experiment
routings -- idempotent, called once from the leaf's ``start_run`` (before the
model scatter, so the request rides the pickle to the workers).
For each model: build one :class:`ExperimentRouting` per scored
``(model, suffix)`` (carrying that condition's chain-rule factors for the
assembly), then ``apply_routings`` the **union** of their sensitivity
requests -- plus the wildtype's. The wildtype alone is not a superset: a
condition can route a free parameter to a column no other experiment binds (a
per-condition estimated initial condition, ADR-0076), so that column is
reached only through the condition and must be unioned in (an extra requested
column is harmless; a missing one aborts the assembly). Raises (capability
gate) if the bngsim build lacks ``output_sensitivities``."""
if self._routings is not None:
return
names = [v.name for v in self.variables]
routings = {}
for model in self.model_list:
# Declare which of this model's outputs are scored gradient targets so
# an incidental/unscored action (a stochastic diagnostic, a
# carried-state pre-equilibration scan) runs sensitivity-free instead
# of aborting the whole fit at a differentiability guard (#475). Rides
# the scatter alongside the sensitivity request.
model.set_scored_suffixes(self.exp_data.get(model.name, {}))
# Apply the UNION sensitivity request over the wildtype and every scored
# condition -> sets _sensitivity_request, which survives the scatter and is
# applied at every simulate(). The wildtype alone is NOT a superset once a
# condition routes a free parameter to a column no other experiment binds --
# a per-condition estimated initial condition (ADR-0076): its species-IC /
# multiplier column is reached only through that condition, so the union must
# include every scored routing (an extra column is harmless; a missing one aborts).
wildtype = route_for_model(model, names, condition=None)
model_routings = {}
for suffix in self.exp_data.get(model.name, {}):
condition = self._condition_for_suffix(model, suffix)
model_routings[suffix] = route_for_model(model, names, condition)
apply_routings(model, [wildtype, *model_routings.values()])
for suffix, routing in model_routings.items():
routings[(model.name, suffix)] = routing
self._routings = routings
self._report_sensitivity_rhs()
def _report_sensitivity_rhs(self):
"""Say which sensitivity right-hand side each model's gradient will run on (#606).
``CVodeSensInit1`` takes one sensitivity-RHS callback for every column, so a
single rate law bngsim cannot differentiate declines the analytic ``∂f/∂p`` for
the **whole** model and CVODES' internal difference quotient carries every
column instead. That is a correctness-preserving substitution and a
cost-multiplying one: an extra RHS evaluation per column per step, so an
N-column request pays roughly N times the sensitivity cost. On a fit measured
in hours that is not a slower answer, it is no answer -- on
``Smith_BMCSystBiol2013`` all 25 columns fell back, every start timed out to
``inf``, and thirteen hours produced nothing, with the only signal a bngsim log
line on a worker that nobody had a reason to look for (#558).
This is the only pre-flight check here that is a property of the **(build,
model)** pair rather than of the build or the config, so unlike the four gates
above it cannot be answered from a module-level flag or a config read: it
builds one sensitivity-bearing Simulator per model and reads the verdict off
the codegen artifact that Simulator installs
(:func:`~pybnf._bngsim_caps.probe_sens_rhs`). Running it here rather than on a
worker is what makes it useful -- the answer arrives before the fit has spent
anything, which is the whole complaint the log line could not answer.
The verdict is what policy keys off, because the verdict is stable. bngsim's
own *reason* for a decline is captured too, but only ever as prose: it is
emitted during codegen source generation, which a warm structural cache skips
entirely, so it is present on the first run of a fit and absent on the second.
A model reporting no opinion (``None`` -- no codegen artifact to read) is
logged and not warned about, in either direction.
Reported **once per run**, not once per pass: ``reset()`` drops the routings so
a bootstrap refit rebuilds them, and a model's differentiability does not change
with resampled data, so ``bootstrap = 100`` would otherwise print the same
warning a hundred times. The refusal is once-only for the same reason -- it has
already ended the run the first time.
Never raises except under ``sensitivity_fallback = error``, which is a user
asking to be stopped."""
policy = str(self.config.config.get('sensitivity_fallback', 'warn')).lower()
if policy == 'ignore' or getattr(self, '_sens_rhs_reported', False):
return
self._sens_rhs_reported = True
declined = []
for model in self.model_list:
probe = getattr(model, 'analytic_sens_rhs_status', None)
if not callable(probe):
# A backend with no opinion to give (a test double, a non-bngsim
# model). The sensitivity-backend gate above already refused anything
# that cannot supply a gradient at all, so this is not a failure.
continue
status = probe()
if status.analytic is None:
logger.info(
"Model %s: cannot tell whether the forward sensitivities run on "
"bngsim's analytic df/dp -- %s.", model.name, status.route)
elif status.analytic:
logger.info(
"Model %s: forward sensitivities run on bngsim's analytic df/dp "
"(read from %s).", model.name, status.route)
else:
declined.append((model, status))
self._warn_sensitivity_fallback(model, status)
if declined and policy == 'error':
raise PybnfError(
"Gradient-based fitting (job_type = %s) was asked to require the "
"analytic sensitivity right-hand side (sensitivity_fallback = error), "
"and bngsim declined it for %s." % (
self._fit_type_label(),
', '.join("model '%s'" % m.name for m, _ in declined)),
hint=["Re-encode the declined rate law in a form bngsim can "
"differentiate. Its own reason is in the warning above when it "
"gave one -- it reports the reason while generating codegen "
"source, so a warm codegen cache has none to give.",
"Or accept the fallback with 'sensitivity_fallback = warn' (the "
"default) and expect roughly one extra right-hand-side "
"evaluation per sensitivity column per step.",
_FALLBACK_HINT])
def _warn_sensitivity_fallback(self, model, status):
"""One model's difference-quotient warning, to the log and to the console.
Console rather than log-only on purpose, and at verbosity 0 rather than 1. The
decline already reaches ``<prefix>.log`` today -- bngsim's logger propagates to
root and PyBNF puts a FileHandler there -- and that is exactly the channel that
failed: a shared, noisy file written from N worker processes, one line per
model, arriving mid-run. It is discoverable by someone who already suspects the
problem, which is the wrong order. A reader who turned the verbosity down is
still a reader who would rather not spend the next thirteen hours, which is the
same call ``_report_bngsim_build`` makes for a stale compiled core (#558).
"""
columns = status.columns or len(self.variables)
cost = ("each of this model's %d sensitivity columns costs an extra "
"right-hand-side evaluation per step, so expect roughly %dx the "
"sensitivity cost of the analytic path" % (columns, columns))
reason = '; '.join(reason for reason, _ in status.reasons)
detail = (' bngsim declined it because %s.' % reason if reason else
' bngsim did not say why in this run: it reports the reason while '
'generating the codegen source, which a warm codegen cache skips.')
logger.warning(
"Model %s: bngsim declined the analytic sensitivity RHS, so CVODES' "
"internal difference quotient carries every column (%s; read from %s).%s",
model.name, cost, status.route, detail)
print0("WARNING: model '%s' has no analytic sensitivity right-hand side, so "
"this gradient fit runs on CVODES' internal difference quotient -- %s. "
"The gradient stays correct.%s" % (model.name, cost, detail))
print1(' -> Read from %s.' % status.route)
if status.fallback_is_wrong:
# The half of the decline space where "correct, but slower" is FALSE. This
# is a statement about the NUMBERS rather than the cost, so it goes to
# print0, past the verbosity the cost line respects.
#
# From 0.14.0 bngsim refuses such a run outright rather than returning the
# gradient it has flagged as wrong (lanl/bngsim#414/#416) -- and it decides
# that from its own ground truth, re-scanning the model rather than trusting
# the codegen warning, so its refusal survives a warm cache where this line
# does not. The wording therefore has to be true on both sides of that
# line: on a carrying build the fit is about to stop at the first
# simulation, and on an older one it is about to run.
print0("WARNING: model '%s' also branches at a crossing whose time moves, "
"and the difference quotient integrates straight through it, so "
"every sensitivity column is wrong at and after that crossing by "
"the jump it drops. bngsim refuses this case outright from 0.14.0; "
"if this fit proceeds, validate against a finite difference of the "
"trajectory before relying on it." % model.name)
def _condition_for_suffix(self, model, suffix):
"""Resolve a scored ``suffix`` to the condition (``MutationSet``) it was
simulated under, or ``None`` for the wildtype.
An edition-2 ``condition:`` is a named :class:`~pybnf.pset.MutationSet` added
to the model as a mutant (its name is the suffix); a mutant simulation's
output suffix carries the mutant's own suffix (``net_model.execute``), so a
scored suffix that ends with a known mutant suffix was simulated under that
condition. The wildtype experiment touches no mutant and maps to ``None`` (the
unperturbed routing, all factors 1)."""
best = None
for mut in getattr(model, 'mutants', []) or []:
ms = getattr(mut, 'suffix', '')
if ms and suffix.endswith(ms) and (best is None or len(ms) > len(best.suffix)):
best = mut
return best
# --- per-evaluation assembly ------------------------------------------- #
[docs]
def gradient_at(self, res):
"""Assemble the objective gradient + residual-Jacobian at ``res``'s point.
``res`` is a master-scored Result, so ``res.simdata`` carries each
experiment's forward-sensitivity tensor. Aligns it with ``exp_data`` over the
scored ``(model, suffix)`` pairs (the same intersection the objective scores),
attaches each one's prebuilt routing, and returns the assembled
:class:`~pybnf.gradient.assembly.GradientResult` -- residual / Jacobian /
scalar gradient in sampling space ``u`` (#385 transformed it once; the
optimizer never re-transforms). Any constraint-penalty gradient is added to
the scalar ``gradient`` (and clears ``least_squares_exact``, since a penalty
is not a sum of squares).
The free-parameter list (column order + current values) is read straight off
the evaluated PSet, so the ``d theta/d u`` scale factors are taken at the
point actually simulated -- and, for the same reason, each routing is taken
``at_point``: a chain-rule factor that reads other model symbols
(``d(beta_N)/d(R0_) = gamma_/N_``, #530) is only a number once the fit vector
is known. A routing whose factors are all constants returns itself, so every
other fit is untouched. Converts a :class:`GradientNotSupported` (an
objective the assembly cannot differentiate) into a clear, fail-fast
:class:`PybnfError` pointing at a metaheuristic job_type."""
free_params = [res.pset.get_param(v.name) for v in self.variables]
try:
routings = self._routings_at(res.pset)
except GradientNotSupported as e:
raise self._unsupported_gradient_error(e) from e
experiments = []
for model_name, by_suffix in res.simdata.items():
model_exp = self.exp_data.get(model_name, {})
for suffix, sim_data in by_suffix.items():
if suffix in model_exp:
# The suffix travels with the experiment as its ``data_key`` -- the same key
# ``evaluate`` resolves a per-series analytic scale against (ADR-0066, #533),
# so the gradient profiles the scale over exactly the series scoring does.
experiments.append(
(sim_data, model_exp[suffix], routings[(model_name, suffix)], suffix))
try:
grad = self._assemble_objective_gradient(experiments, free_params)
if self.config.constraints:
cgrad = assemble_constraint_gradient(
self.config.constraints, res.simdata, routings, free_params)
grad.gradient = grad.gradient + cgrad
grad.least_squares_exact = False
self._attach_curvature(grad, res, experiments, free_params, routings)
except GradientNotSupported as e:
raise self._unsupported_gradient_error(e) from e
return grad
def _routings_at(self, evaluated_pset):
"""The prebuilt routings with every point-dependent chain-rule factor resolved.
The sensitivity *request* is fixed for the whole fit, but a seed derivative may
read other model symbols and so is only a number at an evaluated point (#530).
Returns ``self._routings`` itself when nothing is point-dependent -- the common
case, and byte-identical to the pre-#530 path."""
if not any(r.is_point_dependent for r in self._routings.values()):
return self._routings
values = {p.name: p.value for p in evaluated_pset}
return {key: routing.at_point(values)
for key, routing in self._routings.items()}
def _assemble_objective_gradient(self, experiments, free_params):
"""Assemble the data-fit objective derivatives needed by this optimizer leaf.
The base ``trf`` / ``lbfgs`` path needs only the scalar gradient and residual model.
``gntr`` overrides this seam to assemble those values and its Fisher Hessian in one
scored-point pass (#488).
A **marginal-time** objective (``time_error``, ADR-0113) scores each datum by an integral
over the trajectory rather than at a matched row, so it assembles its own scalar gradient
by sensitivity-chaining over the stored trajectory (``assemble_marginal_time_gradient``)
instead of the matched-row Gaussian path. It is never a sum of squares, so the result is
``least_squares_exact = False`` -- ``lbfgs`` consumes its scalar gradient; ``trf`` (which
needs an exact residual) refuses it and points at ``lbfgs``.
"""
if getattr(self.objective, 'marginalizes_time', False):
return assemble_marginal_time_gradient(self.objective, experiments, free_params)
return assemble_gaussian_gradient(self.objective, experiments, free_params)
def _attach_curvature(self, grad, res, experiments, free_params, routings):
"""Hook for a curvature-consuming leaf to attach its Hessian to the assembled
gradient. A **no-op on the base**, so the residual-form (``trf``) and scalar-gradient
(``lbfgs``) leaves -- which never form a Hessian -- are byte-identical; the EFIM
trust-region leaf (``job_type = gntr``, #481) receives the data-fit Hessian from its
combined :meth:`_assemble_objective_gradient` override, then uses this hook to add, for a
constrained fit, :func:`~pybnf.gradient.assembly.assemble_constraint_hessian`. Called **inside**
:meth:`gradient_at`'s :class:`GradientNotSupported` guard, so an unsupported-curvature
corner (a MEDIAN-count Fisher, a MEAN-on-log estimated scale, an estimated constraint
scale, ...) converts to the same fail-fast :class:`PybnfError`. ``routings`` are the
per-experiment routings **at this point** (:meth:`_routings_at`), the same objects the
objective assembly saw, so a point-dependent chain-rule factor (#530) reaches the
constraint block too."""
def _unsupported_gradient_error(self, exc):
"""Wrap a :class:`GradientNotSupported` as the leaf's fail-fast :class:`PybnfError`
with an actionable fallback hint. The base points at a metaheuristic ``job_type``;
the EFIM leaf (``gntr``) overrides the hint to point at ``lbfgs`` -- which consumes the
scalar gradient and needs no Fisher Hessian, so it fits the very corners ``gntr``
refuses."""
return PybnfError(
"Gradient-based fitting (job_type = %s) cannot differentiate this "
"fit's objective: %s" % (self._fit_type_label(), exc),
hint=_FALLBACK_HINT)
# --- u-space box ------------------------------------------------------- #
def _u_bounds(self):
"""The reflecting box in sampling space ``u`` as ``(lower, upper)`` arrays,
ordered by ``self.variables``.
Finite ``[to_sampling_space(lower_bound), to_sampling_space(upper_bound)]`` for
a bounded (``uniform_var`` / ``loguniform_var``) parameter; ``(-inf, +inf)``
for the unbounded ``var`` / ``logvar`` of a point start. The same box Powell
confines its line search to (#412); a leaf projects or reflects its proposed
step into it."""
lower, upper = [], []
for v in self.variables:
if v.bounded:
lower.append(v.to_sampling_space(v.lower_bound))
upper.append(v.to_sampling_space(v.upper_bound))
else:
lower.append(-np.inf)
upper.append(np.inf)
return np.array(lower, dtype=float), np.array(upper, dtype=float)