Skip to content

Truncated/bounded priors: allow reflecting bounds on unbounded-support families (Normal/Laplace/log-*) (PEtab v2 prerequisite) #411

@wshlavacek

Description

@wshlavacek

Motivation

PEtab v2: "Prior distributions are truncated by the lowerBound and upperBound if the prior's domain exceeds the parameter bounds." So a normal prior with finite bounds is a truncated normal — a very common pattern. PyBNF cannot represent it: FreeParameter attaches reflecting bounds only to finite-support (Uniform) families, so Normal / Laplace / log-* priors are always unbounded. Step 1 of the importer (#407, parametersFreeParameter, shipped in f151914 / ADR-0019) therefore raises NotImplementedError on exactly this case.

This is the prior-side twin of #410 (per-observable noise): a core PyBNF capability that v2 compliance requires, with standalone value — bounded normal / laplace / log-normal priors (positivity, numerical stability, excluding an unphysical tail) are a natural modeling need independent of PEtab. It is arguably more immediately blocking than #410: #410 gates the observables chunk, whereas this gates Step 1 itself on a single bounded normal prior.

Current state

  • FreeParameter.__init__: self.bounded = bounded if self._prior.has_bounded_support else False — a non-Uniform prior can never be box-bounded.
  • The reflecting-bounds machinery (_reflect, the closed-form triangle-wave fold) is already family-agnostic in sampling space u; it is simply gated off for unbounded-support families.
  • The importer raises on finite bounds truncating an unbounded prior (ADR-0019, _reject_truncation); Uniform priors truncate exactly by box intersection.

What's needed

Allow reflecting/truncation bounds on the unbounded-support families, with correct truncated-prior semantics:

  • Prior density. A truncated prior renormalizes over [lb, ub] (p(θ)/Z). Z is parameter-independent (the bounds are fixed), so it cancels in the MCMC acceptance ratio and in MAP optimization — consistent with PyBNF's existing no-Jacobian / target-defined-in-u philosophy (ADR-0003). It matters only for reported log-prior/log-posterior values and any evidence computation.
  • Initial prior sampling. Must draw from the truncated region — rejection or inverse-CDF over [lb, ub] (e.g. scipy truncnorm, or a generic truncated-ppf), not an unbounded draw folded back in. This is the part that genuinely changes behaviour.
  • Proposal arithmetic. The triangle-wave reflection already keeps proposals in-box and stays symmetric, so plain Metropolis over the truncated posterior is correct — verify it composes unchanged.
  • Cleanly decouple Support (the family's nonzero region) from Reflecting Bounds (the FreeParameter box) — ADR-0010 already separated these; this extends the box to unbounded-support families.

Design questions (for an ADR before building)

  • Config surface. How does a native .conf declare a bounded normal? The b/u flag applies only to uniform_var today — add bounds to normal_var / laplace_var / lognormal_var (extra lb ub, or a flag)? Unbounded stays the default (backward compatible).
  • Where truncation lives. On the Prior (a TruncatedPrior wrapper over a family, support [scale.forward(lb), scale.forward(ub)] in u), or on the FreeParameter (extend reflecting bounds + override sampling)? Truncation is a hybrid — it changes both the support and the normalization — so the ADR-0010 "support on the family, reflecting bounds on FreeParameter" seam needs a deliberate call.
  • Normalizer policy. Since Z cancels for inference, track only the unnormalized density (matching today's convention) and rely on correct truncated sampling, or compute Z for faithful reported values?
  • Sampling implementation. scipy truncnorm for normal; generic truncated-ppf for the others (the families already wrap scipy frozen dists, so a truncation wrapper in u-space is natural).

Scope & relationships

  • Core PyBNF capability. Unblocks PEtab v2 problem importer — the 'two-adapter' proof (first step: parameters table → FreeParameter/Prior) #407 Step 1 from realistic v2 problems: the importer's _reject_truncation raise (ADR-0019) becomes a real mapping of a bounded PEtab prior → a bounded FreeParameter.
  • Relevant ADRs: 0003 (prior in the parameter's own scale, no Jacobian — where the normalizer-cancels logic lives), 0010 (Prior family vs FreeParameter scale/bounds split; the Support-vs-Reflecting-Bounds decoupling), 0019 (importer Step 1, where truncation is currently raised). The design should land a new ADR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions