Source code for pybnf.measurement.base

"""The measurement-model observation layer (issue #407, ADR-0036).

A PEtab ``observableFormula`` is a **measurement model** -- a function from the simulation
*output trajectory* (+ the current parameter values) to the quantity compared against data.
It is an *observation-layer* concept, not part of the dynamical model. PyBNF evaluates it as
a **post-simulation transform** over the output trajectory, never by editing a model file:

* :class:`MeasurementModel` -- one named measurement model: an ``observable_id`` and an
  ``observableFormula`` (PEtab math), compiled lazily to a vectorized ``numpy`` callable
  over the trajectory's columns + the PSet.
* :class:`MeasurementLayer` -- the ordered collection and the
  ``(sim_data_dict, pset_values) -> sim_data_dict`` transform the objective applies *before*
  it scores (the empty layer is an exact no-op; ADR-0036 §2).

Because it sits downstream of simulation, the layer is **backend-agnostic** (RoadRunner,
bngsim, the legacy BNG stack all just produce a trajectory) and **language-agnostic** (one
mechanism for BNGL and SBML), and it carries the model file **verbatim**. It is the missing
M2 peer to :class:`~pybnf.priors.base.Prior` (ADR-0010) and the noise model (ADR-0011).

``petab``/``sympy`` (the optional ``pybnf[petab]`` extra) is imported lazily, only when a
formula is compiled (the first :meth:`MeasurementModel.materialize`); the bare-name
``observableFormula`` common case never becomes a :class:`MeasurementModel` and stays
dependency-free (ADR-0019/0036).
"""

import re

import numpy as np

from ..printing import PybnfError

# A PEtab per-measurement placeholder symbol (``observableParameter1_*`` / ``noiseParameter1_*``):
# in a row-varying observableFormula it stands for the *row's* scale/offset token, read from the
# binding table rather than substituted once (ADR-0045). Mirrors ``noise.source._PLACEHOLDER``.
_PLACEHOLDER = re.compile(r'(?:observable|noise)Parameter\d')


def _is_number(token):
    """Whether a per-measurement binding token is a numeric literal (vs a parameter id)."""
    try:
        float(token)
        return True
    except (TypeError, ValueError):
        return False


