Skip to content

Commit 50322ae

Browse files
dweindlclaude
andcommitted
Add petab1to2(reparametrize=...)
PEtab v2 has no `parameterScale`. Converting a v1 `parameterScaleNormal` prior on a log10-scaled parameter to `log-normal` keeps the prior distribution, but changes the maximum a posteriori estimate. `petab1to2` and `petab_files_1to2` get a `reparametrize` argument to estimate log-scaled parameters as `log10_<id>`/`log_<id>` using `reparametrize_log`, which preserves the objective function: * `"none"` (default): don't reparametrize; warn about converted `parameterScale*` priors * `"priors"`: parameters with `parameterScale*` priors * `"all"`: all estimated log-scaled parameters without priors on linear scale; this also preserves the v1 default prior Log-scaled parameters that share a placeholder with selected parameters are added; for non-estimated ones, a nominal value of 0 becomes -inf, and their (ignored) priors are dropped. With `"all"`, parameters without parameter-scale priors are skipped if their placeholder can't be log-transformed. Parameters with a lower bound of 0 get a lower bound of -inf on log scale; they are skipped if their prior on log scale would be an improper uniform prior. The benchmark collection conversion test now also runs with `"all"`. References Benchmarking-Initiative/Benchmark-Models-PEtab#333 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 68be6d1 commit 50322ae

3 files changed

