88from contextlib import suppress
99from pathlib import Path
1010from tempfile import TemporaryDirectory
11+ from typing import Literal , get_args
1112from urllib .parse import urlparse
1213from uuid import uuid4
1314
1415import pandas as pd
16+ import sympy as sp
1517from pandas .io .common import get_handle , is_url
1618
1719from .. import v1 , v2
2022from ..v1 .yaml import validate as validate_yaml
2123from ..versions import get_major_version
2224from .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
2733def 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
499554def 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