Source code for pybnf.parameter_record

"""The new-era ``parameter:`` record -> :class:`~pybnf.pset.FreeParameter` (ADR-0043).

The edition-2 free-parameter declaration is a *fully labeled* record --
``parameter: <id>, prior: <family>, <field>: <v>, ..., lower: <lo>, upper: <hi>,
initial_value: <v>`` -- where the legacy positional ``<family>_var = <id> p1 p2`` line
names nothing. It is also the **only** grammar that carries ``lower``/``upper``, so a
truncated prior (two-sided, ADR-0020; half-bounded, ADR-0047) can be written no other way.

This module is the one mapping from that record onto a ``FreeParameter``, with two
consumers. :class:`pybnf.config.Configuration` reads it when it loads a job to fit, and
:mod:`pybnf.petab.export` reads it when it serializes a job to a PEtab v2 problem. They
must agree: a PEtab row and a native declaration landing on the same object is the
two-adapter proof (ADR-0004), and an exporter that re-derived the record grammar for
itself would drift from the one the fitter actually runs. It lived as a private
``Configuration`` method until the exporter needed it, and was silently skipping every
``parameter:`` record for want of it (#733).

Light by design (``printing`` / ``priors`` / ``pset`` / numpy): the exporter reads a job
through the stdlib ``ploop`` parser without building a ``Configuration``, and pulling the
whole configuration layer in to reach one builder would defeat that.
"""

import logging

import numpy as np

from .printing import PybnfError
from .priors import PRIOR_KEYWORD_MAP
from .pset import FreeParameter

logger = logging.getLogger(__name__)


[docs] def free_parameter_from_record(pid, raw_fields, initialization_distribution): """Build a :class:`FreeParameter` from a new-era ``parameter:`` record (ADR-0043). ``raw_fields`` is the parsed ``{field: str}`` map -- every part of the line is named: ``prior`` (the family), ``space`` (``linear``/``log10``, the sampling-space transform), the family's own distribution fields (``mean``/``sd``, ``location``/``scale``, ...), ``lower``/``upper`` (the bounds that truncate the prior -- #417/ADR-0020), and ``initial_value`` (the start point). No positional numbers; the family names its fields via ``Prior.field_names``. The truncation/box capability is unchanged -- this only maps named fields onto the existing ``FreeParameter`` constructor. """ fields = dict(raw_fields) def _num(name): v = fields.pop(name) try: return float(v) except (TypeError, ValueError): raise PybnfError(f"Parameter '{pid}': field '{name}' must be a number, got {v!r}.") prior_name = fields.pop('prior', None) # The sampling-space transform. PyBNF samples in linear, log10, or natural log; each # base is named explicitly so it is never ambiguous (ADR-0022/0043). ``lin`` is # accepted as PEtab's spelling of ``linear``; ``log`` is rejected as ambiguous (PEtab # means natural by it, PyBNF historically means log10) -- write ``ln`` or ``log10``. # The base prefixes the family keyword (``log{f}_var`` / ``ln{f}_var`` / ``var``). pscale = str(fields.pop('parameter_scale', 'linear')).lower() scale_prefix = {'lin': '', 'linear': '', 'log10': 'log', 'ln': 'ln'} if pscale == 'log': raise PybnfError( f"Parameter '{pid}': parameter_scale 'log' is ambiguous -- write 'log10' " f"(base 10) or 'ln' (natural log) explicitly (ADR-0022).") if pscale not in scale_prefix: raise PybnfError(f"Parameter '{pid}': parameter_scale must be 'linear', 'log10', or " f"'ln', got '{pscale}'.") prefix = scale_prefix[pscale] lower = _num('lower') if 'lower' in fields else None upper = _num('upper') if 'upper' in fields else None # Bounds come as a pair: an open side is an explicit +-inf, never a blank # (ADR-0047 -- no specification by absence). Omitting *both* is the untruncated # shorthand. One-sided truncation IS supported now -- spell the open side with # an infinity. The graded bound rule (positivity, support floor and ceiling) is # applied per path below: finite for a uniform box, the family's support endpoints # for a truncated prior. if (lower is None) != (upper is None): present, absent = ('lower', 'upper') if upper is None else ('upper', 'lower') raise PybnfError( f"Parameter '{pid}': bounds come as a pair -- '{present}' is set but " f"'{absent}' is missing. For an open {absent} side write an explicit " f"infinity ('{absent}: inf' or '{absent}: -inf'), not a blank (ADR-0047).") initial_value = _num('initial_value') if 'initial_value' in fields else None is_log_scale = prefix in ('log', 'ln') if prior_name is None: if lower is not None: # No prior but bounds -> uniform over the bounds (PEtab's default for an # estimated parameter without an explicit prior; the importer does the same). _require_finite_box(pid, lower, upper, is_log_scale, "a uniform box") keyword = f'{prefix}uniform_var' _reject_extra_fields(pid, fields, keyword) return FreeParameter(pid, keyword, lower, upper, value=initial_value, bounded=True, initialization_distribution=initialization_distribution) # No prior and no bounds -> the no-prior start point (legacy var/logvar/lnvar). Its # start value is carried in the FreeParameter's first slot *in sampling space* # (Simplex reads it via from_sampling_space(p1)), so map the theta-space # initial_value through the scale -- making initial_value the real value (theta) for # a log start point too, consistent with the prior-param case. if initial_value is None: raise PybnfError(f"Parameter '{pid}': declares no prior, no bounds, and no " f"initial_value -- nothing to fit. Give it a 'prior:', a " f"'lower:'/'upper:' box, or an 'initial_value:'.") if is_log_scale and initial_value <= 0.0: raise PybnfError(f"Parameter '{pid}': a {pscale} start point needs " f"initial_value > 0, got {initial_value}.") _reject_extra_fields(pid, fields, 'a no-prior start point') _, start_scale = PRIOR_KEYWORD_MAP[f'{prefix}var'] return FreeParameter(pid, f'{prefix}var', float(start_scale.forward(initial_value)), None, initialization_distribution=initialization_distribution) prior_name = str(prior_name).lower() if prior_name == 'uniform': # Uniform: lower/upper ARE the support (and the bounds); no separate family fields. if lower is None: raise PybnfError(f"Parameter '{pid}': a uniform prior needs 'lower' and 'upper'.") _require_finite_box(pid, lower, upper, is_log_scale, "a uniform prior") keyword = f'{prefix}uniform_var' _reject_extra_fields(pid, fields, keyword) return FreeParameter(pid, keyword, lower, upper, value=initial_value, bounded=True, initialization_distribution=initialization_distribution) keyword = f'{prefix}{prior_name}_var' if keyword not in PRIOR_KEYWORD_MAP: raise PybnfError(f"Parameter '{pid}': unknown prior family '{prior_name}'.") fam, _scale = PRIOR_KEYWORD_MAP[keyword] params = [] for fname in fam.field_names: if fname not in fields: raise PybnfError(f"Parameter '{pid}': prior '{prior_name}' needs field '{fname}'.") params.append(_num(fname)) _reject_extra_fields(pid, fields, f"prior '{prior_name}'") p1 = params[0] p2 = params[1] if len(fam.field_names) >= 2 else None # A three-parameter family (student_t, ADR-0057) carries its third value in p3; # field_names ordered it last (df/location/scale -> p1/p2/p3). The carrier and # build_prior pass it through; it is None for the one- and two-parameter families. p3 = params[2] if len(fam.field_names) >= 3 else None # lower/upper truncate an unbounded-support family to a reflecting box: two finite # walls (two-sided, ADR-0020) or one finite wall + an infinity (half-bounded, # ADR-0047). The graded floor rule warns/errors on a sub-floor lower bound first. lower, upper = _graded_truncation_bounds(pid, lower, upper, fam, _scale) return FreeParameter(pid, keyword, p1, p2, lb=lower, ub=upper, value=initial_value, initialization_distribution=initialization_distribution, p3=p3)
def _require_finite_box(pid, lower, upper, is_log, where): """A Uniform family's bounds ARE its support, so they must be finite -- an infinite bound describes an unbounded prior's open tail, not a box. A log scale additionally needs a strictly positive lower bound (ADR-0047).""" for label, v in (('lower', lower), ('upper', upper)): if v is None or not np.isfinite(v): raise PybnfError( f"Parameter '{pid}': {where} needs a finite '{label}' bound " f"(got {v}); an infinite bound describes an unbounded prior's open " f"tail, not a uniform box.") if is_log and lower <= 0.0: raise PybnfError( f"Parameter '{pid}': {where} on a log scale needs 'lower' > 0 " f"(log of <= 0 is -inf), got lower={lower}.") def _graded_truncation_bounds(pid, lower, upper, fam, scale): """Apply the ADR-0047 graded sentinel/support rule to a truncated family's bounds. ``lower``/``upper`` are in theta, already validated to be both-set or both-None (the pairing rule). Omit-both passes through as the untruncated shorthand. On a positive-support family -- whose theta floor, derived from the family's natural support and the scale, is finite (0 for the linear half-bounded families; 0 for any log form; the doubly-unbounded families floor at -inf and are exempt) -- a sloppy-but-lossless ``lower: -inf`` is warned and canonicalized to the floor, and a *finite* ``lower`` below the floor (a wall in the zero-density region, a likely wrong family/scale) is an error. The ceiling side is the mirror image, and applies to the one family in the catalog that has one: ``beta`` on ``[0, 1]`` (``support_hi_u``). It was skipped while every truncatable family was unbounded above, which beta stopped being when it joined the catalog (ADR-0057); an ``upper`` above the ceiling was then accepted and the box silently narrowed to the ceiling by ``FreeParameter`` (#711). Refusing it says the same thing the floor rule says: a wall in the zero-density region is almost always a wrong family or scale, and stating it does not make the fit search there. Returns the (possibly canonicalized) bounds.""" if lower is None: return lower, upper floor = scale.inverse(fam.support_lo_u) # theta-space support floor if np.isfinite(floor): if lower == -np.inf: logger.warning( f"Parameter '{pid}': 'lower: -inf' on a prior whose support floor " f"is {floor:g} -- interpreting as open below at the floor. Write " f"'lower: {floor:g}' to silence this (ADR-0047).") lower = floor elif lower < floor: raise PybnfError( f"Parameter '{pid}': 'lower: {lower:g}' is below the prior's support " f"floor {floor:g} -- a finite wall in the zero-density region (likely " f"a wrong family or scale). Use 'lower: {floor:g}' for an open lower " f"side, or a value >= {floor:g} (ADR-0047).") ceiling = scale.inverse(fam.support_hi_u) # theta-space support ceiling if np.isfinite(ceiling): if upper == np.inf: logger.warning( f"Parameter '{pid}': 'upper: inf' on a prior whose support ceiling " f"is {ceiling:g} -- interpreting as open above at the ceiling. Write " f"'upper: {ceiling:g}' to silence this (ADR-0047).") upper = ceiling elif upper > ceiling: raise PybnfError( f"Parameter '{pid}': 'upper: {upper:g}' is above the prior's support " f"ceiling {ceiling:g} -- a finite wall in the zero-density region (likely " f"a wrong family or scale). Use 'upper: {ceiling:g}' for an open upper " f"side, or a value <= {ceiling:g} (ADR-0047).") return lower, upper def _reject_extra_fields(pid, leftover, where): """Raise a clear error if a ``parameter:`` record carries fields unknown to ``where`` (a typo or a field from a different family) -- naming every part means an unrecognised name is an error, not a silently-ignored token.""" if leftover: unknown = ', '.join(sorted(leftover)) raise PybnfError(f"Parameter '{pid}': unknown field(s) for {where}: {unknown}.")