"""The bngsim ``.net`` segment backend -- multiple shooting on a reaction network (#577).
The SBML/Antimony backend (:mod:`pybnf.shooting.bngsim_backend`) got the state for free: that
path reports its species *as* the trajectory's columns and labels its forward-sensitivity
selectors ``species:<name>`` on both axes, so one run hands over everything a knot needs. The
``.net`` path does not, and for a while ``job_type = ms`` refused it on that basis -- wrongly
framed as a property of the model. It is not one. A ``.net`` file is a fully expanded reaction
network with exactly the same kind of ODE state, and bngsim returns both its species
trajectory and its ``d(species)/d(species_0)`` when asked. What was missing was that nobody
asked: :meth:`~pybnf.bngsim_model.net_model.BngsimModel._build_data` assembles
``time + observables + expressions``, and the net backend's sensitivity request names
``observable:`` / ``expression:`` selectors.
This module asks. It is the one place in :mod:`pybnf.shooting` that needs *both* selector
families on one run, and that is the whole of what makes it different from its SBML peer:
* **an experiment scores observables, a continuity row is a difference of species.** On the
SBML path those are the same columns; here they are not. ``OutputSensitivities.selectors``
is a plain list, so one tensor carries the observable/expression rows the data terms read
and the species rows the continuity block reads -- requested together, off one integration,
because asking twice would mean integrating twice.
* **the segment's ``Data`` carries both too**, the ordinary observable/expression columns the
objective scores plus the species columns
:func:`~pybnf.shooting.backend.trace_from_data` reads the end-knot state from. Species
names carry parentheses (``A(b!1).B(a!1)``) and observables do not, so a collision is
vanishingly unlikely -- and is refused rather than silently overwritten, because a species
column quietly shadowing an observable would change what the fit scores.
Nothing in the net backend is modified: this composes its existing pieces (``_build_data``,
the engine clone, the mutant copy, the species-initializer sync) from outside, so every
ordinary ``.net`` fit is untouched.
The cost, which is real and is the model's, not the method's
-------------------------------------------------------------
The auxiliary block is ``(m - 1) x n_species`` wide and the initial-condition sensitivity
system is ``n_species`` wide, so the transcription scales with the **expanded** species count
rather than with the number of fitted parameters. On a small network that is nothing; on a
combinatorially expanded one it is the dominant term -- ``egfr_ground.net`` (356 species) at
``m = 4`` adds ~1068 auxiliary variables. That is a property of writing multiple shooting on
the state of a rule-based model, not something this backend can arrange away, so
``job_type = ms`` reports the added width when it starts rather than letting a user discover
it from the run time.
"""
import threading
import numpy as np
from ..data import Data, OutputSensitivities
from ..printing import PybnfError
from .backend import SegmentBackend, SegmentSimulationFailed
[docs]
class NetSegmentBackend(SegmentBackend):
"""One scored ``(model, condition)`` pair's segment simulator, on the ``.net`` path.
:param model: The :class:`~pybnf.bngsim_model.net_model.BngsimModel`. This backend
**owns** it, assigning the parameter set in place rather than deep-copying per
evaluation -- sound because the multiple-shooting driver runs on the master (a
segment is not a :class:`~pybnf.pset.PSet` evaluation and never reaches a worker),
and because the writes to it happen once per point under this backend's lock, before
any segment of that point runs. A *lane* is one such per-point clone plus its engine
and ``Simulator``, and two segments cannot share one (see
:mod:`pybnf.shooting.parallel`).
:param sim_params: The parsed ``simulate()`` action this experiment is measured by, as
:func:`~pybnf.bngsim_model.parsing._parse_simulate_action` returns it.
:param mutant: The ``MutationSet`` (condition) it is measured under.
:param suffix: The full output suffix, ``action suffix + mutant suffix``.
:param timeout: Per-segment wall-clock bound, from ``wall_time_sim``.
"""
def __init__(self, model, sim_params, mutant, suffix, timeout=None):
self.model = model
self.sim_params = dict(sim_params or {})
self.mutant = mutant
self.suffix = str(suffix)
self.timeout = timeout
self.print_functions = _flag(self.sim_params.get('print_functions', 0))
self._states = tuple(model._engine_model.species_names)
self._nominal = self._declared_state()
self.n_simulations = 0
self._point = None # identity of the PSet the prepared lanes hold
self._lanes = [] # one (model copy, engine, Simulator) per lane
self._lock = threading.RLock()
# -- the contract -----------------------------------------------------------
@property
def state_names(self):
return self._states
@property
def nominal_state(self):
return self._nominal
[docs]
def open_lanes(self, pset, n_lanes):
"""Build up to ``n_lanes`` model-copy + engine + simulator triples at ``pset``.
The ``.net`` peer of
:meth:`~pybnf.shooting.bngsim_backend.BngsimSegmentBackend.open_lanes`, and the more
expensive of the two: a lane here is a whole cloned PyBNF model, so the cost model in
:mod:`pybnf.shooting.parallel` tilts further toward serial on a small network and
further toward parallel on a large one, where the ``n_species``-wide
initial-condition sensitivity system dominates a segment.
"""
wanted = max(1, int(n_lanes))
with self._lock:
self._sync_point(pset)
for lane in range(wanted):
try:
self._context(lane)
except (PybnfError, SegmentSimulationFailed):
return max(1, lane)
return wanted
[docs]
def simulate(self, pset, sample_times, initial_state=None, lane=0):
times = [float(t) for t in np.asarray(sample_times, dtype=float).reshape(-1)]
if len(times) < 2:
raise PybnfError('A multiple-shooting segment needs at least two output times; '
'got %r.' % (times,))
with self._lock:
self._sync_point(pset)
prepared, engine, sim = self._context(lane)
self.n_simulations += 1
if initial_state:
for name, value in initial_state.items():
if name not in self._state_set:
raise PybnfError(
"Multiple shooting tried to restart model '%s' from a state named "
"'%s', which is not one of its species."
% (getattr(self.model, 'name', '?'), name))
engine.set_concentration(name, float(value))
engine.save_concentrations()
engine.reset()
try:
result = sim.run(t_span=(times[0], times[-1]), n_points=len(times),
sample_times=times, **self._run_kwargs())
data = self._data_with_state(prepared, result)
except Exception as exc:
# A non-integrable point is a property of the point, not of the run. That lane
# is dropped -- whatever state a failed solve left it in is not one to restart
# another segment from -- but only that lane, because a parallel pass has other
# segments still running in the others.
self._drop_lane(lane)
raise SegmentSimulationFailed('%s: %s' % (type(exc).__name__, exc)) from exc
if not np.all(np.isfinite(np.asarray(data.data, dtype=float))):
raise SegmentSimulationFailed('the segment produced a non-finite trajectory')
return data
# -- one engine + one simulator per parameter point, per lane ----------------
@property
def _state_set(self):
return set(self._states)
def _sync_point(self, pset):
"""Point this backend's lanes at ``pset``, discarding any built for another."""
if self._point is pset:
return
self._lanes = []
self.model.param_set = pset
# The per-action sensitivity gate (#475) reads this: without it a scored
# experiment's segments would run sensitivity-free and the assembly would find no
# tensor to differentiate.
self.model._current_action_suffix = self.sim_params.get('suffix', 'time_course')
self._point = pset
def _drop_lane(self, lane):
with self._lock:
if 0 <= lane < len(self._lanes):
self._lanes[lane] = None
def _context(self, lane):
"""Lane ``lane``'s model copy, engine model and ``Simulator``, built on demand and
reused across that point's segments.
Mirrors ``BngsimModel.execute``'s preamble -- apply the parameter set, re-derive the
species initial concentrations from it (a free parameter that only seeds an IC is a
silent no-op without that sync), reset -- on a *clone*, via the same
``_get_mutant_model_bngsim`` copy the ordinary mutant path uses. The wildtype's empty
``MutationSet`` goes through it too, so there is one code path rather than a special
case that could drift from it.
"""
while len(self._lanes) <= lane:
self._lanes.append(None)
if self._lanes[lane] is not None:
return self._lanes[lane]
try:
prepared = self.model._get_mutant_model_bngsim(self.mutant)
engine = prepared._engine_model
for name in (prepared.param_set or {}).keys():
try:
engine.set_param(name, prepared.param_set[name])
except Exception:
# A free parameter of another model in a multi-model fit; the ordinary
# execute path warns and carries on, and so does this one.
pass
prepared._sync_species_initial_concentrations(engine)
engine.reset()
sim = _runtime().bngsim.Simulator(
engine, method='ode', **prepared._codegen_kwargs('ode'),
**prepared._sensitivity_request_kwargs('ode'))
except PybnfError:
self._point = None
raise
except Exception as exc:
self._point = None
raise SegmentSimulationFailed(
'the model could not be prepared at this point (%s: %s)'
% (type(exc).__name__, exc)) from exc
self._lanes[lane] = (prepared, engine, sim)
return self._lanes[lane]
def _run_kwargs(self):
"""The action's own tolerances and this fit's ``wall_time_sim``, as the ordinary
net simulate path passes them."""
kwargs = {}
for key in ('atol', 'rtol'):
if key in self.sim_params:
try:
kwargs[key] = float(self.sim_params[key])
except (TypeError, ValueError):
pass
try:
timeout = float(self.timeout)
except (TypeError, ValueError):
timeout = 0.0
if timeout > 0.0:
kwargs['timeout'] = timeout
return kwargs
# -- both selector families, off one run -------------------------------------
def _data_with_state(self, prepared, result):
"""The ordinary net ``Data`` plus the species columns and their sensitivity rows.
The value columns are the net backend's own (``_build_data``, unmodified), so what
the objective scores is byte-identical to an ordinary fit's; the species columns are
appended, and the sensitivity tensor is requested over the union of both selector
families.
"""
data = prepared._build_data(result, print_functions=self.print_functions)
species = list(result.species_names)
clash = sorted(set(species) & set(data.cols))
if clash:
raise PybnfError(
"Multiple shooting adds model '%s'\\'s species columns to each segment's "
"trajectory so a knot can carry the state, and species %s already name a "
"scored column of that trajectory. One name cannot mean both an observable "
"and a species here." % (getattr(self.model, 'name', '?'), ', '.join(clash)))
headers = [data.headers[i] for i in sorted(data.headers)] + species
out = Data.from_columns(
np.column_stack([np.asarray(data.data, dtype=float),
np.asarray(result.species, dtype=float)]), headers)
sens = self._union_sensitivities(prepared, result, species)
if sens is not None:
out.output_sensitivities = sens
return out
def _union_sensitivities(self, prepared, result, species):
"""One tensor over the observable/expression rows the data terms read **and** the
species rows the continuity block reads.
bngsim answers a mixed selector list from a single run, which is what makes this one
integration rather than two. ``None`` on the scalar path, or when the run carried no
sensitivities.
"""
if getattr(prepared, '_sensitivity_request', None) is None:
return None
if not (getattr(result, 'has_sensitivities', False)
or getattr(result, 'has_sensitivities_ic', False)):
return None
selectors = ['observable:%s' % name for name in result.observable_names]
if self.print_functions and getattr(result, 'has_sensitivities_expressions', False):
selectors += ['expression:%s' % name
for name in prepared._differentiable_expression_names(result)]
selectors += ['species:%s' % name for name in species]
param_names = list(result.sensitivity_params)
ic_species = list(result.sensitivity_ic_species)
d_param = (np.asarray(result.output_sensitivities(selectors, axis='parameter'),
dtype=float) if param_names else None)
d_ic = (np.asarray(result.output_sensitivities(selectors, axis='ic'), dtype=float)
if ic_species else None)
return OutputSensitivities(selectors=selectors, param_names=param_names,
ic_species=ic_species, d_param=d_param, d_ic=d_ic)
def _declared_state(self):
"""Each species' declared magnitude, read off a freshly reset engine clone.
The constraint scales and the centre of each auxiliary variable's box need only a
representative number. A species declared at zero -- on a network that grows its
products, most of them -- says nothing about its own magnitude, so it inherits the
model's scalar one (the median of the positive declarations), the same substitution
ADR-0105 makes for the per-species absolute tolerance and for the same reason: the
alternative is a scale of zero, which the transcription layer refuses outright.
"""
engine = self.model._engine_model
declared = np.array([float(engine.get_concentration(name)) for name in self._states],
dtype=float)
declared[~np.isfinite(declared)] = 0.0
positive = declared[declared > 0.0]
fallback = float(np.median(positive)) if positive.size else 1.0
return np.where(declared > 0.0, declared, fallback)
def __repr__(self):
return 'NetSegmentBackend(%r, states=%i)' % (self.suffix, len(self._states))
def _runtime():
"""The net backend's lazily-imported bngsim runtime handle."""
from ..bngsim_model import _runtime as rt
return rt
def _flag(value):
try:
return bool(int(float(str(value))))
except (TypeError, ValueError):
return False