"""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 -- with a clear message pointing at a metaheuristic ``fit_type`` --
on a legacy (edition < 2) config, a non-bngsim model, or a bngsim build without
the ``output_sensitivities`` feature. 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 .local_base import StartPointOptimizer
from ...gradient import (
GradientNotSupported,
apply_routing,
assemble_constraint_gradient,
assemble_gaussian_gradient,
route_for_model,
)
from ...printing import PybnfError, print1, print2
logger = logging.getLogger('pybnf.algorithms')
#: Sentinel a :class:`GradientRunner`'s ``got`` returns when that start has
#: converged or otherwise terminated (no further evaluation to propose). It is
#: only ever returned synchronously into :meth:`GradientOptimizer.got_result` and
#: never stored in pickled state, so a plain module-level object (identity-checked)
#: is enough.
DONE = object()
[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.
"""
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
[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`."""
raise NotImplementedError
[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 fit_type
# rather than left to guess. Gradient fitting is strictly opt-in (fit_type = trf /
# lbfgs); a metaheuristic always works on the same config.
_FALLBACK_HINT = (
"Gradient-based fitting is not available for this fit; use a metaheuristic "
"fit_type instead (e.g. fit_type = de, the default, or pso / ss / cmaes), "
"which needs no gradient."
)
[docs]
class GradientOptimizer(StartPointOptimizer):
"""Base for the gradient-based local optimizers (#386).
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`;
this base owns the whole ``start_run`` / ``got_result`` orchestration -- seeding the
runners, the ``u``<->PSet plumbing, :meth:`gradient_at` assembly, name-tagging /
routing, reporting, and the multi-start ``STOP`` coordination. 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``) -- 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. The async run loop already
supports this without change: ``start_run`` returns a list of ``N`` initial PSets,
each ``got_result`` advances just the start that owns the returned Result (routed by
PSet name through :attr:`pending`) and returns that start's next PSet (or ``[]`` once
it converges), and ``'STOP'`` fires only when the **last** start finishes. 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'
def __init__(self, config, refine=False):
# Cheapest gate first, before the (expensive) network generation in
# Algorithm.__init__: a legacy-edition config can never carry the gradient
# surface, so refuse it before building a single model.
self._require_edition_2(config)
super().__init__(config)
self.refine = refine
# 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
# 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 has no smooth forward
# sensitivity (bngsim refuses one), so refuse it now rather than mid-run (#461).
self._require_differentiable_dynamics()
# Multi-start setup: the reflecting box, the start-point count + the start
# PSets, and the (empty) orchestration state. The per-start runners are built
# lazily in start_run (they need the leaf's tunables, read after this returns).
self.n = len(self.variables)
self._u_lower, self._u_upper = self._u_bounds()
self.n_starts = self._resolve_n_starts()
self.start_psets = self._resolve_start_psets()
self._init_orchestration()
[docs]
def reset(self, bootstrap=None):
super().reset(bootstrap)
self._routings = None
self._u_lower, self._u_upper = self._u_bounds()
self.n_starts = self._resolve_n_starts()
self.start_psets = self._resolve_start_psets()
self._init_orchestration()
# --- multi-start orchestration ----------------------------------------- #
def _resolve_n_starts(self):
"""The number of independent start points for this fit.
Local multi-start (#386) reuses ``population_size`` as the start count --
consistent with the metaheuristics, where it is the parallel-population size,
and ``population_size = 1`` reproduces the historical single start. A
point-start or refiner-injected start has no prior box to scatter across, so it
always runs a single start (the refiner polishes the one best fit, it does not
re-scatter). :meth:`_is_box_start` is exactly that test (no injected start +
every variable bounded)."""
if not self._is_box_start():
return 1
return max(1, int(self.config.config.get('population_size', 1)))
def _resolve_start_psets(self):
"""The ``n_starts`` start PSets: the box center first (start 0, preserving the
deterministic single-start behavior and the parity tests), then ``n_starts - 1``
Latin-hypercube samples across the prior box drawn from the seeded ``self.rng``
(so the scatter reproduces from ``random_seed``). With ``n_starts == 1`` no
sample is drawn -- the rng is untouched -- so a single-start fit is byte-for-byte
unchanged."""
start0 = self._resolve_start_pset()
if self.n_starts <= 1:
return [start0]
return [start0] + self.random_latin_hypercube_psets(self.n_starts - 1)
def _init_orchestration(self):
"""(Re)initialize the multi-start bookkeeping -- all plain list/dict/int, so the
optimizer pickles for backup/resume (ADR-0007). The per-start runners are built
lazily in :meth:`start_run` (they need the leaf's tunables, set after
``super().__init__``); until then ``runners`` is empty, so a freshly constructed
optimizer still round-trips through pickle."""
self.runners = [] # one GradientRunner per start (built in start_run)
self.pending = {} # pset name -> owning runner index (the routing map)
self.probe_counter = 0 # global submission counter -> unique pset names
self.active = 0 # starts not yet converged/terminated
[docs]
def add_iterations(self, n):
"""Extend every start's per-start iteration budget by ``n`` (the ``-r`` resume
path). Runners already exist when this is called on a resumed run (they ride the
backup pickle), so bump each alongside the template budget."""
self.max_iterations += n
for runner in self.runners:
runner.max_iterations += n
[docs]
def start_run(self):
"""Seed the ``n_starts`` runners and return their initial PSets (one evaluation
each), so all starts run concurrently from the first scheduler batch."""
print2(self._start_banner())
# Activate the gradient path (enable sensitivities + build routings) before the
# model scatter, so the request rides the pickle to the workers.
self._setup_gradient_path()
self.runners = [self._make_runner(self._u_from_pset(p)) for p in self.start_psets]
self.active = len(self.runners)
self.probe_counter = 0
self.pending = {}
return [self._dispatch(idx, runner.start())
for idx, runner in enumerate(self.runners)]
[docs]
def got_result(self, res):
"""Route a completed Result to the start that owns it, advance just that start's
runner, and return its next PSet -- ``[]`` once it converges (other starts keep
going), or ``'STOP'`` only when the last live start finishes."""
idx = self.pending.pop(res.name)
runner = self.runners[idx]
u_point = self._u_from_pset(res.pset)
grad = self.gradient_at(res)
prev_iter = runner.iteration
nxt = runner.got(u_point, float(res.score), grad)
if runner.iteration > prev_iter:
self._report(runner)
if nxt is DONE:
logger.info('%s start %d/%d stopping: %s', self._method_label,
idx + 1, len(self.runners), runner.stop_reason)
self.active -= 1
if self.active == 0:
return 'STOP'
return []
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)
self.pending[name] = idx
return 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:
#
# * **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) -- a discrete-event model has no smooth forward sensitivity
# (bngsim refuses one, GH #205), so refuse it here rather than fail mid-run;
# * **capability** (deferred to :meth:`_setup_gradient_path`'s ``apply_routing``,
# #447) -- raises if the bngsim build lacks the ``output_sensitivities`` feature.
#
# 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) is
# likewise non-differentiable, but the method is an action-level property (a model
# can mix actions) rather than a model-structure one, so it is not hoisted here; it
# keeps its existing clean per-evaluation refusal in the backend's
# ``_sensitivity_request_kwargs`` (a method != 'ode' under a sensitivity request
# raises a :class:`PybnfError`, not a raw backend traceback). Events, by contrast,
# are a build-time structural signal and so *can* be a pre-flight gate.
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 (fit_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),
"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 (fit_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', '?')),
_FALLBACK_HINT)
def _require_differentiable_dynamics(self):
"""Refuse a discrete-event model before the run (#461).
A discrete event is a state-dependent discrete jump in the dynamics; it
reinitialises the integrator state discontinuously, but bngsim's CVODES
forward-sensitivity vectors are not reinitialised across the jump, so the
sensitivities go silently stale -- bngsim refuses forward output
sensitivities outright on such a model (GH #205). Without this gate that
refusal would surface only mid-run, at the first sensitivity-bearing
``simulate()`` (caught and re-raised in ``BngsimModel.execute``). Fired here
next to :meth:`_require_sensitivity_backend`, it gives the discrete-event
model the same clean pre-flight "use a metaheuristic fit_type" refusal the
other gates give. Models whose backend exposes no event count
(``has_discrete_events`` absent) pass through untouched."""
for model in self.model_list:
if getattr(model, 'has_discrete_events', False):
raise PybnfError(
"Gradient-based fitting (fit_type = %s) requires smooth, "
"differentiable dynamics, but model '%s' contains discrete "
"events (a state-dependent discrete jump). Forward output "
"sensitivities go stale across such a jump, so bngsim cannot "
"supply the gradient there." % (
self._fit_type_label(), getattr(model, 'name', '?')),
_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: ``apply_routing`` the **wildtype** routing, whose request
lists (``sensitivity_params`` / ``sensitivity_ic``) are the union over
conditions -- the wildtype pins nothing, so every parameter-/IC-bound free
parameter is requested, a superset of any single condition's (a ``=``-pinned
condition only ever *drops* columns). Then build one
:class:`ExperimentRouting` per scored ``(model, suffix)``, carrying that
condition's chain-rule factors for 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:
# Union sensitivity request (wildtype) -> sets _sensitivity_request,
# which survives the scatter and is applied at every simulate().
apply_routing(model, route_for_model(model, names, condition=None))
for suffix in self.exp_data.get(model.name, {}):
condition = self._condition_for_suffix(model, suffix)
routings[(model.name, suffix)] = route_for_model(model, names, condition)
self._routings = routings
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. Converts a :class:`GradientNotSupported` (an
objective the assembly cannot differentiate) into a clear, fail-fast
:class:`PybnfError` pointing at a metaheuristic fit_type."""
free_params = [res.pset.get_param(v.name) for v in self.variables]
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:
experiments.append(
(sim_data, model_exp[suffix], self._routings[(model_name, suffix)]))
try:
grad = assemble_gaussian_gradient(self.objective, experiments, free_params)
if self.config.constraints:
cgrad = assemble_constraint_gradient(
self.config.constraints, res.simdata, self._routings, free_params)
grad.gradient = grad.gradient + cgrad
grad.least_squares_exact = False
except GradientNotSupported as e:
raise PybnfError(
"Gradient-based fitting (fit_type = %s) cannot differentiate this "
"fit's objective: %s" % (self._fit_type_label(), e),
_FALLBACK_HINT) from e
return grad
# --- 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)