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``.

Reached through a condition (a per-condition estimated initial condition, ADR-0076)
-----------------------------------------------------------------------------------
A free parameter that binds no model id of its own can still reach the model through a
``condition`` that sets a model entity to *its* value -- ``target = free_param``
(``is_param_ref``). The referenced free parameter then gains a :class:`RouteContribution` on the
target's own sensitivity column (:func:`classify_condition_target`): :data:`IC` when ``target``
seeds a species initial value (chain-rule factor ``d(IC)/d(target) = 1`` for a bare
``initialAssignment``), :data:`PARAM` for an ordinary global (e.g. a shared rate multiplier).
Because one free parameter may be assigned to *several* targets in one condition, a route is a
**sum** over its contributions (:class:`ParamRoute`).

Cut-1 scope
-----------
IC routing matches a *bare* initializer (``species <- p`` directly, or ``target = p`` where
``target`` bares a species IC). A parameter that both appears in the ODE RHS and seeds an
initial value, or a species seeded by a non-trivial expression (``2*p``), is a later layer --
:func:`classify_condition_target` raises :class:`GradientNotSupported` for the non-bare seed
rather than emitting a parameter-dependent factor, keeping the clean param/ic split honest.
"""

from dataclasses import dataclass

from ..printing import PybnfError
from .errors import GradientNotSupported


# 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 RouteContribution: """One ``(native sensitivity column -> free parameter)`` term of a route. ``target`` is :data:`PARAM` (kinetic/global -> ``sensitivity_params``), :data:`IC` (species initial value -> ``sensitivity_ic``), or :data:`NONE` (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 folded into this term (#449 multiplies it into the Jacobian column); a zero factor drops it. """ target: str key: object # str (param id / species) for param/ic; None for none factor: float
[docs] @dataclass(frozen=True) class ParamRoute: """How one free parameter maps onto an experiment's forward-sensitivity request. A free parameter's derivative is the **sum** over its ``contributions`` -- one :class:`RouteContribution` per native sensitivity column it reaches. The common case is a single contribution: a ``parameter:`` free parameter bound by id (ADR-0034) reaches exactly one model column. A free parameter routed *only* through a condition (a per-condition estimated initial condition, ADR-0076) reaches its column through the condition target instead; and a free parameter a condition assigns to *several* model entities at once (a shared rate multiplier) reaches several columns, so its derivative is their sum. ``.target`` / ``.key`` / ``.factor`` read the sole contribution of a single-column route (every bind-by-id route); reading them on a multi-column route raises -- use ``.contributions``. """ free_param: str contributions: tuple # of RouteContribution, in composition order
[docs] @classmethod def single(cls, free_param, target, key, factor): """A route with a single :class:`RouteContribution` -- the common bind-by-id case.""" return cls(free_param, (RouteContribution(target, key, factor),))
@property def target(self): return self._sole().target @property def key(self): return self._sole().key @property def factor(self): return self._sole().factor def _sole(self): if len(self.contributions) != 1: raise ValueError( f"ParamRoute for '{self.free_param}' has {len(self.contributions)} " f"contributions; read .contributions, not .target/.key/.factor.") return self.contributions[0]
[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-axis column any free parameter reaches 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: every species initial-condition column any free parameter reaches 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(): for c in route.contributions: if c.target == target and c.factor != 0.0 and c.key not in keys: keys.append(c.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 classify_condition_target(target, param_ids, species_names, ic_seed_map): """Classify the model entity a param-ref condition sets: return ``(axis, key, factor)``. ``target`` is the model id a ``condition`` assignment ``target = free_param`` sets (a per-condition estimated initial condition, ADR-0076). A model parameter that seeds a species' initial value routes to the :data:`IC` axis keyed by that species (checked first -- an IC seed is *also* a ``begin parameters`` id, but only its IC axis is non-zero); a species set directly routes to :data:`IC` on itself; any other model parameter routes to :data:`PARAM`. ``factor`` is ``d(model entity)/d(model parameter)`` -- ``1`` for a bare seed or a direct set. ``ic_seed_map`` maps a model parameter to the species whose initial value it *bares* (a bare ``initialAssignment`` ``species = <param>``); the value ``None`` marks a parameter that seeds a species IC through a **non-bare** expression (a parameter-dependent ``d(IC)/d(param)`` this bind-by-id routing does not model). Raises :class:`GradientNotSupported` for that non-bare seed and for a target that binds no sensitivity entity at all -- keeping a gradient/EFIM fit honest rather than emitting a silently-zero column. """ if target in ic_seed_map: species = ic_seed_map[target] if species is None: raise GradientNotSupported( f"Condition sets '{target}', which seeds a species initial value whose " f"d(IC)/d('{target}') is not a plain 1 -- a non-bare initialAssignment " f"expression, an amount species needing a non-unit concentration factor, or a " f"parameter seeding several species. The gradient path does not yet route this " f"per-condition estimated initial condition (ADR-0076). Use a gradient-free " f"optimizer or sampler for this fit.") return (IC, species, 1.0) if target in species_names: return (IC, target, 1.0) if target in param_ids: return (PARAM, target, 1.0) raise GradientNotSupported( f"Condition sets '{target}' to the value of a free parameter, but '{target}' is " f"neither a model parameter nor a species initial value the sensitivity request can " f"bind; the gradient path cannot route it. Use a gradient-free optimizer or sampler.")
[docs] def route_experiment(free_params, param_ids, species_initializers, condition=None, ic_seed_map=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); ``ic_seed_map`` the ``{model parameter -> species}`` bare-``initialAssignment`` map (:func:`classify_condition_target`). A parameter-reference perturbation (a per-condition estimated initial condition, ADR-0076) ``target = free_param`` **composes** the chain rule: the referenced free parameter reaches the trajectory through the condition target's own sensitivity column, so it gains a :class:`RouteContribution` on that column (:data:`IC` when the target seeds a species IC, :data:`PARAM` for an ordinary global). One free parameter a condition assigns to several targets at once (a shared rate multiplier) accumulates one contribution per target -- its derivative is their sum. A target whose ``d(IC)/d(param)`` is not a bare ``1`` (a non-bare initialAssignment), or that binds no sensitivity entity, raises :class:`GradientNotSupported` rather than emitting a silently-zero column. """ param_ids = set(param_ids) ic_seed_map = ic_seed_map or {} free_param_set = set(free_params) species_names = {species for species, _ in species_initializers} # Contributions a condition's parameter-reference perturbations add to the *referenced* free # parameter: it reaches the model through the condition target's own sensitivity column. ref_contribs = {} # free_param -> [RouteContribution] if condition is not None: for mut in condition: if not getattr(mut, 'is_param_ref', False): continue free_param = mut.value if free_param not in free_param_set: # References a non-variable; the config layer validates this (Mutation.amount). continue if mut.operation != '=': raise GradientNotSupported( f"Condition perturbs '{mut.name}' {mut.operation} '{mut.value}' by a " f"non-'=' parameter reference; the gradient path routes only an '=' " f"per-condition estimated initial condition (ADR-0076). Use a gradient-free " f"optimizer or sampler for this fit.") axis, key, factor = classify_condition_target( mut.name, param_ids, species_names, ic_seed_map) ref_contribs.setdefault(free_param, []).append( RouteContribution(axis, key, factor)) routes = {} for name in free_params: contribs = [] base_target, base_key = classify_free_param(name, param_ids, species_initializers) if base_target != NONE: contribs.append( RouteContribution(base_target, base_key, condition_factor(name, condition))) contribs.extend(ref_contribs.get(name, [])) if not contribs: # No model column at all (a free sigma, or a free parameter pinned out of every # experiment): a single NONE contribution, dropped by the request lists and assembly. contribs.append(RouteContribution(NONE, None, condition_factor(name, condition))) routes[name] = ParamRoute(free_param=name, contributions=tuple(contribs)) 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, ``(species, initial-expr)`` pairs, and the bare-``initialAssignment`` seed map 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, ic_seed_map = model.sensitivity_entity_namespace() condition = _resolve_condition(model, condition) return route_experiment(free_params, param_ids, species_initializers, condition, ic_seed_map=ic_seed_map)
[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
[docs] def apply_routings(model, routings): """Hand the **union** request over several routings to #447's gradient path on ``model``. The model's forward-sensitivity request rides the scatter and is applied at every simulate(), so it must cover every column any scored experiment reads -- the union of the per-condition ``sensitivity_params`` / ``sensitivity_ic``. (The wildtype request 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.) Capability-gated exactly as :func:`apply_routing`. Returns the applied ``(params, ic)`` lists. """ params, ic = [], [] for routing in routings: for key in routing.sensitivity_params: if key not in params: params.append(key) for key in routing.sensitivity_ic: if key not in ic: ic.append(key) model.enable_output_sensitivities(params=params, ic=ic) return params, ic
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