Source code for pybnf.gradient.assembly

"""Gaussian objective gradient + residual-Jacobian assembly (#449, #385).

Step C of the #385 gradient-plumbing epic. Given #447's per-experiment forward
output-sensitivity tensor (``Data.output_sensitivities``) and #448's per-experiment
routing (:class:`pybnf.gradient.routing.ExperimentRouting`), assemble -- for the
default Gaussian, LINEAR-scale, fixed-sigma objective, summed across experiments --
both forms of the gradient:

1. the **scalar** ``dF/du`` (for quasi-Newton / L-BFGS-B), and
2. the **residual vector + residual-Jacobian** (for trust-region least-squares,
   #386's primary path).

Convention pin (issue #449)
---------------------------
PyBNF's Gaussian loss is ``data_fit = (pred - obs)**2/(2 sigma**2)`` with
``mu = pred`` for the default (LINEAR, MEDIAN) family, so per scored point ``i``:

* residual ``rho_i = (pred_i - obs_i)/sigma_i``,  ``loss = 1/2 ||rho||**2``;
* residual-Jacobian (native param space) ``J_ij = (1/sigma_i) * factor_j *
  d pred_i/d theta_j`` -- ``d pred/d theta`` from #447's tensor, ``factor_j`` from
  #448's routing;
* scalar gradient ``dF/d theta = J^T rho`` (**not** ``2 J^T rho``).

``scipy.least_squares`` minimizes ``1/2 ||rho||**2`` with the same ``rho``/``J``, so
the residual form and the scalar form agree by construction -- the optimizer walks
the surface PyBNF reports. The per-point bootstrap weight ``w_i`` (1.0 unless
bootstrapping) is folded in as ``sqrt(w_i)`` on both ``rho_i`` and ``J_i``, so
``1/2 ||rho||**2`` stays ``sum_i w_i * data_fit_i`` -- exactly what ``evaluate``
sums (``eval_point * weight``).

Native -> sampling space (once, ADR-0029)
-----------------------------------------
``rho`` is scale-invariant; the Jacobian moves to the sampling space the optimizer
walks by ``J -> J @ diag(d theta/d u)``, applied **once** at the end. ``d theta/d u``
is one autodiff of each parameter's scale ``inverse_jax`` (``priors/scale.py``) -- no
hand-written per-scale derivative. A LINEAR parameter has ``d theta/d u = 1`` and is
short-circuited, so the common (all-linear) path needs no jax; only a log-scaled
parameter pulls in the optional ``pybnf[jax]`` extra (the house pattern, ADR-0019).

Estimated noise scale (layer D, #451)
-------------------------------------
An estimated sigma -- the edition-2 ``noise_model = normal, sigma = fit <param>`` surface
(ADR-0021/0034), a freely-named free parameter; equivalently ``chi_sq_dynamic``'s legacy
``sigma__FREE`` default -- keeps the Gaussian normalizer, so the per-point loss is
``(pred-obs)**2/(2 sigma**2) + log sigma`` and gains a sigma column ``d loss/d sigma =
-(pred-obs)**2/sigma**3 + 1/sigma`` (``objective.noise_grad_point``). The routing binds
that free parameter by id (ADR-0034); estimated noise is matched by source *type*
(``FreeParameterSigma``), never by a name convention. ``+log sigma`` is **not** a sum
of squares, so it cannot live in the residual/Jacobian form: this assembly adds the
sigma column straight to the **scalar** gradient and leaves the residual-Jacobian a
faithful least-squares model of the *data fit* alone -- flagged by
``GradientResult.least_squares_exact`` (``False`` once any estimated scale is present),
so #386's trust-region path knows to use the scalar gradient (L-BFGS) for an
estimated-sigma fit. The free sigma routes to ``NONE`` in #448 (no model column), so
its gradient comes entirely from this normalizer + the sigma-dependence of the data
fit, never from the sensitivity tensor.

The estimated-scale column generalizes past a single free parameter: ``noise_grad_point`` returns
the full ``sum_p (dL/dp)*(dp/dtheta)`` vector, where each source supplies ``dp/dtheta``
(``sigma_sensitivity``). A single free sigma has ``dsigma/dname = 1`` and no sim coupling, so its
column is byte-identical to the historical scalar one. A **composite** sigma is the general chain
rule ``dsigma/dtheta = sum_symbol (dsigma/dsymbol)*(dsymbol/dtheta)``: a **PSet-only** formula
(``FormulaSigma`` / ``PerMeasurementFormulaSigma``, ADR-0044/0045/#505) puts ``dsigma/dcoeff`` on each
coefficient's column (the per-measurement variant binding a row token from ``exp_data``/``exp_row``);
a **prediction-dependent** sigma (``sigma = sigma_abs + sigma_rel*y``, a ``PredictionFormulaSigma``,
ADR-0075/0079) additionally chains ``dsigma/dprediction`` through the SAME ``raw_sens`` forward
sensitivity the residual rides -- so, unlike the others, it also perturbs the model-parameter columns.
Every estimated scale stays on the scalar path (the retained normalizer is not a square).

Trajectory transforms + normalization (layer F, #453)
-----------------------------------------------------
``_prediction`` may form the scored value from the raw observable through a per-observable
transform: a **cumulative -> incident** difference (ADR-0051), a **per-measurement** scale/
offset formula (ADR-0045), or an upstream ``Data``-level **normalization** (ADR-0053). Each
makes ``∂pred/∂θ`` differ from the raw observable sensitivity, so the assembly reads a
``raw_sens(col, row)`` accessor (the #447 tensor, routing-factor-folded, with normalization's
own quotient/chain rule threaded in -- ``_normalized_sensitivity``) and hands it to the
objective's :meth:`~pybnf.objective.SummationObjective.prediction_sensitivity` seam, which
mirrors ``_prediction`` branch for branch (cumulative differences sensitivity rows; a per-
measurement formula chains its symbolic gradient through each referenced column's sensitivity
plus any estimated placeholder it names -- which, unlike a free σ, *does* enter ``∂pred/∂θ`` and
so lands in the residual-Jacobian). A plain column collapses to the raw sensitivity, so the
no-transform path is byte-identical.

Asymmetric / non-Gaussian families (layer G, #454; least-squares residual #459)
-------------------------------------------------------------------------------
A family yields an **exact least-squares residual/Jacobian** iff its data fit reformulates as a
*smooth* half-square. Two do: the **Gaussian** (``data_fit = 1/2 rho**2``) and -- the #459
follow-up -- the **Student-t**, whose exact square-root-loss residual ``r = sign(z) sqrt(2
data_fit) = sign(z) sqrt((ν+1) log1p(z²/ν))`` satisfies both ``1/2 r² == data_fit`` *and*
``r · d r/d pred == d(data_fit)/d pred`` and is smooth through ``z=0`` (``r ~ sqrt((ν+1)/ν) z``,
downweighting the tails). Both route through ``residual_point`` (``NoiseModel.residual`` /
``d_residual_d_prediction``), contributing a residual row + native Jacobian row, so a fixed-scale
Student-t fit is ``least_squares_exact`` -- the Gaussian's exact-least-squares status recovered
for the robust family, and #386's LM/TRF can fit it directly.

**Laplace** has no such clean residual: its L1 data fit ``|·|/b`` gives ``sqrt(2 data_fit) ~
sqrt|z|``, a cusp with infinite slope at ``z=0``, so it stays scalar-only (the count family
likewise). Such a family's objective gradient is assembled from the **universal** scalar form
``sum_i w_i · d(data_fit_i)/d(pred_i) · d(pred_i)/d θ`` -- the per-family slope ``d(data_fit)/d(pred)``
(``objective.data_fit_grad_point`` -> ``NoiseModel.d_data_fit_d_prediction``) chained through the
same layer-F ``prediction_sensitivity`` ``d pred/d θ``, accumulated into a separate
``data_fit_gradient`` vector exactly as the estimated-noise column accumulates into
``noise_gradient``; the result is flagged not ``least_squares_exact`` (the residual/Jacobian then
model only the Gaussian/Student-t columns, if any), the signal #386's trust-region path uses the
scalar gradient (L-BFGS). The estimated-noise column generalizes per family throughout: Laplace's
``b``, and Student-t's ``sigma`` **and** ``df`` (the first multi-parameter estimated-noise
gradient, ADR-0058) -- a retained normalizer is never a square, so an estimated-scale fit is
inexact for *any* family (its data-fit residual, if it has one, still stacks; the normalizer
column rides ``noise_gradient``). The prediction (the MEDIAN or, layer G, the MEAN) and the noise
scale (linear or log, layer E) compose throughout; ``objective.has_least_squares_residual`` routes
each column. An all-Gaussian fit never touches ``data_fit_gradient`` (it stays zero), so that path
is byte-identical. The **negative-binomial**
count family rides the same scalar ``data_fit_gradient`` path (#458): its prediction slope is the
NB score chained through the median CDF-inversion implicit derivative, and its estimated dispersion
``r`` -- self-normalizing PMF, so the whole column is in the data fit -- the ``noise_gradient``
path. The dispersion column closes for MEAN and MEDIAN alike: a MEDIAN mean is solved from ``r``,
so its column folds in ``d mean/d r`` (the betainc first-parameter implicit derivative), the count
analogue of the mean-on-log offset coupling the location-scale families fold in (#385/#458).

Constraint / qualitative penalties (layer I, #456)
--------------------------------------------------
A fit may add qualitative / inequality constraints (BPSL ``.con`` / ``.prop`` files) whose
penalty is added to the objective. :func:`assemble_constraint_gradient` is the sibling assembler:
each constraint's penalty is a piecewise (static) or Gaussian-CDF (likelihood) function of an at-/
between-time readout ``q1 - q2``, so its gradient is that readout's forward sensitivity (read via
a ``(model, suffix, observable)``-keyed accessor over the #447 tensor + #448 routing) times the
local penalty slope (:meth:`~pybnf.constraint.Constraint.penalty_gradient`). Like an estimated-
noise normalizer, a penalty is not a sum of squares, so it lives on the scalar gradient only: a
fit with active constraints is not ``least_squares_exact``, and #386 adds this term to the
objective gradient.

Measurement-model layer (layer H, #455)
---------------------------------------
A scored observable may be a materialized **measurement-model** column (ADR-0036): an expression
``observableFormula`` the objective's :class:`~pybnf.measurement.base.MeasurementLayer` adds to the
trajectory before scoring (the SBML/Antimony / new-era PEtab path, where the SBML backend exposes
the same ``species:`` forward sensitivities the net backend does for observables). Such a column is
not in the #447 tensor, so the ``raw_sens`` accessor recognizes it and delegates to the model's
:meth:`~pybnf.measurement.base.MeasurementModel.prediction_sensitivity` -- the formula's exact chain
rule over each referenced column's sensitivity (read back through this same accessor, so routing /
normalization fold in) plus any fit parameter the formula names directly (an observation-model
scale/offset, which -- like a per-measurement scale -- enters ``∂pred/∂θ`` and lands in the
residual-Jacobian). A plain (non-measurement) column collapses to the tensor/normalized sensitivity,
so the no-measurement path is byte-identical.
"""

