"""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``
comes from each parameter's scale in closed form (``Scale.d_inverse``,
``priors/scale.py``): ``1`` for LINEAR (short-circuited), ``ln(10)*10**u`` for log10,
``exp(u)`` for ln. No autodiff, so no gradient fit built on the shipped scales needs
the optional ``pybnf[jax]`` extra (ADR-0087, #524); a custom scale that supplies only
an ``inverse_jax`` still falls back to ``jax.grad`` (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/0066). 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``, which for a *chain* of
``Data``-level transforms wraps the tensor accessor once per stage, each stage's rule read in
the values that stage consumed and produced, #539/ADR-0102) 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.
The two ADR-0066 primitives close the same way (#533). A **floor** (``x' = x + rho*max(x)``) is
additive and separable, so ``∂x'_i/∂θ = s_i + rho*s_argmax`` -- one more term in
``_normalized_sensitivity``. An **analytic per-series scale** is the one transform that is not
per-point: its ``c*`` is profiled from the whole matched (sim, data) series, so the scored value
is ``c*(θ)·s_i(θ)`` and its sensitivity is the product rule ``c*·∂s_i/∂θ + s_i·∂c*/∂θ``. The
series-wide ``∂c*/∂θ`` is the closed-form derivative of the profiling condition
(:meth:`~pybnf.objective.SummationObjective.analytic_scale_sensitivity`) -- a geometric-mean
ratio for a log family, the least-squares optimum for a linear one -- computed once per
experiment here and shared by every point of the column. It does **not** vanish by the envelope
theorem: the profiling criterion is family-aware but σ-unweighted, so it is not in general the
objective's own minimizer over ``c``. Resolving which columns *this* experiment scales needs the
experiment's ``data_key``, which travels as the optional 4th element of each ``experiments``
item; without it a scaled column is refused rather than silently differentiated unscaled.
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, IC, 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 (``job_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[, data_key])`` items -- 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`. The optional 4th element is that
experiment's ``data_key`` (the suffix ``evaluate`` scores it under), needed only to resolve an
analytic per-series ``scale`` (ADR-0066, #533): omitting it is byte-identical for every other
fit, and refuses a scaled column rather than differentiating it unscaled. ``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}}
_seed_profiled_noise(objective, experiments)
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, *rest 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, data_key=rest[0] if rest else None):
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 _seed_profiled_noise(objective, experiments):
"""Put every analytically profiled noise scale at its MLE before the point walk (ADR-0108,
#562), so the seams below differentiate the loss at ``sigma_hat(theta)`` -- the value the
fit's own scoring uses at this point.
A profiled scale is not among ``free_params`` (the search never carries it), so the
``_pset_values`` seeding above cannot supply it; without this the noise sources would read a
stale value from a previous evaluation, or none at all. The gradient itself needs no new
term: ``sigma_hat`` minimizes the objective over the scale, so by the envelope theorem
``d/dtheta NLL*(theta) = partial/partial theta NLL(theta, sigma)`` at ``sigma = sigma_hat``
-- which is exactly the assembled frozen-scale gradient with the profiled columns dropped
(``objective.noise_grad_point``).
A no-op for a fit that profiles nothing. A degenerate group (an unbounded or non-finite
profile) has no gradient to assemble, so it refuses rather than differentiating a value that
does not exist."""
if not getattr(objective, '_profiled_noise_params', None):
return
triples = [(sim_data, exp_data, rest[0] if rest else None)
for sim_data, exp_data, _routing, *rest in experiments]
if not objective._resolve_profiled_noise(triples):
raise GradientNotSupported(
"An analytically profiled noise scale (noise_profiling = 1, ADR-0108) is degenerate "
"at this point -- its group's residual is zero or not finite, so the profiled "
"likelihood has no finite value and no gradient. Score this point on the "
"gradient-free path, or drop noise_profiling for this fit.")
def _accumulate_experiment(objective, sim_data, exp_data, routing, index, n_param,
rho_rows, jac_rows, noise_gradient, data_fit_gradient, hessian=None,
data_key=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, data_key):
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,
data_key=None):
"""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.
``data_key`` is the experiment's scoring key (the ``evaluate`` ``data_key`` -- the suffix),
needed only to resolve a per-series **analytic scale** (ADR-0066, #533): its profiled ``c*``
and ``∂c*/∂θ`` are properties of the whole series, so they are computed once here and handed
to every point of the column. A fit that scales nothing walks no extra points.
"""
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)
# The per-series analytic scale's (c*, ∂c*/∂θ) for each column this experiment scales
# (ADR-0066, #533) -- one profiling walk, shared by every point of the column, exactly the
# points ``evaluate`` profiles from. ``{}`` for an unscaled experiment (the common case), and
# ``None`` when the caller supplied no data_key, which ``prediction_sensitivity`` refuses
# rather than silently differentiating an unscaled prediction.
scale_terms = (objective.analytic_scale_sensitivity(
sim_data, exp_data, indvar, compare_cols, data_key, raw_sens, index)
if data_key is not None else None)
if scale_terms is not None:
# Point the objective's own scoring seam at THIS experiment's factors, exactly as
# ``evaluate`` does at the top of its loop: the residual / data-fit / Fisher consumers
# below read the prediction through ``_prediction``, which multiplies by them. Without
# this the residual would carry whichever experiment was scored last. ``{}`` for an
# unscaled experiment, which is what an unscaled fit already holds.
objective._scale_factors = {col: c for col, (c, _dc) in scale_terms.items()}
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 / analytic scale; #453/#533), 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,
scale_terms=scale_terms)
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
(``job_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[, data_key])`` 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}}
_seed_profiled_noise(objective, experiments)
hessian = np.zeros((n_param, n_param))
for sim_data, exp_data, routing, *rest in experiments:
_accumulate_experiment_fisher(objective, sim_data, exp_data, routing, index, n_param,
hessian, data_key=rest[0] if rest else None)
# 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, data_key=None):
"""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", data_key):
_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.
The routing is checked here -- once per experiment, before any point is read -- for the one
structural property ``tensor_sens`` relies on and cannot itself detect: each route names
each native column exactly once, so summing a route's contributions counts each column
exactly once (:meth:`~pybnf.gradient.routing.ExperimentRouting.check_column_multiplicity`,
#537). A column added twice is a clean integer multiple of the true derivative, which no
objective value can reveal -- the fit just walks a scaled surface -- so it is asserted
rather than assumed."""
routing.check_column_multiplicity()
_check_axes_are_not_the_same_derivative(sens, routing)
norm = sim_data.normalization or {}
normalized = {} # col -> the folded chain accessor, built on first use and reused per row
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)
records = norm.get(col_name)
if not records:
return tensor_sens(col_name, row)
if col_name not in normalized:
normalized[col_name] = _normalized_sensitivity(records, col_name, sim_data, tensor_sens)
return normalized[col_name](col_name, row)
return raw_sens
def _check_axes_are_not_the_same_derivative(sens, routing):
"""Refuse a route whose parameter axis and initial-condition axis are the *same* number (#537).
A free parameter that seeds a species initial value can reach the trajectory on two axes:
its own ``sensitivity_params`` column, and the ``sensitivity_ic`` column of the species it
seeds. The router keeps both when the model says the ODE right-hand side also reads the
parameter (ADR-0097, #535), because then the parameter axis carries the right-hand-side path
and the initial-condition terms carry the seeding -- two genuinely different quantities whose
sum is the derivative.
That is a statement about the *backend*, not a theorem. bngsim seeds ``∂x(0)/∂p`` into the
parameter axis as well (lanl/bngsim#43, widened to compound initialAssignment expressions by
lanl/bngsim#147), and where it does, the two axes are not complementary halves -- they are
the same derivative, and summing them doubles the column. On
``Raia_CancerResearch2011`` the two are byte-identical, and forcing both into the route
reproduces #537's signature exactly: ``init_Rec_i`` at 2.00000x its central difference, every
other column untouched.
So it is checked rather than assumed, numerically and per experiment: for a route holding
both, compare the parameter slice against the **sum of the initial-condition terms the route
is about to add**, each with its own chain-rule factor. Agreement means the parameter axis
is *entirely* seeding -- the right-hand-side path is zero -- so the two are one derivative
and the sum would double it.
Comparing against the weighted sum rather than a single slice is what makes this work for a
non-unit seed. Where the coefficient is exactly 1 the two columns are the same IVP and
CVODES returns them bit-for-bit (Raia); where it is not (``X(0) = a*X0`` gives
``d_param[X0] = 3.0 * d_ic[X]``) they agree to roundoff instead, which an equality test
misses -- lanl/bngsim#155. Hence a tight relative tolerance: two genuinely independent
derivatives of a live model do not track each other to 1e-12 across every selector and row.
What this **cannot** catch is a parameter that seeds *and* drives the right-hand side, where
``d_param`` is ``RHS + seeding`` and overlaps the initial-condition terms only partially --
no comparison of totals can see a partial overlap. That case is gated at routing time
instead (:func:`~pybnf.gradient.routing.route_for_model`) and closes properly once
lanl/bngsim#155 exposes the seed matrix. ``Fiedler_BMCSystBiol2016`` is that shape and
passes here, correctly.
"""
if sens.d_param is None or sens.d_ic is None:
return # only one axis was computed; nothing can be summed twice
for name, route in routing.routes.items():
own = next((c for c in route.contributions
if c.target == PARAM and c.key == name and c.requested), None)
if own is None or own.key not in sens.param_names:
continue
ic_terms = [c for c in route.contributions
if c.target == IC and c.requested and c.key in sens.ic_species]
if not ic_terms:
continue
# Both sides carry this experiment's condition factor, so scale the parameter slice by
# its own contribution's factor to compare like with like.
p_col = own.factor * sens.d_param[:, :, sens.param_names.index(own.key)]
ic_sum = sum(c.factor * sens.d_ic[:, :, sens.ic_species.index(c.key)] for c in ic_terms)
scale = float(np.abs(p_col).max()) if p_col.shape == ic_sum.shape else 0.0
if scale == 0.0 or np.abs(p_col - ic_sum).max() > 1e-12 * scale:
continue
raise PybnfError(
"Free parameter '%s' routes to both the parameter axis '%s' and the "
"initial-condition axis/axes %s, but the simulation returns the same numbers for "
"the two (agreeing to %.1e relative) -- they are one derivative, not the "
"right-hand-side and seeding halves of one, so the assembly would sum it twice and "
"report this column at an integer multiple of its true value. The backend seeds "
"d(x(0))/d('%s') into the parameter axis, which already carries the whole "
"derivative for a parameter the right-hand side does not otherwise read; the "
"routing should drop that axis (ADR-0097). See lanl/PyBNF#537, lanl/bngsim#155."
% (name, own.key, ', '.join(repr(c.key) for c in ic_terms),
float(np.abs(p_col - ic_sum).max()) / scale, name))
def _normalized_sensitivity(records, col_name, sim_data, tensor_sens):
"""Fold a column's normalization **chain** into one ``∂(normalized col)/∂θ`` accessor
(ADR-0053/0066, #539).
``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. A chain (``floor 0.03, peak``) applies two or more such
transforms in order, and each one's rule is that same closed form read in *its own* inputs:
the previous stage's per-row sensitivities, and its own output values. So the fold is
literal -- start from the raw #447 tensor accessor and wrap it once per stage, each wrapper
reading the accessor the previous stage returned. A single transform is the one-iteration
case and threads exactly the rule it always did.
The values a stage's rule reads are its **output** column: retained on the record when a
later transform overwrote them (:class:`~pybnf.data.NormalizationRecord.values`), and for
the last stage read back from the now-rescaled ``Data`` -- so a column normalized once
retains nothing. See :class:`~pybnf.data.NormalizationRecord` for each method's closed form.
Returns an accessor with ``tensor_sens``'s own ``(col_name, row)`` signature, which is what
lets each stage call its predecessor exactly as the un-chained rule called the tensor."""
final = sim_data.data[:, sim_data.cols[col_name]]
stage_sens = tensor_sens
for i, record in enumerate(records):
values = record.values if i < len(records) - 1 else final
if values is None:
# Only reachable if a caller normalized the column twice without going through
# Data's own normalize_* methods, which hand each overwritten stage its values.
raise GradientNotSupported(
"Column '%s' went through a chain of normalizations (%s) but stage %d ('%s') "
"kept none of the values it produced, which its own chain rule reads, so the "
"chain cannot be folded (issue #539). Use a single normalization per column, or "
"a gradient-free step."
% (col_name, ' -> '.join(r.method for r in records), i + 1, record.method))
stage_sens = _stage_sensitivity(record, values, stage_sens)
return stage_sens
def _stage_sensitivity(record, normed, previous):
"""One stage of a normalization chain: ``∂(this stage's output)/∂θ`` from ``previous``, the
accessor for the values it consumed, and ``normed``, the values it produced.
Memoized per row, because a stage reads its predecessor at rows other than the scored one --
the reference row a divisor is read from, and for ``zero`` *every* row. So a ``zero`` mid-
chain asks the stage below it for the whole column, once per scored row, and that stage asks
the one below it; without the memo the work would multiply down the chain per scored row.
With it each (stage, row) is computed once per experiment per evaluation. The tensor at the
bottom is not memoized, so a single transform costs exactly what it always did."""
cache = {} # keyed by row alone: a stage belongs to the one column its chain normalizes
def stage(col_name, row):
if row not in cache:
cache[row] = _stage_rule(record, col_name, row, normed, previous)
return cache[row]
return stage
def _stage_rule(record, col_name, row, normed, tensor_sens):
"""The closed-form rule for one recorded transform (:class:`~pybnf.data.NormalizationRecord`),
read in the values it consumed (``tensor_sens``, the previous stage's accessor -- the #447
tensor itself for the first transform of a column) and the values it produced (``normed``)."""
s_i = tensor_sens(col_name, row)
if record.method == 'floor':
# x' = x + rho*max(x) (ADR-0066): additive and separable, so ∂x'_i/∂θ = s_i + rho*s_argmax
# -- the scored row's own sensitivity plus rho times the row the max is read from
# (recorded as ref_row; the argmax is unchanged by a constant shift). Like ``peak``'s
# divisor, the max is differentiated at its achieving row (a.e. valid).
return s_i + record.rho * tensor_sens(col_name, record.ref_row)
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 sensitivities of the values it consumed (the #447 tensor's, or -- mid-chain -- the
previous stage's) 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 -- short-circuited, so the all-linear
case never touches a scale. For a log-scaled parameter, the scale's own analytic
derivative at the current ``u = forward(theta)`` (``priors/scale.py``), which keeps
the log10/ln bases in one place and 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 -- the scale's analytic derivative
(``d_inverse``, ADR-0087), falling back to autodiff of ``inverse_jax``.
Every built-in scale's inverse is an exponential, so its derivative is closed form
(``ln(10)*10**u`` / ``exp(u)``) and this stays off jax. It used to ``jax.grad`` that
scalar power, which JIT-compiled an XLA kernel *per rebuild of the Jacobian* -- ~40 ms
the persistent cache then declined as too cheap to keep -- and made the optional
``pybnf[jax]`` extra a hard requirement of most gradient fits, since ``loguniform_var``
is the usual way to declare a rate constant (#524). The autodiff route remains for a
custom :class:`~pybnf.priors.scale.Scale` that supplies only an ``inverse_jax``."""
try:
return float(free_param.d_from_sampling_space(float(u)))
except NotImplementedError:
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.
Reached only by a **custom** scale that defines an ``inverse_jax`` but no analytic
``d_inverse`` (ADR-0087), whose native -> sampling Jacobian factor is autodiffed
instead (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``. No
fit using only the built-in linear/log10/ln scales reaches here."""
try:
import jax
except ImportError as e:
raise PybnfError(
"Gradient assembly needs jax to autodiff a custom parameter scale that "
"defines no analytic derivative (`d_inverse`), which is the optional 'jax' "
"extra. Install it with `pip install pybnf[jax]` (or `uv pip install "
"pybnf[jax]`), or give the scale a `d_inverse`. A fit using only the "
"built-in linear / log10 / ln scales 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 (``job_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).
Every supplied routing is checked for the one-contribution-per-native-column invariant the
summation below relies on, exactly as the objective's accessor checks its own (#537)."""
for routing in routings.values():
routing.check_column_multiplicity()
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