Source code for pybnf.gradient.routing

"""Free-parameter -> forward-sensitivity routing for the gradient path (#448, #385).

Step B of the #385 gradient-plumbing epic: a **pure mapping**, no objective math. Given an
edition-2 fit's free parameters, a model's bind-by-id namespace (ADR-0034), and an
experiment's condition perturbation (ADR-0028), this computes -- *per experiment* -- which
free parameters become a bngsim forward-sensitivity request and, for each, the chain-rule
``factor`` that converts a native sensitivity column into the derivative w.r.t. the *free*
parameter.

The routing feeds two consumers:

* the ``sensitivity_params`` / ``sensitivity_ic`` lists handed to #447's request at each
  experiment's Simulator (:meth:`pybnf.bngsim_model.net_model.BngsimModel.enable_output_sensitivities`); and
* the per-free-parameter ``factor`` that #449 multiplies into the per-experiment objective
  Jacobian. Building that Jacobian is out of scope here.

Bind by id (ADR-0034)
---------------------
A ``parameter:`` free parameter (ADR-0043) binds to the model entity of the same id -- the
contract ``set_param`` already uses. So classification is by name (:func:`classify_free_param`):

* a free parameter whose id is a species' **bare initial-value expression**
  (``_parse_net_species_initializers``) routes to ``sensitivity_ic`` keyed by the *species*.
  An initial-condition parameter does not appear in the ODE RHS, so the ``parameter``
  sensitivity axis would be identically zero; bngsim's ``ic`` axis carries it. (Checked
  first, because an IC parameter is *also* a ``begin parameters`` id.)
* a free parameter whose id is a model parameter routes to ``sensitivity_params`` keyed by
  the parameter.
* a free parameter matching **no** model id (e.g. a free sigma) is **not** a sensitivity
  request -- it carries no model column (its gradient is assembled in layer D, #449+). This
  is the existing unmatched-parameter path (a warning, not an error -- ADR-0034).

Per-condition perturbation = local derivative (ADR-0028)
--------------------------------------------------------
A ``condition:`` is a :class:`pybnf.pset.MutationSet` on the base model; each
:class:`pybnf.pset.Mutation` carries ``operation`` in ``{=,+,-,*,/}``. For a free parameter
``p`` perturbed in an experiment's condition the chain-rule ``factor`` is the local
derivative ``d(perturbed p)/dp`` (:func:`condition_factor`):

* ``p = c``  -> ``p`` is pinned to a constant in that experiment => factor ``0`` (its column
  is dropped from the request);
* ``p * c``  -> factor ``c``;  ``p / c`` -> factor ``1/c``;
* ``p + c`` / ``p - c`` -> factor ``1`` (a shift has unit slope);
* an unperturbed free parameter -> factor ``1``.

Multiple mutations on the same id compose: affine maps compose, so the multiplicative parts
multiply; any ``=`` pins the parameter, driving the composed factor to ``0``.

Cut-1 scope
-----------
IC routing matches a *bare* initializer (``species <- p``). A parameter that both appears in
the ODE RHS and seeds an initial value, or a species seeded by a non-trivial expression of
the free parameter (``2*p``), is a later layer -- the clean param/ic split is what #449's
first FD-oracle case needs.
"""

from dataclasses import dataclass

from ..printing import PybnfError


# Routing targets. ``PARAM`` -> sensitivity_params (kinetic/global), ``IC`` -> sensitivity_ic
# (species initial value), ``NONE`` -> bound to no model id (a free sigma; no model column).
PARAM = 'param'
IC = 'ic'
NONE = 'none'


