"""PEtab v2 ``parameters`` table -> ``FreeParameter`` (issue #407, Step 1).
This is the first, self-contained step of the PEtab v2 problem importer -- the
**two-adapter proof** the M2 refactor anticipated (ADR-0004): a native ``.conf``
and a PEtab problem should land on the *same* internal ``FreeParameter`` /
``Prior`` objects. If they do, the abstractions are right; where PEtab forces a
special case, we learn where they are wrong.
Two deliberately separated layers (the "neutral seam"):
* **The TSV reader** (``read_parameter_table``) -- the *disposable* half: a
dependency-free ``csv`` parse of ``parameters.tsv`` into
:class:`PetabParameterRow` records. When the later importer chunks pull in the
``petab`` library for the SBML model and the ``observableFormula`` sympy layer,
this is swapped for ``petab``'s ``parameter_df`` reader with no change below.
* **The mapping** (``free_parameter_from_row``) -- the *asset*: a
``PetabParameterRow`` -> ``FreeParameter``, driven by the prior-family registry
(ADR-0010). It synthesizes the equivalent legacy ``*_var`` keyword and builds
the ``FreeParameter`` through its ordinary constructor, so the importer lands on
a **bit-identical** object to the native config path -- the strongest form of
the two-adapter proof -- rather than a parallel mapping table.
PEtab v2 specifics this encodes (current spec, *not* the v1 shape):
* There is **no** ``parameterScale`` column (removed in v2); everything is in
linear space, and the parameter's PyBNF :class:`~pybnf.priors.Scale` is derived
from the *prior family* instead (a ``log-*`` prior -> ``Log10``).
* Priors are ``priorDistribution`` / ``priorParameters`` (renamed from v1's
``objectivePrior*``); a single prior, used for the objective only.
* ``log-normal`` / ``log-laplace`` parameters are the location/scale of the
**natural** log of the parameter; PyBNF's log families parameterize in log10, so
we convert ``(mu, sigma) -> (mu/ln10, sigma/ln10)``. The resulting distribution
*over theta* is identical -- PyBNF's scale lives in the sampling
parameterization, so there is no change-of-variables term to add (ADR-0003).
* The **full v2 prior catalog** maps (#417): besides uniform / normal / laplace,
the five families that were a catalog gap -- ``cauchy`` (loc, scale), ``gamma``
(shape, scale), ``exponential`` (scale), ``chisquare`` (dof), ``rayleigh``
(scale) -- now each have a registered PyBNF prior family (ADR-0010). The
parameterizations are verified against petab's own ``v1.distributions`` classes
(gamma is shape+scale, not shape+rate; exponential's parameter is the scale, not
the rate). PEtab defines no log- form for these five.
* Bounds **truncate** the prior. A Uniform prior truncates exactly (we intersect
the box). For an unbounded-support family, *two-sided* finite bounds map to a
truncated prior on a finite reflecting box (ADR-0020, ``TruncatedPrior``); a
*one-sided* truncation -- one finite wall and the other side covering the family's
natural domain (an infinite bound, or a log form's ``theta <= 0``) -- maps to a
half-bounded box, a single reflecting wall (ADR-0047, #432). Both directions are
the ``ub->inf`` limit of the two-sided fold; the open side is the family's natural
support endpoint.
Gaps are surfaced as ``NotImplementedError`` with clear messages so the boundary
is documented in code, not silent: ``estimate = false`` fixed parameters (those
become model constants, handled by a later importer chunk, not here).
"""
import csv
import math
from dataclasses import dataclass
import numpy as np
from ..printing import PybnfError
from ..priors import PRIOR_KEYWORD_MAP
from ..pset import FreeParameter, INITIALIZATION_BOUNDS
from ._tsv import num, write_tsv
_LN10 = math.log(10.0)
# PEtab v2 priorDistribution spelling -> (PyBNF prior-family stem, is_log). The
# stem must be a registered prior family (PRIOR_FAMILY_REGISTRY, ADR-0010): the
# synthesized "{stem}_var" / "log{stem}_var" keyword is validated against the
# registry-derived PRIOR_KEYWORD_MAP below, so this is the PEtab *vocabulary*, not
# a parallel prior-family table.
_PETAB_DISTRIBUTION_TO_FAMILY = {
'uniform': ('uniform', False),
'log-uniform': ('uniform', True),
'normal': ('normal', False),
'log-normal': ('normal', True),
'laplace': ('laplace', False),
'log-laplace': ('laplace', True),
# The full v2 catalog (#417). PEtab defines no log- form for these five, so only the
# linear keyword maps; their native log{stem}_var keywords exist (the registry generates
# them) but have no PEtab priorDistribution, so the exporter refuses them.
'cauchy': ('cauchy', False),
'gamma': ('gamma', False),
'exponential': ('exponential', False),
'chisquare': ('chisquare', False),
'rayleigh': ('rayleigh', False),
}
# Per location/scale/shape family stem: ``(n_priorParameters, support_lo_in_theta)``. The
# arity drives both the priorParameters count and the FreeParameter build (a one-parameter
# family carries only ``p1``); ``support_lo`` is the family's natural lower support in theta
# (0 for the half-bounded gamma/exponential/chisquare/rayleigh, -inf for the doubly-unbounded
# normal/laplace/cauchy) -- the edge :func:`_truncation_box` measures bounds against. uniform
# is absent (it carries bounds, not a location/scale, and is handled on its own path). The
# PEtab parameterizations are verified against petab's own ``v1.distributions`` classes
# (gamma is shape+scale, exponential's parameter is the scale = 1/rate).
_FAMILY_META = {
'normal': (2, -math.inf),
'laplace': (2, -math.inf),
'cauchy': (2, -math.inf),
'gamma': (2, 0.0),
'exponential': (1, 0.0),
'chisquare': (1, 0.0),
'rayleigh': (1, 0.0),
}
# The reverse of ``_PETAB_DISTRIBUTION_TO_FAMILY`` (the export direction, ADR-0025):
# a PyBNF prior keyword (a FreeParameter ``type``) -> (PEtab priorDistribution
# spelling, family stem, is_log). Derived by inverting the import map so the two
# directions can never drift apart -- the same dictionary read both ways.
_KEYWORD_TO_PETAB_DISTRIBUTION = {
f"{'log' if is_log else ''}{stem}_var": (dist, stem, is_log)
for dist, (stem, is_log) in _PETAB_DISTRIBUTION_TO_FAMILY.items()
}
# The free-parameter keywords the exporter can write as a PEtab prior. Exposed for
# the exporter's conf-level filter (export.py). The no-prior ``var`` / ``logvar``
# point-start keywords are deliberately absent: a flat improper prior is not a PEtab
# probability family, so it has no ``priorDistribution`` to emit (#423).
EXPORTABLE_PRIOR_KEYWORDS = frozenset(_KEYWORD_TO_PETAB_DISTRIBUTION)
[docs]
@dataclass(frozen=True)
class PetabParameterRow:
"""One row of a PEtab v2 parameters table, in PyBNF's neutral vocabulary.
The dependency-free seam between the (disposable) TSV reader and the (asset)
registry-driven mapping: the mapping never depends on *how* the row was read,
so a future ``petab``-library adoption feeds it by constructing these from
``Problem.parameter_df`` records.
``lower_bound`` / ``upper_bound`` / ``nominal_value`` are ``None`` when the
column is absent or blank; ``prior_distribution`` is ``None`` for an estimated
parameter with no explicit prior (PEtab v2 defaults that to a uniform over the
bounds). ``prior_parameters`` is the parsed semicolon-delimited tuple.
"""
parameter_id: str
estimate: bool
lower_bound: float | None = None
upper_bound: float | None = None
nominal_value: float | None = None
prior_distribution: str | None = None
prior_parameters: tuple[float, ...] = ()
# ---------------------------------------------------------------------------
# Mapping: PetabParameterRow -> FreeParameter (the asset)
# ---------------------------------------------------------------------------
[docs]
def free_parameter_from_row(row):
"""Map one estimated PEtab v2 parameters row to a :class:`FreeParameter`.
Finite bounds truncating an unbounded-support family map to a bounded
``FreeParameter`` -- two finite walls to a two-sided box (ADR-0020), one finite
wall plus an open side to a half-bounded box (ADR-0047, #432). The full v2 prior
catalog maps (uniform / normal / laplace / cauchy / gamma / exponential /
chisquare / rayleigh, #417). Raises ``NotImplementedError`` at the remaining
PEtab/PyBNF boundary (``estimate=false`` fixed parameters) and ``PybnfError`` for
malformed rows (unknown prior type, wrong parameter count, reversed bounds).
"""
if not row.estimate:
raise NotImplementedError(
f"Parameter '{row.parameter_id}' has estimate=false: a fixed parameter "
f"becomes a model constant, not a FreeParameter. Fixed-parameter "
f"wiring is a later #407 chunk (conditions / model overrides), not the "
f"parameters step.")
lb = -np.inf if row.lower_bound is None else float(row.lower_bound)
ub = np.inf if row.upper_bound is None else float(row.upper_bound)
if lb > ub:
raise PybnfError(
f"Parameter '{row.parameter_id}' has lowerBound {lb} > upperBound {ub}.")
dist = row.prior_distribution
if dist is None:
# PEtab v2: an estimated parameter with no explicit prior defaults to a
# uniform over its bounds (priorParameters = lowerBound;upperBound).
if not (np.isfinite(lb) and np.isfinite(ub)):
raise PybnfError(
f"Parameter '{row.parameter_id}' has no prior and non-finite bounds "
f"[{lb}, {ub}]: a uniform default needs finite bounds.")
keyword, p1, p2, bounded, tlb, tub = 'uniform_var', lb, ub, True, None, None
else:
keyword, p1, p2, bounded, tlb, tub = _resolve_prior(row, lb, ub)
# Tie the importer to the registry-derived keyword map (ADR-0010): the
# synthesized keyword must be one the native *_var grammar produces. A miss
# here means the PEtab vocabulary table drifted from the prior registry.
if keyword not in PRIOR_KEYWORD_MAP:
raise PybnfError(
f"Internal error mapping '{row.parameter_id}': synthesized keyword "
f"{keyword!r} is not a registered prior keyword.")
value = None if row.nominal_value is None else float(row.nominal_value)
init_kwargs = {}
if _can_initialize_from_bounds(keyword, lb, ub):
init_kwargs = {
'initialization_distribution': INITIALIZATION_BOUNDS,
'initialization_lb': lb,
'initialization_ub': ub,
}
return FreeParameter(row.parameter_id, keyword, p1, p2, value=value,
bounded=bounded, lb=tlb, ub=tub, **init_kwargs)
def _can_initialize_from_bounds(keyword, lb, ub):
"""True when the PEtab row has a finite two-sided initialization box.
The bounds are row-level PEtab parameter bounds, deliberately separate from
the objective prior support. Log-scale PyBNF parameters need a strictly
positive lower bound so the box is finite in sampling space.
"""
if not (np.isfinite(lb) and np.isfinite(ub)):
return False
if keyword.startswith('log') and lb <= 0.0:
return False
return lb < ub
def _resolve_prior(row, lb, ub):
"""Resolve an explicit ``priorDistribution`` to ``(keyword, p1, p2, bounded,
trunc_lb, trunc_ub)``.
``trunc_lb``/``trunc_ub`` are the truncation box (in theta) for an
unbounded-support family that PEtab bounds truncate; ``None`` otherwise.
"""
dist = row.prior_distribution
if dist not in _PETAB_DISTRIBUTION_TO_FAMILY:
raise PybnfError(
f"Parameter '{row.parameter_id}': unknown PEtab priorDistribution "
f"{dist!r}.")
stem, is_log = _PETAB_DISTRIBUTION_TO_FAMILY[dist]
keyword = f"{'log' if is_log else ''}{stem}_var"
if stem == 'uniform':
# PEtab uniform params are (a, b) linear bounds; the prior's support is
# the box, truncated by [lb, ub] -> uniform over the intersection (exact;
# the base of the log is irrelevant for a uniform-in-log density).
a, b = _expect_n(row.prior_parameters, 2, dist, row)
p1, p2 = max(a, lb), min(b, ub)
if p1 > p2:
raise PybnfError(
f"Parameter '{row.parameter_id}': uniform prior ({a}, {b}) and "
f"bounds [{lb}, {ub}] have an empty intersection.")
return keyword, p1, p2, True, None, None
# The location/scale/shape families (normal / laplace / cauchy: two parameters;
# exponential / chisquare / rayleigh: one), each with infinite (possibly half-bounded)
# support that PEtab bounds truncate.
n_params, family_support_lo = _FAMILY_META[stem]
params = _expect_n(row.prior_parameters, n_params, dist, row)
if n_params == 2:
p1, p2 = params
if is_log:
# PEtab log-normal/log-laplace parameters are the location/scale of the
# NATURAL log of theta; PyBNF's log families parameterize in log10, so
# convert. The distribution over theta is identical -- the scale lives in
# the sampling parameterization, so there is no Jacobian term (ADR-0003).
p1, p2 = p1 / _LN10, p2 / _LN10
else:
p1, p2 = params[0], None # a one-parameter family carries only p1
# The family's natural lower support in theta: 0 for a half-bounded family, and 0 for
# any log form (theta > 0), else -inf. The upper support is +inf for all of these.
support_lo = 0.0 if (is_log or family_support_lo == 0.0) else -np.inf
tlb, tub = _truncation_box(support_lo, lb, ub)
return keyword, p1, p2, tlb is not None, tlb, tub
def _truncation_box(support_lo, lb, ub):
"""Map PEtab bounds on an unbounded-support prior to a truncation box.
PEtab truncates a prior by ``[lb, ub]``. ``support_lo`` is the family's natural lower
support in theta (0 for a half-bounded family or any log form, -inf otherwise). The
cases (ADR-0020, ADR-0047, issues #411/#432):
* **Untruncated** -- the bounds cover the family's natural domain in theta
(``[support_lo, inf)``). Returns ``(None, None)``: the prior is built unbounded.
* **Two-sided truncation** -- neither side covers (both are finite walls inside the
domain). Returns ``(lb, ub)``: the family is wrapped in a finite two-sided
reflecting box (a ``TruncatedPrior``).
* **One-sided truncation** -- exactly one side covers the natural domain while the
other is a finite wall. Returns the covered side as the family's natural support
endpoint (an open side) and the other as the wall, so the ``FreeParameter``
constructor builds a half-bounded box -- a single reflecting wall, the ``ub->inf``
limit of the two-sided fold (ADR-0047).
A covered lower side is returned as ``support_lo`` (``-inf`` for a doubly-unbounded
family; ``0`` for a log form, which the constructor maps to ``-inf`` in sampling
space, or for a linear half-bounded family, where it is the support floor). On a log
scale a non-covered (finite-wall) lower bound is automatically strictly positive
(``support_lo`` is 0, so ``lb <= 0`` implies covered), so no ``log10(<=0)`` arises.
"""
covers_lower = lb <= support_lo
covers_upper = ub >= np.inf
if covers_lower and covers_upper:
return None, None # no truncation: build the prior unbounded
# A covered side becomes an open side at the family's natural endpoint; a
# non-covered side is a finite reflecting wall. One open + one wall is the
# half-bounded box; two walls the two-sided box (ADR-0047).
tlb = support_lo if covers_lower else lb
tub = np.inf if covers_upper else ub
return tlb, tub
def _expect_n(params, n, dist, row):
"""Validate the priorParameters count for a PEtab distribution."""
if len(params) != n:
raise PybnfError(
f"Parameter '{row.parameter_id}': PEtab prior '{dist}' needs {n} "
f"priorParameters, got {len(params)}: {list(params)}.")
return params
# ---------------------------------------------------------------------------
# Table-level helpers
# ---------------------------------------------------------------------------
[docs]
def free_parameters_from_table(rows):
"""Map the estimated rows of a parameters table to ``FreeParameter`` objects.
``estimate=false`` rows are skipped (they are fixed model constants, not free
parameters), so this returns one ``FreeParameter`` per estimated row.
"""
return [free_parameter_from_row(row) for row in rows if row.estimate]
[docs]
def free_parameters_from_file(path):
"""Read ``parameters.tsv`` at ``path`` and map it to ``FreeParameter`` objects."""
return free_parameters_from_table(read_parameter_table(path))
# ---------------------------------------------------------------------------
# Export: FreeParameter -> PetabParameterRow (the reverse asset; ADR-0025)
# ---------------------------------------------------------------------------
[docs]
def petab_parameter_row(free_parameter, parameter_id=None):
"""Map a PyBNF :class:`FreeParameter` back to a :class:`PetabParameterRow`.
The exact reverse of :func:`free_parameter_from_row`: a native ``.conf`` free
parameter and a PEtab row land on the same object, so this read backwards is the
two-adapter proof in the export direction. ``parameter_id`` defaults to the free
parameter's name -- new-era binds a free parameter to its model parameter **by id**
(ADR-0034), so the name *is* the ``parameterId``; a caller that has resolved the
model parameter name authoritatively (the exporter, which renames a fit-and-mutated
parameter to its ``<p>__REF`` surrogate) passes it explicitly.
Exports the whole proper-prior catalog -- the reverse of ADR-0019's import map:
* ``uniform_var`` -- the bounds are ``[p1, p2]`` and **no** ``priorDistribution``
is written (PEtab v2 defaults an estimated, prior-less parameter to
uniform-over-bounds, so the row round-trips).
* ``loguniform_var`` -- the same linear bounds, but ``priorDistribution`` is
stated (``log-uniform``; PEtab's default uniform is *linear*) with
``priorParameters = (p1, p2)``.
* ``normal_var`` / ``laplace_var`` / ``cauchy_var`` / ``gamma_var`` and the log
forms of normal/laplace -- a two-parameter prior: ``priorParameters`` are
``(p1, p2)`` (``(loc, scale)`` / ``(shape, scale)``), in **natural** log for the
log families (PyBNF parameterizes in log10, so they are scaled back by ``ln 10``).
* ``exponential_var`` / ``chisquare_var`` / ``rayleigh_var`` -- a one-parameter
prior: ``priorParameters`` is a single ``(p1,)`` (the scale, or chisquare's dof).
A truncated parameter writes its reflecting box as the truncating bounds: two finite
walls (two-sided, ADR-0020) or one finite wall with the open side as an explicit
infinity (half-bounded, ADR-0047). An unbounded one writes blank bounds. The no-prior
``var`` / ``logvar`` point-start keywords and the log forms of the five catalog
families (no PEtab ``log-`` spelling) raise ``NotImplementedError`` -- surfaced in
code, not mis-exported.
"""
if parameter_id is None:
parameter_id = free_parameter.name
keyword = free_parameter.type
if keyword not in _KEYWORD_TO_PETAB_DISTRIBUTION:
raise NotImplementedError(
f"Parameter '{free_parameter.name}': exporting a '{keyword}' prior to "
f"PEtab is not supported. The PEtab v2 prior families are "
f"{sorted(EXPORTABLE_PRIOR_KEYWORDS)}; the no-prior 'var'/'logvar' "
f"point-start keywords have no PEtab priorDistribution (a flat improper "
f"prior is not a PEtab probability family). This is the reverse of "
f"ADR-0019's import catalog (#423).")
dist, stem, is_log = _KEYWORD_TO_PETAB_DISTRIBUTION[keyword]
nominal = (None if free_parameter.value is None
else float(free_parameter.value))
if stem == 'uniform':
return _petab_uniform_row(free_parameter, parameter_id, dist, is_log, nominal)
return _petab_prior_row(free_parameter, parameter_id, dist, stem, is_log, nominal)
def _petab_uniform_row(fp, parameter_id, dist, is_log, nominal):
"""A bounded Uniform family -> a PEtab estimated parameter over ``[p1, p2]``.
The linear ``uniform_var`` needs no ``priorDistribution`` (it *is* PEtab's
default for an estimated, prior-less parameter); ``log-uniform`` is not that
default, so it states its family and its ``(a, b)`` = the same linear bounds.
"""
lb, ub = float(fp.p1), float(fp.p2)
prior_distribution = dist if is_log else None
prior_parameters = (lb, ub) if is_log else ()
return PetabParameterRow(
parameter_id=parameter_id, estimate=True,
lower_bound=lb, upper_bound=ub, nominal_value=nominal,
prior_distribution=prior_distribution, prior_parameters=prior_parameters)
def _petab_prior_row(fp, parameter_id, dist, stem, is_log, nominal):
"""A location/scale/shape family -> its PEtab prior row (the reverse of ``_resolve_prior``).
``priorParameters`` are the family's parameters in PEtab order: ``(p1, p2)`` for a
two-parameter family (normal/laplace ``(loc, scale)``, cauchy ``(loc, scale)``, gamma
``(shape, scale)``), or ``(p1,)`` for a one-parameter family (exponential/rayleigh scale,
chisquare dof). For the log families (normal/laplace only) PyBNF stores ``(loc, scale)`` in
log10 and PEtab expects the **natural**-log parameters, so they are scaled back by ``ln 10``
(the exact reverse of the importer's ``/_LN10``; the distribution over theta is unchanged --
ADR-0003). Truncation bounds, if any (ADR-0020), become the PEtab bounds that truncate the
prior; otherwise the parameter is unbounded and the bounds are left blank.
"""
n_params, _support_lo = _FAMILY_META[stem]
if n_params == 2:
p1, p2 = float(fp.p1), float(fp.p2)
if is_log:
p1, p2 = p1 * _LN10, p2 * _LN10
prior_parameters = (p1, p2)
else:
prior_parameters = (float(fp.p1),) # a one-parameter family carries only p1
lb = None if fp.trunc_lb is None else float(fp.trunc_lb)
ub = None if fp.trunc_ub is None else float(fp.trunc_ub)
return PetabParameterRow(
parameter_id=parameter_id, estimate=True,
lower_bound=lb, upper_bound=ub, nominal_value=nominal,
prior_distribution=dist, prior_parameters=prior_parameters)
_PARAMETER_COLUMNS = ['parameterId', 'estimate', 'lowerBound', 'upperBound']
_PRIOR_COLUMNS = ['priorDistribution', 'priorParameters']
[docs]
def write_parameter_table(rows, path):
"""Write parameter ``rows`` to ``path`` as a PEtab v2 ``parameters.tsv``.
Always writes ``parameterId``/``estimate``/``lowerBound``/``upperBound``. The
prior columns (``priorDistribution``/``priorParameters``) are appended only when
some row carries an explicit prior, so a plain ``uniform_var`` job keeps the
four-column chunk-1 shape (PEtab v2 defaults a prior-less estimated parameter to
uniform-over-bounds). An unbounded location-scale family writes blank bounds;
``nominalValue`` is optional in PEtab v2 and omitted while unused.
"""
has_prior = any(r.prior_distribution is not None for r in rows)
header = _PARAMETER_COLUMNS + (_PRIOR_COLUMNS if has_prior else [])
records = []
for r in rows:
rec = [r.parameter_id, 'true' if r.estimate else 'false',
num(r.lower_bound), num(r.upper_bound)]
if has_prior:
rec += [r.prior_distribution or '',
';'.join(num(p) for p in r.prior_parameters)]
records.append(rec)
write_tsv(path, header, records)
# ---------------------------------------------------------------------------
# TSV reader (the disposable half of the seam)
# ---------------------------------------------------------------------------
[docs]
def read_parameter_table(path):
"""Read a PEtab v2 ``parameters.tsv`` into :class:`PetabParameterRow` records.
Dependency-free (stdlib ``csv``). Unknown extra columns (e.g.
``parameterName``) are tolerated and ignored.
"""
with open(path, newline='') as fh:
reader = csv.DictReader(fh, delimiter='\t')
return [_row_from_record(rec) for rec in reader]
def _row_from_record(rec):
pid = rec.get('parameterId')
if pid is None or pid.strip() == '':
raise PybnfError("PEtab parameters row is missing a parameterId.")
return PetabParameterRow(
parameter_id=pid.strip(),
estimate=_parse_estimate(rec.get('estimate'), pid),
lower_bound=_parse_float(rec.get('lowerBound')),
upper_bound=_parse_float(rec.get('upperBound')),
nominal_value=_parse_float(rec.get('nominalValue')),
prior_distribution=_parse_str(rec.get('priorDistribution')),
prior_parameters=_parse_params(rec.get('priorParameters')),
)
def _parse_float(s):
if s is None:
return None
s = s.strip()
return None if s == '' else float(s) # float() handles 'inf' / '-inf'
def _parse_str(s):
if s is None:
return None
s = s.strip()
return s or None
def _parse_estimate(s, pid):
if s is None or s.strip() == '':
raise PybnfError(f"PEtab parameter '{pid}' is missing the 'estimate' column.")
v = s.strip().lower()
if v in ('true', '1'):
return True
if v in ('false', '0'):
return False
raise PybnfError(
f"PEtab parameter '{pid}' has an unrecognized estimate value {s!r} "
f"(expected true/false).")
def _parse_params(s):
if s is None:
return ()
s = s.strip()
if s == '':
return ()
return tuple(float(x.strip()) for x in s.split(';') if x.strip() != '')