from dataclasses import dataclass

import numpy as np

from .errors import GradientNotSupported
from .routing import PARAM, NONE
from ..printing import PybnfError


[docs] @dataclass class GradientResult: """The assembled gradient of a Gaussian objective at one parameter point. ``residual`` is the stacked standardized residual ``rho`` (one entry per scored observation across all experiments, ``sqrt(weight)``-folded). ``jacobian`` is the matching ``(n_obs, n_param)`` residual-Jacobian **in sampling space** (the ``d theta/d u`` transform already applied). ``gradient`` is the scalar ``dF/d u`` over the free parameters, in ``param_names`` order. With a **fixed**-scale fit whose families all carry an exact least-squares residual -- the **Gaussian** (any scale/location) and, #459, the **Student-t** (its smooth square-root-loss residual) -- the data fit IS the whole objective, so the residual and scalar forms agree by construction (``gradient == jacobian.T @ residual``, ``0.5||rho||**2 == evaluate``) and ``least_squares_exact`` is ``True``. It is ``False`` once the residual-Jacobian is no longer the whole story: * an **estimated** noise parameter (layer D/G, #451/#454) -- its retained normalizer (``+log sigma``, ``log(2 b)``, the df-block) is not a square, so it is folded into the scalar ``gradient`` only and the residual-Jacobian models the data fit alone; or * a family with **no clean least-squares residual** (Laplace, whose L1 data fit is the cusp ``sqrt|z|``; the count family, layer G #454/#459) -- its data fit is not a smooth sum of squares, so it carries no residual at all and its whole data-fit gradient is on the scalar path. Either way the scalar ``gradient`` is complete (``jacobian.T @ residual`` over the residual-bearing columns -- Gaussian / Student-t -- if any, plus the data-fit and noise columns), and ``least_squares_exact = False`` is the signal that #386's trust-region step must consume it rather than the bare residual. """ residual: np.ndarray # (n_obs,) jacobian: np.ndarray # (n_obs, n_param), sampling space gradient: np.ndarray # (n_param,) = J^T rho + estimated-noise columns param_names: list # free-parameter order of the columns / gradient least_squares_exact: bool = True # False once an estimated sigma is present #: The expected-Fisher / Gauss-Newton Hessian (n_param, n_param), sampling space -- #: attached only on the EFIM trust-region path (``fit_type = gntr``, #481/#488) by #: :func:`assemble_gradient_and_fisher_hessian`; ``None`` for ``trf`` / ``lbfgs``, which #: never form it. hessian: np.ndarray = None
[docs] def assemble_gaussian_gradient(objective, experiments, free_params): """Assemble the scalar gradient and residual-Jacobian, summed across experiments. ``objective`` is the fit's :class:`~pybnf.objective.LikelihoodObjective`; it supplies each residual-bearing point's residual through ``residual_point`` (a Gaussian or, #459, a Student-t -- the families whose data fit is a smooth half-square), each no-residual (Laplace / count) point's data-fit gradient through ``data_fit_grad_point`` (routed by ``has_least_squares_residual``), and any estimated-noise gradient column through ``noise_grad_point`` (each gating the supported configuration -- a Gaussian / Laplace / Student-t / negative-binomial family, MEDIAN or MEAN, any noise scale, noise fixed or single free parameters -- raising :class:`GradientNotSupported` otherwise). ``experiments`` is an iterable of ``(sim_data, exp_data, routing)`` triples -- one per scored model/condition; each ``sim_data`` must carry the #447 ``output_sensitivities`` payload (the gradient path active), and ``routing`` is that experiment's :class:`~pybnf.gradient.routing.ExperimentRouting`. ``free_params`` is the ordered list of :class:`~pybnf.pset.FreeParameter` defining the ``u``-vector: it fixes the column order of the Jacobian and the entries of the scalar gradient, and supplies each parameter's scale (current value -> ``d theta/d u``). Returns a :class:`GradientResult`. The per-experiment routing is built once by the caller (#386) -- it depends only on model structure, conditions, and free-parameter ids, never on the parameter values -- so this per-evaluation assembly only reads the freshly simulated sensitivity tensors. """ return _assemble_gradient(objective, experiments, free_params, include_fisher=False)
[docs] def assemble_gradient_and_fisher_hessian(objective, experiments, free_params): """Assemble a :class:`GradientResult` and attach its expected-Fisher Hessian in one pass. This is the ``gntr`` objective-assembly path (#488). It produces the same residual, Jacobian, scalar gradient, and Fisher Hessian as calling :func:`assemble_gaussian_gradient` and :func:`assemble_fisher_hessian` separately, but walks each scored point only once. The shared point walk resolves the simulation row, filters missing observations, builds the raw sensitivity accessor, and calls ``prediction_sensitivity`` once before feeding both the gradient and curvature accumulators. """ return _assemble_gradient(objective, experiments, free_params, include_fisher=True)
def _assemble_gradient(objective, experiments, free_params, include_fisher): """Implementation shared by the gradient-only and combined gradient/Fisher assemblers.""" names = [p.name for p in free_params] index = {name: j for j, name in enumerate(names)} n_param = len(free_params) # An estimated free noise scale (a free sigma) reads its value from the objective's # per-evaluation pset map (ADR-0021); seed it from the current free-parameter point # so the loss is scored at u. Merged over any existing map so a prior evaluate's # fixed parameters survive (a fixed-sigma fit never reads it -- harmless there). existing = getattr(objective, '_pset_values', None) or {} objective._pset_values = {**existing, **{p.name: p.value for p in free_params}} rho_rows = [] jac_rows = [] # The estimated-noise (sigma) columns of the scalar gradient -- accumulated apart # from the residual-Jacobian because the normalizer ``+log sigma`` is not a square # (layer D, #451). Zero for a fixed-sigma fit. noise_gradient = np.zeros(n_param) # The no-residual-family data-fit gradient (layer G, #454/#459): a Laplace (its L1 data # fit is the cusp sqrt|z|) or count column has no least-squares residual, so its data fit # contributes its scalar gradient ``sum_i w_i * d(data_fit_i)/d(pred_i) * d(pred_i)/d theta`` # straight here. Zero for an all-Gaussian / Student-t fit (the residual-bearing path). data_fit_gradient = np.zeros(n_param) hessian = np.zeros((n_param, n_param)) if include_fisher else None least_squares_exact = True for sim_data, exp_data, routing in experiments: if _accumulate_experiment(objective, sim_data, exp_data, routing, index, n_param, rho_rows, jac_rows, noise_gradient, data_fit_gradient, hessian=hessian): least_squares_exact = False rho = np.asarray(rho_rows, dtype=float) jac = np.asarray(jac_rows, dtype=float).reshape(len(rho_rows), n_param) # Native -> sampling space, applied exactly once (ADR-0029): rho is invariant, each # Jacobian column scales by d theta_j/d u_j at the current value, and the two scalar # gradient accumulators (a free sigma's noise column; an asymmetric family's data-fit # column) take the same per-parameter chain factor. factors = _sampling_scale_factors(free_params) jac = jac * factors[np.newaxis, :] noise_gradient = noise_gradient * factors data_fit_gradient = data_fit_gradient * factors if hessian is not None: hessian = hessian * np.outer(factors, factors) gradient = jac.T @ rho + data_fit_gradient + noise_gradient return GradientResult(residual=rho, jacobian=jac, gradient=gradient, param_names=names, least_squares_exact=least_squares_exact, hessian=hessian) def _accumulate_experiment(objective, sim_data, exp_data, routing, index, n_param, rho_rows, jac_rows, noise_gradient, data_fit_gradient, hessian=None): """Append one experiment's per-point residual and native-space Jacobian rows (for a least-squares column -- Gaussian or Student-t, #459), accumulate any estimated-noise gradient columns into ``noise_gradient``, and accumulate a no-residual family's scalar data-fit gradient into ``data_fit_gradient`` (layer G, #454). Mirrors ``SummationObjective.evaluate``'s point loop exactly -- same independent variable, same comparable-column intersection, same NaN skip, same ``_sim_row_for`` row match -- so the gradient is assembled over precisely the points PyBNF scores. Columns are walked in sorted order for a deterministic observation axis (matching ``evaluate_pointwise``). Returns ``True`` iff this experiment made the result not ``least_squares_exact`` -- an estimated-noise column or a no-residual (Laplace / count) family was present (so the caller can clear the flag).""" inexact = False quantity = "gradient and Hessian" if hessian is not None else "gradient" for point in _iter_scored_points( objective, sim_data, exp_data, routing, index, n_param, quantity): if _accumulate_gradient_point( objective, sim_data, exp_data, index, point, rho_rows, jac_rows, noise_gradient, data_fit_gradient): inexact = True if hessian is not None: _accumulate_fisher_point(objective, sim_data, exp_data, index, point, hessian) return inexact def _iter_scored_points(objective, sim_data, exp_data, routing, index, n_param, quantity): """Yield each scored point and its native-space prediction sensitivity once (#488). This is the point-selection scaffold shared by the gradient-only, Fisher-only, and combined ``gntr`` assemblers: independent-variable resolution, comparable-column intersection, row matching, NaN filtering, raw-sensitivity access, and the trajectory-transform chain rule all live here. Each item is ``(sim_row, rownum, col_name, weight, dpred_dtheta, raw_sens)``; ``raw_sens`` travels with the point because estimated-noise gradient and Fisher blocks use the same accessor when differentiating their scale sources. """ sens = sim_data.output_sensitivities if sens is None: raise GradientNotSupported( "An experiment carries no forward-sensitivity tensor; enable the gradient " "path (apply_routing) on every scored model before assembling the %s." % quantity) indvar = min(exp_data.cols, key=exp_data.cols.get) comparable = set(sim_data.cols) | set(objective._per_measurement_models) compare_cols = set(exp_data.cols).intersection(comparable) compare_cols.discard(indvar) # The per-column sensitivity accessor (#453): ∂(column as _prediction sees it)/∂θ -- the # #447 tensor read at a row, routing-factor-folded, NONE/pinned parameters at 0, with any # Data-level normalization and measurement-model chain rules folded in. Built once per # experiment; the combined gradient/Fisher path then reuses it for both consumers. raw_sens = _raw_sensitivity_accessor(objective, sim_data, sens, routing, index, n_param, indvar) for rownum in range(exp_data.data.shape[0]): sim_row = objective._sim_row_for(sim_data, exp_data, indvar, rownum, show_warnings=False) for col_name in sorted(compare_cols): observation = exp_data.data[rownum, exp_data.cols[col_name]] if np.isnan(observation): continue weight = exp_data.weights[rownum, exp_data.cols[col_name]] # ∂pred/∂θ through the objective's transform seam (plain / cumulative / per- # measurement; #453), so every consumer differentiates exactly what is scored. A # pinned parameter and a model-unbound nuisance carry 0 in raw_sens. dpred_dtheta = objective.prediction_sensitivity( sim_data, sim_row, col_name, exp_data, rownum, raw_sens, index) yield sim_row, rownum, col_name, weight, dpred_dtheta, raw_sens def _accumulate_gradient_point(objective, sim_data, exp_data, index, point, rho_rows, jac_rows, noise_gradient, data_fit_gradient): """Consume one :func:`_iter_scored_points` item for the scalar/residual gradient.""" sim_row, rownum, col_name, weight, dpred_dtheta, raw_sens = point sqrt_w = np.sqrt(weight) # Layer D/G (#451/#454), ADR-0079: an estimated noise scale contributes its full # ``sum_p (dL/dp) * (dp/dtheta)`` vector directly to the scalar gradient. The normalizer is # not a square, so this stays off the residual-Jacobian. ``None`` means fixed noise. noise_vec = objective.noise_grad_point( sim_data, exp_data, sim_row, rownum, col_name, raw_sens, index) inexact = noise_vec is not None if noise_vec is not None: noise_gradient += weight * noise_vec if objective.has_least_squares_residual(col_name): # Gaussian and Student-t data fits are smooth half-squares: append their exact residual # and native Jacobian rows, sqrt(weight)-folded. rho, drho_dpred = objective.residual_point( sim_data, exp_data, sim_row, rownum, col_name) rho_rows.append(sqrt_w * rho) jac_rows.append(sqrt_w * drho_dpred * dpred_dtheta) else: # Laplace and count families have no clean least-squares residual; accumulate their # complete data-fit gradient on the scalar path. dfit_dpred = objective.data_fit_grad_point( sim_data, exp_data, sim_row, rownum, col_name) data_fit_gradient += weight * dfit_dpred * dpred_dtheta inexact = True return inexact def _accumulate_fisher_point(objective, sim_data, exp_data, index, point, hessian): """Consume one :func:`_iter_scored_points` item for its expected-Fisher terms.""" sim_row, rownum, col_name, weight, dpred_dtheta, raw_sens = point # Location block: w_i * kappa_i * outer(s_i, s_i). kappa = objective.location_fisher_point(sim_data, exp_data, sim_row, rownum, col_name) if kappa: hessian += (weight * kappa) * np.outer(dpred_dtheta, dpred_dtheta) # Noise block (ADR-0080): ``sum_p I_scale_p * outer(g_i^p, g_i^p)``. A single free # sigma supplies a diagonal unit-vector block; prediction-dependent scales may couple axes. noise_block = objective.noise_fisher_point( sim_data, exp_data, sim_row, rownum, col_name, raw_sens, index) if noise_block is not None: hessian += weight * noise_block
[docs] def assemble_fisher_hessian(objective, experiments, free_params): """Assemble the expected-Fisher / Gauss-Newton **Hessian** ``H`` (n_param x n_param), summed across experiments. This standalone API produces the same curvature the combined :func:`assemble_gradient_and_fisher_hessian` path feeds to the EFIM trust-region optimizer (``fit_type = gntr``, #481/#488). ``H = sum_i w_i [ kappa_i * outer(s_i, s_i) + sum_p I_scale_p * outer(g_i^p, g_i^p) ]`` where ``s_i = d(prediction_i)/d(theta)`` is the same forward sensitivity the gradient uses (through the objective's ``prediction_sensitivity`` seam), ``kappa_i`` the per-point location Fisher (``objective.location_fisher_point``: ``(d rho/d pred)**2`` for a residual-bearing Gaussian/Student-t column, the family's ``location_fisher`` for Laplace/count), ``I_scale_p`` each estimated noise parameter's expected Fisher, and ``g_i^p = d(noise_param_p)/d(theta)`` its scale sensitivity -- the whole ``sum_p I_scale_p * outer(g_i^p, g_i^p)`` noise block returned by ``objective.noise_fisher_point`` (ADR-0080). Every rank-1 term is PSD (``kappa >= 0``, ``I_scale >= 0``), so ``H`` is PSD by construction. For a bare **free** sigma ``g_i^p`` is the unit vector ``e_p`` (the noise parameter is model-unbound, 0 in ``s_i``), so its block is the historical diagonal entry ``I_scale * outer(e_p, e_p)`` and the estimated-sigma Hessian is block-diagonal, exactly the Fisher predicts. For a **prediction-dependent** sigma (``sigma = sigma_abs + sigma_rel*y``, ADR-0075/0079) ``g_i^p`` also carries model-parameter columns (the scale rides the prediction), so ``outer(g_i^p, g_i^p)`` produces the genuine location↔scale coupling off the diagonal -- a strict superset of the diagonal cut. ``experiments`` is the same ``(sim_data, exp_data, routing)`` iterable :func:`assemble_gaussian_gradient` consumes, ``free_params`` the same ordered free- parameter list. Mirrors that assembler's point loop exactly (same points, same ``raw_sens`` accessor, same ``prediction_sensitivity``), so the Hessian is formed over precisely the points the gradient and objective score. The native -> sampling transform (ADR-0029) is the gradient's ``d theta/d u`` factor applied on **both** axes: ``H <- diag(f) H diag(f)``. Raises :class:`GradientNotSupported` for a configuration whose Fisher this cut does not assemble (a MEDIAN-centered count, a MEAN-on-log estimated scale, ...), so the fit refuses the EFIM step with a pointer to the L-BFGS-B path.""" names = [p.name for p in free_params] index = {name: j for j, name in enumerate(names)} n_param = len(free_params) # An estimated free noise scale reads its value from the objective's per-evaluation pset # map (ADR-0021); seed it from the current point exactly as assemble_gaussian_gradient # does, so the Fisher is formed at u (idempotent -- the gradient assembly already seeded it). existing = getattr(objective, '_pset_values', None) or {} objective._pset_values = {**existing, **{p.name: p.value for p in free_params}} hessian = np.zeros((n_param, n_param)) for sim_data, exp_data, routing in experiments: _accumulate_experiment_fisher(objective, sim_data, exp_data, routing, index, n_param, hessian) # Native -> sampling space, applied once on both axes (ADR-0029): the same per-parameter # d theta/d u factor the gradient scales its columns by, here as an outer product. factors = _sampling_scale_factors(free_params) return hessian * np.outer(factors, factors)
def _accumulate_experiment_fisher(objective, sim_data, exp_data, routing, index, n_param, hessian): """Accumulate one experiment's per-point Fisher rank-1 terms into ``hessian`` (the curvature twin of :func:`_accumulate_experiment`). Same independent variable, same comparable-column intersection, same NaN skip, same ``_sim_row_for`` row match, same sorted-column walk -- so the Hessian is assembled over precisely the points the gradient is. The location block reads ``kappa_i`` (``location_fisher_point``) and the noise block the per-point matrix ``sum_p I_scale_p * outer(g_i^p, g_i^p)`` (``noise_fisher_point``, ADR-0080).""" for point in _iter_scored_points( objective, sim_data, exp_data, routing, index, n_param, "Hessian"): _accumulate_fisher_point(objective, sim_data, exp_data, index, point, hessian) def _sensitivity(sens, selector, contribution, sim_row, free_param): """The native forward sensitivity ``d(observable)/d(routed entity)`` at one time row. Reads the parameter axis (``sensitivity_params``) for a PARAM contribution and the initial-condition axis (``sensitivity_ic``) for an IC contribution, addressing the entity by the contribution's ``key`` (parameter id, or species for an IC). ``free_param`` names the routed free parameter for the diagnostic when a requested column is absent.""" if contribution.target == PARAM: axis, labels = 'parameter', sens.param_names else: # IC axis, labels = 'ic', sens.ic_species if contribution.key not in labels: raise GradientNotSupported( "Free parameter '%s' routes to %s '%s', but the simulation's " "sensitivity tensor has no such column (axis labels: %s). Apply the same " "routing to the model before running it (apply_routing)." % (free_param, axis, contribution.key, ', '.join(map(str, labels)) or '(none)')) column = sens.slice_for(selector, axis=axis) # (n_times, n_axis) return column[sim_row, labels.index(contribution.key)] def _raw_sensitivity_accessor(objective, sim_data, sens, routing, index, n_param, indvar): """Build ``raw_sens(col_name, row) -> (n_param,)``: native-space ``∂(that column as ``_prediction`` reads it)/∂θ`` (#453), the seam the objective's :meth:`~pybnf.objective.SummationObjective.prediction_sensitivity` composes transforms on. The base is the #447 forward tensor read at one row, each routed parameter's column scaled by its condition factor, with a pinned (factor 0) or model-unbound (``NONE``, e.g. a free σ) parameter left at 0 -- exactly the per-parameter Jacobian the assembly built inline before, now vectorised. The independent variable is θ-independent (sensitivity 0). When the column was normalized (ADR-0053), the normalizer's own θ-dependence is threaded here so the caller sees ``∂(normalized column)/∂θ`` and every downstream transform composes correctly (scoring applies normalize -> ``_prediction``). A **materialized measurement-model column** (ADR-0036, layer H #455) -- an expression ``observableFormula`` the objective's :class:`~pybnf.measurement.base.MeasurementLayer` added to the trajectory before scoring (the SBML/Antimony / new-era PEtab path) -- is not in the sensitivity tensor; its derivative is the formula's chain rule over the raw columns it references, so it is delegated to the model's ``prediction_sensitivity``, which calls back into this same accessor for each referenced column (a species/observable that *is* in the tensor, normalization-folded). A plain column has no measurement model and collapses to the tensor/normalized sensitivity, so the no-measurement path is byte-identical.""" norm = sim_data.normalization or {} measurement = getattr(objective, 'measurement', None) measurement_models = ({mm.observable_id: mm for mm in measurement.models} if measurement else {}) pset_values = getattr(objective, '_pset_values', None) or {} def tensor_sens(col_name, row): if col_name == indvar: return np.zeros(n_param) # the independent variable does not move with θ vec = np.zeros(n_param) selector = _selector_for(sens, col_name) for name, route in routing.routes.items(): total = 0.0 for c in route.contributions: if c.target == NONE or c.factor == 0.0: continue total += c.factor * _sensitivity(sens, selector, c, row, name) vec[index[name]] = total return vec def raw_sens(col_name, row): model = measurement_models.get(col_name) if model is not None: return model.prediction_sensitivity(sim_data, row, pset_values, raw_sens, index) record = norm.get(col_name) if record is None: return tensor_sens(col_name, row) return _normalized_sensitivity(record, col_name, row, sim_data, tensor_sens) return raw_sens def _normalized_sensitivity(record, col_name, row, sim_data, tensor_sens): """Thread a per-column normalizer's own derivative into ``∂(normalized col)/∂θ`` (ADR-0053). ``normalization`` rescales the predicted column by a θ-dependent ``N(θ)`` read off the moving trajectory, so ``∂(raw/N)/∂θ`` is a quotient/chain rule coupling the scored row with the row(s) ``N`` is read from. The raw per-row sensitivities ``s_k`` come from the (un-normalized) #447 tensor; the normalized values ``n_k`` are read back from the now- rescaled ``Data`` (so the raw values need not be retained). See :class:`~pybnf.data.NormalizationRecord` for each method's closed form.""" normed = sim_data.data[:, sim_data.cols[col_name]] s_i = tensor_sens(col_name, row) if record.method == 'floor': # x' = x + rho*max(x): additive, so ∂x'_i/∂θ = s_i + rho*s_argmax -- a simple rule, but # the floor's gradient is deliberately DEFERRED (ADR-0066, #479); refuse rather than # silently return the wrong (peak/init) quotient below. The fit falls back to a # gradient-free step (ADR-0475). raise GradientNotSupported( "Floor normalization ('floor', #479) on column '%s' has a deferred gradient; use a " "gradient-free step." % col_name) if record.method == 'zero': return _zscore_sensitivity(record, col_name, row, tensor_sens, normed, s_i) # peak / init / unit: a two-row (+ optional baseline) quotient rule. s_base = (tensor_sens(col_name, record.baseline_row) if record.baseline_row is not None else 0.0) s_ref = tensor_sens(col_name, record.ref_row) n_i = normed[row] return ((s_i - s_base) - record.sign * n_i * (s_ref - s_base)) / record.scale def _zscore_sensitivity(record, col_name, row, tensor_sens, normed, s_i): """``∂/∂θ`` of a z-score-normalized column (subtract mean μ, divide by std σ) -- the one method that couples **every** row through σ (ADR-0053). With ``s_bar`` the per-row mean of the raw sensitivities and σ = ``record.scale``:: ∂n_i/∂θ = (s_i - s_bar)/σ - n_i·(∂σ/∂θ)/σ, ∂σ/∂θ = Σ_k n_k (s_k - s_bar)/(K - ddof) (``n_k = (raw_k - μ)/σ`` is the recorded normalized value, so ``(raw_k - μ) = σ·n_k`` cancels the σ in ``∂σ/∂θ``). A σ of 0 means ``Data`` left the column un-divided (``n_i = raw_i - μ``), so ``∂n_i/∂θ = s_i - s_bar``.""" nrows = len(normed) all_s = np.array([tensor_sens(col_name, k) for k in range(nrows)]) # (K, n_param) s_bar = all_s.mean(axis=0) if record.scale == 0.0: return s_i - s_bar dsigma = (normed[:, np.newaxis] * (all_s - s_bar)).sum(axis=0) / (nrows - record.ddof) return (s_i - s_bar) / record.scale - normed[row] * dsigma / record.scale def _selector_for(sens, col_name): """The typed sensitivity selector for an objective column name. A scored (or measurement-formula-referenced) column is a BNGL model observable (``observable:<name>``), an ``expression:<name>`` global function (with ``print_functions``), or -- on the SBML/Antimony backend (layer H #455) -- a ``species:<name>``; the sensitivity tensor labels its columns the same way (#447/#455). Raises :class:`GradientNotSupported` if none was computed -- the gradient path needs a sensitivity column for every scored observable (or column a measurement formula reads).""" for prefix in ('observable:', 'expression:', 'species:'): selector = prefix + col_name if selector in sens.selectors: return selector raise GradientNotSupported( "No forward-sensitivity column for scored observable '%s' (have: %s)." % (col_name, ', '.join(sens.selectors) or '(none)')) def _sampling_scale_factors(free_params): """The ``d theta/d u`` Jacobian-diagonal for the native -> sampling transform. Identity (1.0) for every LINEAR parameter -- the common case, computed without jax. For a log-scaled parameter, autodiff its scale's ``inverse_jax`` at the current ``u = forward(theta)`` (``priors/scale.py``), so no per-scale derivative is hand-written and the log10/ln bases stay bit-consistent with the sampler.""" factors = np.ones(len(free_params)) for j, param in enumerate(free_params): if not param.log_space: continue u = param.to_sampling_space(param.value) factors[j] = _d_theta_d_u(param, u) return factors def _d_theta_d_u(free_param, u): """``d theta/d u`` for one log-scaled parameter via autodiff of ``inverse_jax``.""" jax = _require_jax() return float(jax.grad(free_param.from_sampling_space_jax)(float(u))) def _require_jax(): """Import ``jax`` lazily for the sampling-space transform, or raise a pointed error. The native -> sampling Jacobian of a **log-scaled** parameter autodiffs the scale's ``inverse_jax`` (ADR-0029/0059); ``jax`` is the optional ``pybnf[jax]`` extra (ADR-0019), so a missing install surfaces as a :class:`PybnfError` naming the extra -- the house pattern (mirroring ``samplers/hmc._require_jax``) -- never a bare ``ImportError``. An all-linear fit never reaches here.""" try: import jax except ImportError as e: raise PybnfError( "Gradient assembly needs jax to transform a log-scaled parameter's " "gradient into sampling space, which is the optional 'jax' extra. Install " "it with `pip install pybnf[jax]` (or `uv pip install pybnf[jax]`). A fit " "with only linear-scale parameters needs no extra." ) from e return jax # ============================== constraint penalty gradient (layer I, #456) ===
[docs] def assemble_constraint_gradient(constraint_sets, sim_data_dict, routings, free_params): """The scalar gradient of the total constraint penalty w.r.t. the free parameters (layer I, #456/#385), in **sampling space** -- the term #386 adds to the objective gradient for a fit with active constraints. ``constraint_sets`` is an iterable of :class:`~pybnf.constraint.ConstraintSet`; each constraint's penalty is a piecewise (static) or Gaussian-CDF (likelihood) function of an at-/ between-time readout, so its gradient is that readout's forward sensitivity times the local penalty slope (:meth:`~pybnf.constraint.Constraint.penalty_gradient`). ``sim_data_dict`` is the ``{model: {suffix: Data}}`` the penalties are scored on, each ``Data`` carrying the #447 sensitivity tensor; ``routings`` maps ``(model, suffix) -> ExperimentRouting`` (#448) so a readout's sensitivity is factor-folded into the free-parameter axes exactly as the objective's is. ``free_params`` fixes the parameter (column) order and supplies the native->sampling transform applied once at the end. A penalty is not a sum of squares, so -- like an estimated noise normalizer (layer D) -- it lives on the scalar gradient only: a fit with active constraints is not ``least_squares_exact``, and #386 must consume the scalar gradient. Returns a ``(n_param,)`` vector (zeros for an empty constraint set).""" names = [p.name for p in free_params] index = {name: j for j, name in enumerate(names)} n_param = len(free_params) # Live parameter values, so a constraint with an estimated scale reads its # current value and contributes its d(penalty)/d(scale) column -- the constraint counterpart of # assemble_gaussian_gradient seeding the objective's _pset_values from the free-parameter point. pset_values = {p.name: p.value for p in free_params} raw_sens = _constraint_sensitivity_accessor(sim_data_dict, routings, index, n_param) grad = np.zeros(n_param) for cset in constraint_sets: for constraint in cset.constraints: grad += constraint.penalty_gradient(sim_data_dict, raw_sens, index, n_param, pset_values=pset_values) return grad * _sampling_scale_factors(free_params)
[docs] def assemble_constraint_hessian(constraint_sets, sim_data_dict, routings, free_params): """The Gauss-Newton **Hessian** of the total constraint penalty w.r.t. the free parameters (layer I curvature, #481/#456), in **sampling space** -- the constraint block the EFIM trust-region path (``fit_type = gntr``) adds to :func:`assemble_fisher_hessian`'s data-fit Hessian, the curvature sibling of :func:`assemble_constraint_gradient`. Each constraint's penalty is ``P(q(theta))`` for an at-/between-time readout ``q``, so its exact Hessian is ``P''(q) * outer(grad q, grad q) + P'(q) * hess q``; the Gauss-Newton term drops the second-order sensitivity ``hess q`` (as the EFIM drops it for the data fit) and clamps ``P''`` to its positive part, giving the PSD ``max(P''(q), 0) * outer(grad q, grad q)`` (:meth:`~pybnf.constraint.Constraint.penalty_curvature`). Reuses :func:`_constraint_sensitivity_accessor` for ``grad q`` exactly as the gradient does, and applies the same native -> sampling ``d theta/d u`` factor on both axes. A **piecewise-linear** (static hinge ``.con``) penalty has ``P'' == 0``, so it contributes no curvature -- correct, a linear penalty has none; its pull rides the gradient. Returns zeros for an empty constraint set; raises :class:`GradientNotSupported` for a penalty whose curvature this cut does not assemble (a clipped smooth penalty, an estimated constraint scale) -> the L-BFGS-B fallback.""" names = [p.name for p in free_params] index = {name: j for j, name in enumerate(names)} n_param = len(free_params) pset_values = {p.name: p.value for p in free_params} raw_sens = _constraint_sensitivity_accessor(sim_data_dict, routings, index, n_param) hessian = np.zeros((n_param, n_param)) for cset in constraint_sets: for constraint in cset.constraints: hessian += constraint.penalty_curvature(sim_data_dict, raw_sens, index, n_param, pset_values=pset_values) factors = _sampling_scale_factors(free_params) return hessian * np.outer(factors, factors)
def _constraint_sensitivity_accessor(sim_data_dict, routings, index, n_param): """Build ``raw_sens(model, suffix, observable, row) -> (n_param,)``: the native-space forward sensitivity of one constraint readout, routing-factor-folded (#448), with a pinned (factor 0) or model-unbound (``NONE``) parameter left at 0 -- the constraint counterpart of the objective's ``raw_sens``, keyed by the full ``(model, suffix, observable)`` since a constraint may read any simulation's output. The independent variable does not move with theta (sensitivity 0); a constant operand is handled by the constraint (it never calls this).""" def raw_sens(model, suffix, observable, row): sim_data = sim_data_dict[model][suffix] sens = sim_data.output_sensitivities if sens is None: raise GradientNotSupported( "Constraint reads observable '%s' from model '%s' suffix '%s', whose simulation " "carries no forward-sensitivity tensor; enable the gradient path (apply_routing) " "on every model a constraint reads before assembling the constraint gradient." % (observable, model, suffix)) if observable == sim_data.indvar: return np.zeros(n_param) if (model, suffix) not in routings: raise GradientNotSupported( "Constraint reads observable '%s' from model '%s' suffix '%s', but no routing " "was supplied for it; provide an ExperimentRouting for every (model, suffix) a " "constraint reads (routings[(model, suffix)])." % (observable, model, suffix)) routing = routings[(model, suffix)] selector = _selector_for(sens, observable) vec = np.zeros(n_param) for name, route in routing.routes.items(): total = 0.0 for c in route.contributions: if c.target == NONE or c.factor == 0.0: continue total += c.factor * _sensitivity(sens, selector, c, row, name) vec[index[name]] = total return vec return raw_sens