[docs] @dataclass(frozen=True) class ParamRoute: """How one free parameter maps onto an experiment's forward-sensitivity request. ``target`` is :data:`PARAM` (kinetic/global -> ``sensitivity_params``), :data:`IC` (species initial value -> ``sensitivity_ic``), or :data:`NONE` (bound to no model id -- a free sigma; no model column). ``key`` is the request key the tensor is read by: the parameter id for :data:`PARAM`, the *species* for :data:`IC`, ``None`` for :data:`NONE`. ``factor`` is the chain-rule derivative ``d(perturbed param)/d(free param)`` for this experiment's condition (#449 multiplies it into the Jacobian column); a pinned (``=``) parameter has factor ``0`` and is dropped from the request list. """ free_param: str target: str key: object # str (param id / species) for param/ic; None for none factor: float
[docs] @dataclass class ExperimentRouting: """The per-experiment routing object: ``{free_param -> ParamRoute}`` plus the derived ``sensitivity_params`` / ``sensitivity_ic`` request lists handed to #447's gradient path. """ routes: dict # free_param -> ParamRoute, in declared free-parameter order @property def sensitivity_params(self): """``sensitivity_params=`` for the experiment's Simulator: every parameter-bound free parameter with a non-zero factor (a pinned ``=`` column is dropped), de-duplicated in declared free-parameter order.""" return self._request_keys(PARAM) @property def sensitivity_ic(self): """``sensitivity_ic=`` for the experiment's Simulator: the species of every IC-bound free parameter with a non-zero factor, de-duplicated in declared free-parameter order.""" return self._request_keys(IC) def _request_keys(self, target): keys = [] for route in self.routes.values(): if route.target == target and route.factor != 0.0 and route.key not in keys: keys.append(route.key) return keys
[docs] def condition_factor(free_param, condition): """The chain-rule factor ``d(perturbed param)/d(free param)`` for one free parameter under an experiment's condition (ADR-0028). ``condition`` is the experiment's :class:`pybnf.pset.MutationSet`, or ``None`` for the unperturbed wildtype. Composes every mutation that targets this id (affine maps compose, so the multiplicative parts multiply): ``=`` contributes ``0`` (pins the parameter to a constant), ``*c`` contributes ``c``, ``/c`` contributes ``1/c``, and ``+`` / ``-`` contribute ``1`` (an additive shift has unit slope). An id the condition does not touch keeps the identity factor ``1``. """ factor = 1.0 if condition is None: return factor for mut in condition: if mut.name != free_param: continue op = mut.operation if op == '=': # Pinned to a constant: its value no longer depends on the free parameter, so the # derivative is 0 and stays 0 through any later affine op in the same condition. factor *= 0.0 elif op == '*': factor *= mut.value elif op == '/': factor /= mut.value # '+' / '-': additive shift, unit slope -> factor unchanged. return factor
[docs] def classify_free_param(free_param, param_ids, species_initializers): """Classify one free parameter by id (ADR-0034): return ``(target, key)``. Checks the species initial-value namespace first: a free parameter that is a species' *bare* initial-value expression routes to the :data:`IC` axis keyed by the *species* (an IC parameter is absent from the ODE RHS, so the parameter axis is identically zero). Otherwise a match in the ``begin parameters`` namespace routes to :data:`PARAM` keyed by the id; no match at all is :data:`NONE` (a nuisance such as a free sigma -- no model column). ``param_ids`` is the model's ``begin parameters`` namespace (any container supporting ``in``); ``species_initializers`` is the ``(species, initial-expr)`` list from ``_parse_net_species_initializers``. """ for species, expr in species_initializers: if expr.strip() == free_param: return (IC, species) if free_param in param_ids: return (PARAM, free_param) return (NONE, None)
[docs] def route_experiment(free_params, param_ids, species_initializers, condition=None): """Build the :class:`ExperimentRouting` for one experiment (pure -- no model, no sim). ``free_params`` is the ordered free-parameter id list (the config's declared variables); ``param_ids`` the model's ``begin parameters`` namespace; ``species_initializers`` the ``(species, initial-expr)`` pairs; ``condition`` the experiment's :class:`pybnf.pset.MutationSet` (``None`` for the wildtype experiment). """ param_ids = set(param_ids) routes = {} for name in free_params: target, key = classify_free_param(name, param_ids, species_initializers) routes[name] = ParamRoute( free_param=name, target=target, key=key, factor=condition_factor(name, condition), ) return ExperimentRouting(routes=routes)
[docs] def route_for_model(model, free_params, condition=None): """:func:`route_experiment` against a live model's bind-by-id namespaces. Reads the model's ``begin parameters`` ids and ``(species, initial-expr)`` pairs through :meth:`BngsimModel.sensitivity_entity_namespace` (the only model coupling), so the routing core stays backend-agnostic. ``condition`` may be a :class:`pybnf.pset.MutationSet`, a condition *name* resolved against ``model.mutants``, or ``None`` for the wildtype experiment. """ param_ids, species_initializers = model.sensitivity_entity_namespace() condition = _resolve_condition(model, condition) return route_experiment(free_params, param_ids, species_initializers, condition)
[docs] def apply_routing(model, routing): """Hand a routing's request lists to #447's gradient path on ``model``. Calls :meth:`BngsimModel.enable_output_sensitivities` with the routing's ``sensitivity_params`` / ``sensitivity_ic`` -- the capability-gated activation of the gradient path. A build without forward output sensitivities refuses there (#447). Returns the same ``routing`` for chaining. """ model.enable_output_sensitivities( params=routing.sensitivity_params, ic=routing.sensitivity_ic) return routing
def _resolve_condition(model, condition): """Resolve a condition *name* to its :class:`MutationSet` on ``model``; pass anything else (a MutationSet or ``None``) through unchanged.""" if condition is None or not isinstance(condition, str): return condition mut = next((m for m in model.mutants if getattr(m, 'suffix', None) == condition), None) if mut is None: known = ', '.join(sorted(getattr(m, 'suffix', '') for m in model.mutants)) or '(none)' raise PybnfError( f"Condition '{condition}' is not defined on model '{getattr(model, 'name', '?')}'.", f"Define it with a 'condition:' line. Known conditions: {known}.") return mut