Lines changed: 717 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@
8080
* `petab.v2.reparametrize_log` estimates parameters on log scale by replacing
8181
them with new log-transformed parameters
8282
(by @dweindl in https://github.com/PEtab-dev/libpetab-python/pull/560)
83+
* `petab1to2` and `petab_files_1to2` can preserve the objective function of
84+
log-scaled parameters with `parameterScale*` priors via
85+
`reparametrize="priors"` or `reparametrize="all"`
86+
(by @dweindl in https://github.com/PEtab-dev/libpetab-python/pull/539)
8387

8488
## 0.9 series
8589

‎petab/v2/petab1to2.py‎

Lines changed: 250 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
from contextlib import suppress
99
from pathlib import Path
1010
from tempfile import TemporaryDirectory
11+
from typing import Literal, get_args
1112
from urllib.parse import urlparse
1213
from uuid import uuid4
1314

1415
import pandas as pd
16+
import sympy as sp
1517
from pandas.io.common import get_handle, is_url
1618

1719
from .. import v1, v2
@@ -20,15 +22,20 @@
2022
from ..v1.yaml import validate as validate_yaml
2123
from ..versions import get_major_version
2224
from .models import MODEL_TYPE_SBML
25+
from .reparametrize import _get_override_slots, reparametrize_log
2326

2427
__all__ = ["petab1to2"]
2528

29+
#: Options for reparametrizing log-scaled parameters
30+
Reparametrize = Literal["none", "priors", "all"]
31+
2632

2733
def petab1to2(
2834
yaml_config: Path | str,
2935
output_dir: Path | str | None = None,
3036
*,
3137
validate: bool = True,
38+
reparametrize: Reparametrize = "none",
3239
) -> v2.Problem | None:
3340
"""Convert from PEtab 1.0 to PEtab 2.0 format.
3441
@@ -56,16 +63,43 @@ def petab1to2(
5663
If ``True`` (default), both problems are validated.
5764
If ``False``, neither problem is validated.
5865
The ``yaml_config`` content is validated regardless.
66+
:param reparametrize:
67+
Which ``log``/``log10``-scaled parameters to estimate as
68+
``log10_<id>``/``log_<id>`` (see :func:`petab.v2.reparametrize_log`).
69+
PEtab v2 has no ``parameterScale``, so without reparametrization,
70+
``parameterScale*`` priors are converted to densities over the
71+
unscaled parameter, which changes the maximum a posteriori estimate.
72+
73+
* ``"none"`` (default): don't reparametrize.
74+
* ``"priors"``: parameters with ``parameterScale*`` priors.
75+
* ``"all"``: all parameters without priors on linear scale.
76+
This also preserves the v1 default prior.
77+
78+
Parameters with a lower bound of ``0`` get a lower bound of ``-inf``.
79+
They are not reparametrized if their prior on log scale would be
80+
an improper uniform prior.
81+
Further log-scaled parameters are reparametrized if they share a
82+
placeholder with reparametrized ones in the measurement table.
5983
6084
:raises ValueError:
6185
If the input is invalid or, if ``validate`` is ``True``, if the input
6286
does not pass linting or if the generated files do not pass linting.
6387
"""
6488
if output_dir is not None:
65-
return petab_files_1to2(yaml_config, output_dir, validate=validate)
89+
return petab_files_1to2(
90+
yaml_config,
91+
output_dir,
92+
validate=validate,
93+
reparametrize=reparametrize,
94+
)
6695

6796
with TemporaryDirectory() as tmp_dir:
68-
petab_files_1to2(yaml_config, tmp_dir, validate=validate)
97+
petab_files_1to2(
98+
yaml_config,
99+
tmp_dir,
100+
validate=validate,
101+
reparametrize=reparametrize,
102+
)
69103
return v2.Problem.from_yaml(Path(tmp_dir, Path(yaml_config).name))
70104

71105

@@ -74,6 +108,7 @@ def petab_files_1to2(
74108
output_dir: Path | str,
75109
*,
76110
validate: bool = True,
111+
reparametrize: Reparametrize = "none",
77112
):
78113
"""Convert PEtab files from PEtab 1.0 to PEtab 2.0.
79114
@@ -85,6 +120,9 @@ def petab_files_1to2(
85120
:param validate:
86121
Whether to lint the PEtab v1 problem before and the generated
87122
PEtab v2 problem after the conversion. See :func:`petab1to2`.
123+
:param reparametrize:
124+
Which log-scaled parameters to reparametrize.
125+
See :func:`petab1to2`.
88126
89127
:raises ValueError:
90128
If the input is invalid or, if ``validate`` is ``True``, if the input
@@ -102,6 +140,12 @@ def petab_files_1to2(
102140

103141
get_dest_path = lambda filename: f"{output_dir}/{filename}" # noqa: E731
104142

143+
if reparametrize not in get_args(Reparametrize):
144+
raise ValueError(
145+
f"Invalid value for `reparametrize': {reparametrize}. "
146+
f"Must be one of {get_args(Reparametrize)}."
147+
)
148+
105149
# Validate the original PEtab problem
106150
validate_yaml(yaml_config, path_prefix=path_prefix)
107151
if get_major_version(yaml_config) != 1:
@@ -124,7 +168,9 @@ def petab_files_1to2(
124168
# Update tables
125169

126170
# parameter table
127-
parameter_df = v1v2_parameter_df(petab_problem.parameter_df.copy())
171+
parameter_df = v1v2_parameter_df(
172+
petab_problem.parameter_df.copy(), reparametrize=reparametrize
173+
)
128174
v2.write_parameter_df(
129175
parameter_df, get_dest_path(new_yaml_config.parameter_files[0])
130176
)
@@ -266,6 +312,15 @@ def create_experiment_id(sim_cond_id: str, preeq_cond_id: str) -> str:
266312
new_yaml_file = output_dir / Path(yaml_file).name
267313
new_yaml_config.to_yaml(new_yaml_file)
268314

315+
if reparametrize != "none" and (
316+
problem := _reparametrize_log_scaled(
317+
v2.Problem.from_yaml(new_yaml_file),
318+
petab_problem.parameter_df,
319+
reparametrize,
320+
)
321+
):
322+
problem.to_files(output_dir)
323+
269324
if not validate:
270325
return
271326

@@ -498,11 +553,17 @@ def extract_placeholders(row: pd.Series, type_: str) -> str:
498553

499554
def v1v2_parameter_df(
500555
parameter_df: pd.DataFrame,
556+
reparametrize: Reparametrize = "none",
501557
) -> pd.DataFrame:
502558
"""Convert parameter table from petab v1 to v2.
503559
504560
Do all the necessary conversions to the parameter table that can
505561
be done with the parameter table alone.
562+
563+
:param parameter_df: The PEtab v1 parameter table.
564+
:param reparametrize:
565+
The reparametrization option passed to :func:`petab1to2`.
566+
Only used for warnings, the reparametrization itself is done later.
506567
"""
507568
df = parameter_df.copy().reset_index()
508569

@@ -606,10 +667,35 @@ def update_prior(row) -> tuple[str, str | None]:
606667
stacklevel=9,
607668
)
608669
if not (df[v1.C.PARAMETER_SCALE] == v1.C.LIN).all():
609-
warnings.warn(
610-
"Parameter scales are not supported in PEtab v2.",
611-
stacklevel=9,
670+
msg = "Parameter scales are not supported in PEtab v2."
671+
reparametrizable = (
672+
parameter_df[v1.C.PARAMETER_SCALE].isin([v1.C.LOG, v1.C.LOG10])
673+
& (parameter_df[v1.C.ESTIMATE].astype(int) == 1)
674+
& (parameter_df[v1.C.LOWER_BOUND] >= 0)
612675
)
676+
if reparametrize == "none" and reparametrizable.any():
677+
prior_types = parameter_df.get(v1.C.OBJECTIVE_PRIOR_TYPE)
678+
scale_priors = (
679+
[]
680+
if prior_types is None
681+
else parameter_df.index[
682+
reparametrizable
683+
& prior_types.isin(v1.C.PARAMETER_SCALE_PRIOR_TYPES)
684+
]
685+
)
686+
msg += " Log-scaled parameters are estimated on linear scale."
687+
if len(scale_priors):
688+
msg += (
689+
f" Parameter-scale priors of {', '.join(scale_priors)} "
690+
"were converted to the corresponding priors of the "
691+
"unscaled parameters, which changes the maximum a "
692+
"posteriori estimate."
693+
)
694+
msg += (
695+
" Use `reparametrize='priors'` or `'all'` to estimate them "
696+
"on log scale."
697+
)
698+
warnings.warn(msg, stacklevel=9)
613699
df.drop(
614700
columns=[
615701
v1.C.INITIALIZATION_PRIOR_TYPE,
@@ -638,3 +724,161 @@ def update_prior_pars(row):
638724
df[v2.C.PRIOR_PARAMETERS] = df.apply(update_prior_pars, axis=1)
639725

640726
return df
727+
728+
729+
def _reparametrize_log_scaled(
730+
problem: v2.Problem,
731+
v1_parameter_df: pd.DataFrame,
732+
reparametrize: Reparametrize,
733+
) -> v2.Problem | None:
734+
"""Reparametrize log-scaled parameters of a converted problem.
735+
736+
See :func:`petab1to2`.
737+
738+
:param problem: The converted problem.
739+
:param v1_parameter_df: The original PEtab v1 parameter table.
740+
:param reparametrize: The parameters to reparametrize.
741+
:return:
742+
The reparametrized problem, or ``None`` if no parameter was
743+
reparametrized.
744+
"""
745+
df = v1_parameter_df
746+
has_priors = v1.C.OBJECTIVE_PRIOR_TYPE in df
747+
# for each base: candidates and their priors over the scaled parameter
748+
candidates = {10: {}, "e": {}}
749+
# parameters with parameter-scale priors
750+
required = {10: set(), "e": set()}
751+
selected = {10: set(), "e": set()}
752+
# selected parameters that cannot be reparametrized due to their bounds
753+
# and priors
754+
zero_bound = []
755+
for par_id, row in df.iterrows():
756+
pscale = row.get(v1.C.PARAMETER_SCALE, v1.C.LIN)
757+
if pscale not in (v1.C.LOG, v1.C.LOG10):
758+
continue
759+
base = 10 if pscale == v1.C.LOG10 else "e"
760+
prior_type = row.get(v1.C.OBJECTIVE_PRIOR_TYPE)
761+
if pd.isna(prior_type):
762+
# v1 default
763+
prior_type = None
764+
elif prior_type not in v1.C.PARAMETER_SCALE_PRIOR_TYPES:
765+
# A prior over the unscaled parameter. Its conversion already
766+
# preserves the objective function.
767+
continue
768+
estimate = int(row[v1.C.ESTIMATE])
769+
is_selected = estimate and (
770+
reparametrize == "all" or prior_type is not None
771+
)
772+
if estimate and (
773+
row[v1.C.LOWER_BOUND] < 0 or row[v1.C.UPPER_BOUND] <= 0
774+
):
775+
# invalid on log scale (and in v1)
776+
if is_selected:
777+
zero_bound.append(par_id)
778+
continue
779+
780+
# The prior over the scaled parameter. Derived from the v1 table
781+
# rather than from the converted v2 prior to avoid rounding errors.
782+
# Without a prior type, but with other parameters having priors,
783+
# the v1 default is made explicit, as in `v1v2_parameter_df`.
784+
prior = None
785+
if estimate and (prior_type is not None or has_priors):
786+
# v1 allows a lower bound of 0 for log-scaled parameters
787+
lb = (
788+
scale(row[v1.C.LOWER_BOUND], pscale)
789+
if row[v1.C.LOWER_BOUND] > 0
790+
else -math.inf
791+
)
792+
ub = scale(row[v1.C.UPPER_BOUND], pscale)
793+
pars = row.get(v1.C.OBJECTIVE_PRIOR_PARAMETERS)
794+
if prior_type is None or pd.isna(pars):
795+
pars = [lb, ub]
796+
else:
797+
pars = [
798+
float(x) for x in str(pars).split(v1.C.PARAMETER_SEPARATOR)
799+
]
800+
if prior_type in (None, v1.C.PARAMETER_SCALE_UNIFORM):
801+
prior = (v2.C.UNIFORM, [max(pars[0], lb), min(pars[1], ub)])
802+
else:
803+
name = prior_type.removeprefix("parameterScale").lower()
804+
prior = (name, pars)
805+
if prior[0] == v2.C.UNIFORM and math.isinf(prior[1][0]):
806+
# improper prior for a lower bound of 0
807+
if is_selected:
808+
zero_bound.append(par_id)
809+
continue
810+
811+
candidates[base][par_id] = prior
812+
if estimate and prior_type is not None:
813+
required[base].add(par_id)
814+
if is_selected:
815+
selected[base].add(par_id)
816+
817+
if zero_bound:
818+
warnings.warn(
819+
f"Log-scaled parameters {', '.join(zero_bound)} were not "
820+
"reparametrized, because their lower bound of 0 would result in "
821+
"an improper prior on log scale. "
822+
"They are estimated on linear scale, and any parameter-scale "
823+
"priors were converted to the corresponding priors of the "
824+
"unscaled parameters, which changes the maximum a posteriori "
825+
"estimate.",
826+
stacklevel=4,
827+
)
828+
829+
# Placeholders can only be transformed if all their overrides are
830+
# reparametrized or positive numbers. Add log-scaled parameters that
831+
# share a placeholder with selected parameters. If that is not possible,
832+
# drop selected parameters that don't have parameter-scale priors.
833+
# Otherwise, `reparametrize_log` will raise.
834+
slots = _get_override_slots(problem).values()
835+
excluded = set()
836+
for base, base_selected in selected.items():
837+
changed = True
838+
while changed:
839+
changed = False
840+
for values in slots:
841+
ids = {str(v) for v in values if isinstance(v, sp.Symbol)}
842+
if not ids & base_selected:
843+
continue
844+
other = ids - base_selected
845+
if other and other <= candidates[base].keys() - excluded:
846+
base_selected |= other
847+
changed = True
848+
elif other or any(v.is_number and not v > 0 for v in values):
849+
optional = (ids & base_selected) - required[base]
850+
if optional:
851+
base_selected -= optional
852+
excluded |= optional
853+
changed = True
854+
if excluded:
855+
warnings.warn(
856+
"Log-scaled parameters "
857+
f"{', '.join(sorted(excluded))} were not reparametrized, "
858+
"because they are used as overrides for placeholders that "
859+
"cannot be log-transformed.",
860+
stacklevel=4,
861+
)
862+
863+
if not any(selected.values()):
864+
return None
865+
866+
for base, base_selected in selected.items():
867+
if not base_selected:
868+
continue
869+
try:
870+
problem, _ = reparametrize_log(
871+
problem,
872+
# keep the parameter table order
873+
[p for p in candidates[base] if p in base_selected],
874+
base=base,
875+
priors={p: candidates[base][p] for p in base_selected},
876+
)
877+
except NotImplementedError as e:
878+
raise NotImplementedError(
879+
"Log-scaled parameters could not be reparametrized. "
880+
"Use `reparametrize='none'` to estimate them on linear "
881+
"scale.\n" + str(e)
882+
) from e
883+
884+
return problem

0 commit comments

Comments
 (0)