[docs] class MeasurementModel: """One named PEtab measurement model: ``observable_id`` = ``formula`` evaluated post-sim. ``formula`` is a PEtab math ``observableFormula`` over the model's expression namespace (``allowed_symbols`` -- the BNGL ``ParamList`` or SBML species u parameters, ADR-0026/0036). ``constants`` carries fixed model-parameter values (a numeric BNGL parameter RHS, or an SBML ``parameter``/``compartment`` value) snapshotted by the loader, for symbols that are neither an output column nor a free parameter. At :meth:`materialize` each free symbol resolves, in order, to **a trajectory column** (species / observable / global function / ``time`` -- vectorized over the time axis), **a PSet value** (a free / estimated parameter -- broadcast), or **a fixed constant** (broadcast). The compiled callable is built lazily on first use and excluded from pickling (a ``lambdify``\\ d callable is not picklable, and the objective carrying the layer is scattered to workers; ADR-0036 §5 -- the compile-once-per-worker pattern). """ def __init__(self, observable_id, formula, allowed_symbols, constants=None): self.observable_id = observable_id self.formula = formula self.allowed_symbols = frozenset(allowed_symbols) self.constants = dict(constants or {}) self._compiled = None # (callable, ordered_names); lazy, not pickled self._dcompiled = None # ({name: d_callable}, ordered_names); lazy, not pickled (#455) def __getstate__(self): state = self.__dict__.copy() state['_compiled'] = None # a lambdify callable is not picklable; recompile worker-side state['_dcompiled'] = None # the partials are lambdify callables too; recompile worker-side return state def _compile(self): if self._compiled is None: from ..petab.formula import compile_petab_formula self._compiled = compile_petab_formula( self.formula, self.allowed_symbols, detail=(f"Measurement model '{self.observable_id}': known symbols are the " f"model's species/parameters/observables/functions and the fit's " f"free parameters.")) return self._compiled def _compile_derivatives(self): """The partials ``{symbol: ∂formula/∂symbol}``, sharing :meth:`_compile`'s argument order, for the gradient path (#455). Lazy + not pickled, the same compile-once-per- worker pattern as the value callable (ADR-0036 §5).""" if self._dcompiled is None: from ..petab.formula import compile_petab_formula_derivatives self._dcompiled = compile_petab_formula_derivatives( self.formula, self.allowed_symbols, detail=(f"Measurement model '{self.observable_id}': known symbols are the " f"model's species/parameters/observables/functions and the fit's " f"free parameters.")) return self._dcompiled
[docs] def materialize(self, data, pset_values): """Evaluate this measurement model over one trajectory ``data`` -> a column vector. ``data`` is a :class:`~pybnf.data.Data` (one ``(model, suffix)`` simulation output); ``pset_values`` is the ``{name: value}`` map of the current PSet. Returns a 1-D ``numpy`` array of length ``data.data.shape[0]`` (the number of output rows). A formula over only scalars (no trajectory column) is broadcast to that length. """ func, names = self._compile() nrows = data.data.shape[0] args = [] for name in names: if name in data.cols: args.append(np.asarray(data[name], dtype=float)) elif name in pset_values: args.append(float(pset_values[name])) elif name in self.constants: args.append(float(self.constants[name])) else: # Validation at compile time should make this unreachable; keep it pointed. raise PybnfError( f"Measurement model '{self.observable_id}' references '{name}', which is " f"neither a simulation-output column ({sorted(data.cols)}) nor a fit/" f"model parameter. The measurement model cannot be evaluated.") column = func(*args) column = np.asarray(column, dtype=float) if column.shape != (nrows,): # A constant-valued (all-scalar) formula returns a scalar; broadcast it. column = np.full(nrows, float(column)) return column
[docs] def prediction_sensitivity(self, data, sim_row, pset_values, raw_sens, index): """The native-space ``∂(materialized column)/∂θ`` (a ``(len(index),)`` vector) of this measurement model at one trajectory row (#455/#385) -- the gradient sibling of :meth:`materialize`, resolving each symbol the same way. A materialized observable is ``f(symbol values)`` over sim-output columns + the PSet, so by the chain rule ``∂(col)/∂θ = Σ_symbol (∂f/∂symbol)·(∂symbol/∂θ)``: * a **sim-output column** symbol chains through its sensitivity, ``∂f/∂col · raw_sens(col, row)`` -- ``raw_sens(column_name, row)`` is the #447 tensor read at that row with any ``Data``-level normalization already folded in (the assembly's accessor), so a formula over the raw species/observables of *any* backend (SBML/Antimony species, ADR-0036) differentiates through their forward sensitivities; * a **directly-named free parameter** (a free symbol resolved from the PSet that the fit declares) contributes ``∂f/∂param`` straight to that parameter's column -- a parameter that enters the *observation* model, e.g. a scale/offset estimated as a fit parameter; * a **fixed model constant** (a snapshotted numeric parameter) and the independent variable contribute nothing. Both paths sum, so a parameter that both appears in the formula **and** moves the simulation gets its explicit ``∂f/∂param`` term plus the indirect ``∂f/∂col·∂col/∂param`` terms -- the complete total derivative. Mirrors :meth:`PerMeasurementModel.prediction_sensitivity` (the row-varying sibling), without the per-row placeholder branch a constant-per-observable model never has.""" derivs, names = self._compile_derivatives() # names match :meth:`materialize`'s order args, contribs = [], [] # contribs[k] = (kind, payload) parallel to names[k] for name in names: if name in data.cols: args.append(float(data.data[sim_row, data.cols[name]])) contribs.append(('column', name)) elif name in pset_values: args.append(float(pset_values[name])) contribs.append(('param', name)) elif name in self.constants: args.append(float(self.constants[name])) contribs.append(('constant', None)) else: # Validation at compile time should make this unreachable; keep it pointed. raise PybnfError( f"Measurement model '{self.observable_id}' references '{name}', which is " f"neither a simulation-output column ({sorted(data.cols)}) nor a fit/model " f"parameter. The measurement model cannot be differentiated.") grad = np.zeros(len(index)) for name, (kind, payload) in zip(names, contribs): if kind == 'constant': continue partial = float(derivs[name](*args)) if partial == 0.0: continue if kind == 'column': grad = grad + partial * raw_sens(name, sim_row) elif payload in index: grad[index[payload]] += partial return grad
[docs] class PerMeasurementModel: """A measurement model whose ``observableFormula`` references a **row-varying** PEtab per-measurement placeholder, bound per data point from the experimental data's binding table (the observable-side sibling of :class:`~pybnf.noise.PerMeasurementFormulaSigma`, ADR-0045). A :class:`MeasurementModel` covers a constant-per-observable scale/offset: it is pre-materialized once over the *simulation* trajectory by :class:`MeasurementLayer`. When the ``observableParameters`` token instead **differs row to row** -- a per-condition or per-timepoint scale -- there is no single column to materialize: the value must be evaluated **per data point, after the sim<->data match**, with that row's token in hand. So this model is **not** placed in the layer; it is registered on the objective (``_per_measurement_models``) and evaluated in the objective's prediction step, where ``exp_row`` is known. At :meth:`value` each free symbol of the (cached, lambdified) formula resolves to: a **placeholder** -> the row's token from ``exp_data.measurement_params[col_name][placeholder]\ [exp_row]`` (a number inlines, a parameter id resolves from the PSet -- a per-row estimated nuisance, ADR-0034); a **simulation-output column** -> that column's value at ``sim_row`` (unlike :class:`MeasurementModel`, which is vectorized over the whole column, this reads one matched point); a **PSet value**; or a **fixed model constant**. The compiled callable is built lazily and dropped on pickling (the compile-once-per-worker pattern, ADR-0036 §5); the binding table rides the experimental :class:`~pybnf.data.Data`, so it survives scatter independently of this model. """ def __init__(self, observable_id, formula, allowed_symbols, constants=None): self.observable_id = observable_id self.formula = formula # allowed_symbols includes the kept placeholder symbol(s) (config admits them), so the # lazy compile validates against the same set the importer/config validated against. self.allowed_symbols = frozenset(allowed_symbols) self.constants = dict(constants or {}) self._compiled = None # (callable, ordered_names); lazy, not pickled self._dcompiled = None # ({name: d_callable}, ordered_names); lazy, not pickled (#453) def __getstate__(self): state = self.__dict__.copy() state['_compiled'] = None # a lambdify callable is not picklable; recompile worker-side state['_dcompiled'] = None # the partials are lambdify callables too; recompile worker-side return state def _compile(self): if self._compiled is None: from ..petab.formula import compile_petab_formula self._compiled = compile_petab_formula( self.formula, self.allowed_symbols, detail=(f"Per-measurement model '{self.observable_id}': known symbols are the " f"model's species/parameters/observables/functions, the fit's free " f"parameters, and the row-varying observableParameters placeholder(s).")) return self._compiled def _compile_derivatives(self): """The partials ``{symbol: ∂formula/∂symbol}``, sharing :meth:`_compile`'s argument order, for the gradient path (#453). Lazy + not pickled, the same compile-once-per- worker pattern as the value callable (ADR-0036 §5).""" if self._dcompiled is None: from ..petab.formula import compile_petab_formula_derivatives self._dcompiled = compile_petab_formula_derivatives( self.formula, self.allowed_symbols, detail=(f"Per-measurement model '{self.observable_id}': known symbols are the " f"model's species/parameters/observables/functions, the fit's free " f"parameters, and the row-varying observableParameters placeholder(s).")) return self._dcompiled
[docs] def value(self, sim_data, sim_row, exp_data, exp_row, col_name, pset_values): """The measurement-model prediction for one matched data point -- a scalar. ``sim_data``/``sim_row`` locate the matched simulation point (trajectory columns read there); ``exp_data``/``exp_row``/``col_name`` locate the data row (its placeholder token(s) read from ``exp_data.measurement_params[col_name]``); ``pset_values`` is the objective's ``{name: value}`` PSet map (free parameters + per-row estimated nuisances). """ func, names = self._compile() bindings = self._row_bindings(exp_data, col_name) args = [] for name in names: if _PLACEHOLDER.match(name): args.append(self._resolve_token(bindings, name, exp_row, col_name, pset_values)) elif name in sim_data.cols: args.append(float(sim_data.data[sim_row, sim_data.cols[name]])) elif name in pset_values: args.append(float(pset_values[name])) elif name in self.constants: args.append(float(self.constants[name])) else: # Validation at compile time should make this unreachable; keep it pointed. raise PybnfError( f"Per-measurement model '{self.observable_id}' references '{name}', which " f"is neither a placeholder, a simulation-output column ({sorted(sim_data.cols)}), " f"nor a fit/model parameter. The measurement model cannot be evaluated.") return float(func(*args))
[docs] def prediction_sensitivity(self, sim_data, sim_row, exp_data, exp_row, col_name, pset_values, raw_sens, index): """The native-space ``∂pred/∂θ`` (an ``(len(index),)`` vector) of this per-measurement measurement model at one matched point (#453/#385) -- the gradient sibling of :meth:`value`, resolving each symbol the same way. ``raw_sens(column_name, sim_row)`` supplies ``∂(that sim column as _prediction sees it)/∂θ`` (the #447 sensitivity tensor, with any ``Data``-level normalization already folded in); ``index`` maps a free-parameter name to its column in the returned vector. The prediction is ``f(symbol values)``, so by the chain rule ``∂pred/∂θ = Σ_symbol (∂f/∂symbol)·(∂symbol/∂θ)``: * a **sim-output column** symbol chains through its sensitivity (``∂f/∂col · raw_sens``); * a **placeholder bound to a free-parameter id**, or a directly-named **free parameter**, contributes ``∂f/∂symbol`` straight to that parameter's column -- a per-row estimated nuisance / scale (ADR-0034). Unlike a free σ (layer D, which is model-unbound and lands only on the scalar path), such a parameter genuinely enters ``∂pred/∂θ``, so it belongs in the residual-Jacobian (the column it lands in is a square); * a **numeric token**, a **fixed model constant**, or the independent variable contributes nothing. Both paths sum, so a free parameter that appears in the formula **and** moves the simulation (named directly while also driving a referenced column) gets both its explicit ``∂f/∂param`` term and the indirect ``∂f/∂col·∂col/∂param`` terms -- the complete total derivative.""" derivs, names = self._compile_derivatives() # names match :meth:`value`'s arg order bindings = self._row_bindings(exp_data, col_name) args, contribs = [], [] # contribs[k] = (kind, payload) parallel to names[k] for name in names: if _PLACEHOLDER.match(name): token = self._token_at(bindings, name, exp_row, col_name) args.append(self._token_value(token, pset_values)) # a numeric token is a constant (no parameter); a parameter id chains to it contribs.append(('param', None if _is_number(token) else token)) elif name in sim_data.cols: args.append(float(sim_data.data[sim_row, sim_data.cols[name]])) contribs.append(('column', name)) elif name in pset_values: args.append(float(pset_values[name])) contribs.append(('param', name)) elif name in self.constants: args.append(float(self.constants[name])) contribs.append(('constant', None)) else: # Validation at compile time should make this unreachable; keep it pointed. raise PybnfError( f"Per-measurement model '{self.observable_id}' references '{name}', which " f"is neither a placeholder, a simulation-output column ({sorted(sim_data.cols)}), " f"nor a fit/model parameter. The measurement model cannot be differentiated.") grad = np.zeros(len(index)) for name, (kind, payload) in zip(names, contribs): if kind == 'constant': continue partial = float(derivs[name](*args)) if partial == 0.0: continue if kind == 'column': grad = grad + partial * raw_sens(name, sim_row) elif payload is not None and payload in index: grad[index[payload]] += partial return grad
@staticmethod def _row_bindings(exp_data, col_name): table = getattr(exp_data, 'measurement_params', None) if not table or col_name not in table: raise PybnfError( f"A row-varying observable formula for '{col_name}' needs a per-measurement " f"binding table, but none is attached to the experimental data. The " f"experiment must declare 'measurement_params: <file>.tsv' (ADR-0045).") return table[col_name] @staticmethod def _token_at(bindings, placeholder, exp_row, col_name): """The raw per-row token (a numeric literal or a parameter id) for ``placeholder`` at data row ``exp_row`` -- the shared lookup behind :meth:`_resolve_token` (the value) and :meth:`prediction_sensitivity` (which also needs the token's *kind*).""" try: return bindings[placeholder][exp_row] except (KeyError, IndexError): raise PybnfError( f"The per-measurement binding table for '{col_name}' has no value for " f"placeholder '{placeholder}' at data row {exp_row} (ADR-0045).") @staticmethod def _token_value(token, pset_values): """Resolve a raw token to its numeric value: a numeric literal inlines, a parameter id resolves from the PSet (a per-row estimated nuisance, ADR-0034).""" try: return float(token) # a per-row numeric token, inlined except (TypeError, ValueError): return pset_values[token] # a per-row parameter id, from the PSet @classmethod def _resolve_token(cls, bindings, placeholder, exp_row, col_name, pset_values): return cls._token_value(cls._token_at(bindings, placeholder, exp_row, col_name), pset_values)
[docs] class MeasurementLayer: """The ordered measurement models + the ``(sim_data_dict, pset_values)`` transform. :meth:`apply` walks every ``(model, suffix)`` :class:`~pybnf.data.Data` in the simulation output and materializes each :class:`MeasurementModel`'s column into it **in place**, before the objective's by-name column match. The **empty layer is an exact no-op** -- the byte-identical default for every job with no expression measurement model (ADR-0036 §2). Adding a column is additive (existing columns are untouched); an ``observableId`` that shadows an existing output column raises rather than silently overwriting it. """ def __init__(self, models=()): self.models = list(models) def __bool__(self): return bool(self.models) def __len__(self): return len(self.models)
[docs] def apply(self, sim_data_dict, pset_values): """Materialize every measurement model into each trajectory, in place. Returns the (now-augmented) ``sim_data_dict`` for call-site convenience.""" if not self.models: return sim_data_dict for model in sim_data_dict: for suffix in sim_data_dict[model]: data = sim_data_dict[model][suffix] for mm in self.models: self._add_column(data, mm.observable_id, mm.materialize(data, pset_values)) return sim_data_dict
@staticmethod def _add_column(data, name, column): if name in data.cols: raise PybnfError( f"Measurement model '{name}' would shadow an existing simulation-output " f"column of the same name (columns: {sorted(data.cols)}). Rename the " f"observable so its materialized column does not collide (ADR-0036).") idx = data.data.shape[1] # Use the Data.data setter (fires the weights observer): a measurement column is an # additive output column, scored exactly like a native observable/function column. data.data = np.column_stack([data.data, column]) data.cols[name] = idx data.headers[idx] = name