From d3cb3a157bdd7fd05f1583b73775396f8d3fab59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 8 Oct 2026 13:37:28 +0100 Subject: [PATCH 1/3] petab1to2: rescale noise and priors when converting log10 to log PEtab v2 has no log10-based noise distributions or priors. Since ln(y) = ln(10) * log10(y), a normal (Laplace) distribution of log10(y) with standard deviation (scale) sigma is a normal (Laplace) distribution of ln(y) with ln(10) * sigma. * log10-normal and log10-laplace noise now become log-normal and log-laplace with the noise formula `log(10) * ()`. Previously, log10-normal became log-normal with an unchanged noise formula (changing the likelihood), and log10-laplace raised NotImplementedError. * parameterScaleNormal and parameterScaleLaplace priors on log10-scaled parameters now become log-normal and log-laplace with both prior parameters multiplied by ln(10). Previously, log10-normal priors kept their parameters, and log10-laplace priors raised NotImplementedError. Closes #529 Co-Authored-By: Claude Opus 5.5 --- petab/v2/petab1to2.py | 66 ++++++----- tests/v2/test_conversion.py | 214 +++++++++++++++++++++++++++++++++++- 2 files changed, 251 insertions(+), 29 deletions(-) diff --git a/petab/v2/petab1to2.py b/petab/v2/petab1to2.py index 14ed8939..ad9e39dc 100644 --- a/petab/v2/petab1to2.py +++ b/petab/v2/petab1to2.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math import re import shutil import warnings @@ -397,20 +398,12 @@ def get_noise_dist(row): if trans == v1.C.LIN: new_dist = dist + elif trans == v1.C.LOG10: + # see `get_noise_formula` + new_dist = f"{v1.C.LOG}-{dist}" else: new_dist = f"{trans}-{dist}" - if new_dist == "log10-normal": - warnings.warn( - f"Noise distribution `{new_dist}' for " - f"observable `{row[v1.C.OBSERVABLE_ID]}'" - f" is not supported in PEtab v2. " - "Using `log-normal` instead.", - # call to `petab1to2` - stacklevel=9, - ) - new_dist = v2.C.LOG_NORMAL - if new_dist not in v2.C.NOISE_DISTRIBUTIONS: raise NotImplementedError( f"Noise distribution `{new_dist}' for " @@ -420,7 +413,20 @@ def get_noise_dist(row): return new_dist + # PEtab v2 has no log10-based noise distributions. Since + # ln(y) = ln(10) * log10(y), a normal (Laplace) distribution of + # log10(y) with standard deviation (scale) sigma is a normal + # (Laplace) distribution of ln(y) with ln(10) * sigma. + # Therefore, log10- becomes log- with the noise + # formula multiplied by ln(10). + def get_noise_formula(row): + formula = row[v1.C.NOISE_FORMULA] + if row[v1.C.OBSERVABLE_TRANSFORMATION] != v1.C.LOG10: + return formula + return f"log(10) * ({formula})" + df[v2.C.NOISE_DISTRIBUTION] = df.apply(get_noise_dist, axis=1) + df[v1.C.NOISE_FORMULA] = df.apply(get_noise_formula, axis=1) df.drop(columns=[v1.C.OBSERVABLE_TRANSFORMATION], inplace=True) def extract_placeholders(row: pd.Series, type_: str) -> str: @@ -478,44 +484,52 @@ def v1v2_parameter_df( lambda x: str(bool(int(x))).lower() ) - def update_prior(row): - """Convert prior to v2 format.""" + def update_prior(row) -> tuple[str, str | None]: + """Convert prior type and prior parameters to v2 format.""" prior_type = row.get(v1.C.OBJECTIVE_PRIOR_TYPE) if pd.isna(prior_type): prior_type = v1.C.UNIFORM + prior_pars = row.get(v1.C.OBJECTIVE_PRIOR_PARAMETERS) pscale = row.get(v1.C.PARAMETER_SCALE) if pd.isna(pscale): pscale = v1.C.LIN if prior_type not in v1.C.PARAMETER_SCALE_PRIOR_TYPES: - return prior_type + return prior_type, prior_pars + + if pscale == v1.C.LOG10 and prior_type in ( + v1.C.PARAMETER_SCALE_NORMAL, + v1.C.PARAMETER_SCALE_LAPLACE, + ): + # PEtab v2 has no log10-based priors. Since + # ln(x) = ln(10) * log10(x), a normal (Laplace) distribution of + # log10(x) with mean (location) mu and standard deviation + # (scale) sigma is a normal (Laplace) distribution of ln(x) + # with ln(10) * mu and ln(10) * sigma. + pscale = v1.C.LOG + prior_pars = v2.C.PARAMETER_SEPARATOR.join( + str(float(p) * math.log(10)) + for p in str(prior_pars).split(v1.C.PARAMETER_SEPARATOR) + ) new_prior_type = prior_type.removeprefix("parameterScale").lower() if pscale != v1.C.LIN: new_prior_type = f"{pscale}-{new_prior_type}" - if new_prior_type == "log10-normal": - warnings.warn( - f"Prior distribution `{new_prior_type}' for parameter " - f"`{row[v1.C.PARAMETER_ID]}' is not supported in PEtab v2. " - "Using `log-normal` instead.", - # call to `petab1to2` - stacklevel=9, - ) - new_prior_type = v2.C.LOG_NORMAL - if new_prior_type not in v2.C.PRIOR_DISTRIBUTIONS: raise NotImplementedError( f"PEtab v2 does not support prior type `{new_prior_type}' " f"required for parameter `{row[v1.C.PARAMETER_ID]}'." ) - return new_prior_type + return new_prior_type, prior_pars # update parameterScale*-priors if v1.C.OBJECTIVE_PRIOR_TYPE in df.columns: - df[v1.C.OBJECTIVE_PRIOR_TYPE] = df.apply(update_prior, axis=1) + df[[v1.C.OBJECTIVE_PRIOR_TYPE, v1.C.OBJECTIVE_PRIOR_PARAMETERS]] = ( + df.apply(update_prior, axis=1, result_type="expand") + ) # rename objectivePrior* to prior* df.rename( diff --git a/tests/v2/test_conversion.py b/tests/v2/test_conversion.py index 3380c363..b152f7f2 100644 --- a/tests/v2/test_conversion.py +++ b/tests/v2/test_conversion.py @@ -1,10 +1,16 @@ import logging +from pathlib import Path +import numpy as np import pandas as pd import pytest from petab import v1, v2 +from petab.v1.calculate import calculate_llh as calculate_llh_v1 +from petab.v1.models.sbml_model import SbmlModel +from petab.v1.priors import Prior from petab.v2 import Problem +from petab.v2.calculate import calculate_llh as calculate_llh_v2 from petab.v2.petab1to2 import petab1to2, v1v2_observable_df @@ -29,6 +35,211 @@ def test_v1v2_observable_df_noise_distribution(): ] +def test_v1v2_observable_df_log10(): + """Test that log10-transformed observables are converted to + log-transformed ones with the noise formula scaled by ln(10).""" + observable_df = pd.DataFrame( + data={ + v1.C.OBSERVABLE_ID: ["obs1", "obs2", "obs3"], + v1.C.OBSERVABLE_FORMULA: ["a", "b", "c"], + v1.C.NOISE_FORMULA: [ + "noiseParameter1_obs1 + 0.1", + "noiseParameter1_obs2 + 0.1", + "noiseParameter1_obs3 + 0.1", + ], + v1.C.OBSERVABLE_TRANSFORMATION: [ + v1.C.LOG, + v1.C.LOG10, + v1.C.LOG10, + ], + v1.C.NOISE_DISTRIBUTION: [ + v1.C.NORMAL, + v1.C.NORMAL, + v1.C.LAPLACE, + ], + } + ).set_index(v1.C.OBSERVABLE_ID) + + new_df = v1v2_observable_df(observable_df) + + assert list(new_df[v2.C.NOISE_DISTRIBUTION]) == [ + v2.C.LOG_NORMAL, + v2.C.LOG_NORMAL, + v2.C.LOG_LAPLACE, + ] + assert list(new_df[v2.C.NOISE_FORMULA]) == [ + "noiseParameter1_obs1 + 0.1", + "log(10) * (noiseParameter1_obs2 + 0.1)", + "log(10) * (noiseParameter1_obs3 + 0.1)", + ] + assert list(new_df[v2.C.NOISE_PLACEHOLDERS]) == [ + "noiseParameter1_obs1", + "noiseParameter1_obs2", + "noiseParameter1_obs3", + ] + + +def _write_v1_problem( + path: Path, + *, + observables: list[dict], + measurements: list[dict], + parameters: list[dict], +) -> str: + """Write a PEtab v1 problem for the conversion A -> B with the given + tables (as lists of rows) to ``path`` and return the YAML file name.""" + problem = v1.Problem( + model=SbmlModel.from_antimony( + "r1: A -> B; k1 * A; A = 1; B = 0; k1 = 0.5" + ), + condition_df=v1.get_condition_df( + pd.DataFrame([{v1.C.CONDITION_ID: "c0", "A": 1.0}]) + ), + observable_df=v1.get_observable_df(pd.DataFrame(observables)), + measurement_df=v1.get_measurement_df(pd.DataFrame(measurements)), + parameter_df=v1.get_parameter_df(pd.DataFrame(parameters)), + ) + return problem.to_files_generic(path) + + +_K1 = { + v1.C.PARAMETER_ID: "k1", + v1.C.PARAMETER_SCALE: v1.C.LIN, + v1.C.LOWER_BOUND: 1e-3, + v1.C.UPPER_BOUND: 1e2, + v1.C.NOMINAL_VALUE: 0.5, + v1.C.ESTIMATE: 1, +} + + +@pytest.mark.parametrize("noise_distribution", [v1.C.NORMAL, v1.C.LAPLACE]) +@pytest.mark.parametrize("noise_placeholder", [False, True]) +def test_petab1to2_log10_noise_llh( + tmp_path, noise_distribution, noise_placeholder +): + """Test that the conversion of log10-transformed observables preserves + the likelihood.""" + if noise_placeholder: + noise_formula = "noiseParameter1_obs_B + offset" + noise_parameters = "sigma_B" + noise_parameter_rows = [ + { + v1.C.PARAMETER_ID: "sigma_B", + v1.C.PARAMETER_SCALE: v1.C.LIN, + v1.C.LOWER_BOUND: 1e-3, + v1.C.UPPER_BOUND: 1e2, + v1.C.NOMINAL_VALUE: 0.1, + v1.C.ESTIMATE: 1, + }, + { + v1.C.PARAMETER_ID: "offset", + v1.C.PARAMETER_SCALE: v1.C.LIN, + v1.C.LOWER_BOUND: 0, + v1.C.UPPER_BOUND: 1, + v1.C.NOMINAL_VALUE: 0.05, + v1.C.ESTIMATE: 0, + }, + ] + else: + noise_formula = "0.1" + noise_parameters = "" + noise_parameter_rows = [] + + yaml_file = _write_v1_problem( + tmp_path, + observables=[ + { + v1.C.OBSERVABLE_ID: "obs_B", + v1.C.OBSERVABLE_FORMULA: "B", + v1.C.OBSERVABLE_TRANSFORMATION: v1.C.LOG10, + v1.C.NOISE_FORMULA: noise_formula, + v1.C.NOISE_DISTRIBUTION: noise_distribution, + } + ], + measurements=[ + { + v1.C.OBSERVABLE_ID: "obs_B", + v1.C.SIMULATION_CONDITION_ID: "c0", + v1.C.TIME: t, + v1.C.MEASUREMENT: y, + v1.C.NOISE_PARAMETERS: noise_parameters, + } + for t, y in ((1.0, 0.3), (2.0, 0.5)) + ], + parameters=[_K1, *noise_parameter_rows], + ) + v1_problem = v1.Problem.from_yaml(yaml_file) + v2_problem = petab1to2(yaml_file) + + def simulate(measurement_df): + return measurement_df.rename( + columns={v1.C.MEASUREMENT: v1.C.SIMULATION} + ).assign(**{v1.C.SIMULATION: [0.35, 0.45]}) + + llh_v1 = calculate_llh_v1( + v1_problem.measurement_df, + simulate(v1_problem.measurement_df), + v1_problem.observable_df, + v1_problem.parameter_df, + ) + llh_v2 = calculate_llh_v2( + v2_problem.measurement_df, + simulate(v2_problem.measurement_df), + v2_problem.observable_df, + v2_problem.parameter_df, + ) + assert llh_v2 == pytest.approx(llh_v1, rel=1e-12) + + +@pytest.mark.filterwarnings( + "ignore:.*Parameter scales are not supported in PEtab v2.*:UserWarning" +) +@pytest.mark.parametrize( + "parameter_scale, prior_type", + [ + (v1.C.LOG10, v1.C.PARAMETER_SCALE_NORMAL), + (v1.C.LOG10, v1.C.PARAMETER_SCALE_LAPLACE), + (v1.C.LOG, v1.C.PARAMETER_SCALE_NORMAL), + (v1.C.LOG, v1.C.PARAMETER_SCALE_LAPLACE), + (v1.C.LIN, v1.C.PARAMETER_SCALE_NORMAL), + ], +) +def test_petab1to2_parameter_scale_priors( + tmp_path, parameter_scale, prior_type +): + """Test that parameterScale{Normal,Laplace} priors are converted to + priors with the same density.""" + k1 = _K1 | { + v1.C.PARAMETER_SCALE: parameter_scale, + v1.C.OBJECTIVE_PRIOR_TYPE: prior_type, + v1.C.OBJECTIVE_PRIOR_PARAMETERS: "-0.5;0.7", + } + yaml_file = _write_v1_problem( + tmp_path, + observables=[ + { + v1.C.OBSERVABLE_ID: "obs_B", + v1.C.OBSERVABLE_FORMULA: "B", + v1.C.NOISE_FORMULA: "0.1", + } + ], + measurements=[ + { + v1.C.OBSERVABLE_ID: "obs_B", + v1.C.SIMULATION_CONDITION_ID: "c0", + v1.C.TIME: 1.0, + v1.C.MEASUREMENT: 0.3, + } + ], + parameters=[k1], + ) + v1_prior = Prior.from_par_dict(k1, type_="objective") + v2_prior = petab1to2(yaml_file)["k1"].prior_dist + + x = np.logspace(-3, 2, 21) + assert v2_prior.pdf(x) == pytest.approx(v1_prior.pdf(x), rel=1e-12) + + def test_petab1to2_remote(): """Test that we can upgrade a remote PEtab 1.0.0 problem.""" yaml_url = ( @@ -53,9 +264,6 @@ def test_petab1to2_remote(): ) -@pytest.mark.filterwarnings( - "ignore:.*Using `log-normal` instead.*:UserWarning" -) @pytest.mark.filterwarnings( "ignore:.*Initialisation priors in parameter table are not supported.*:" "UserWarning" From dce27078c3478000129b3e784daf1a720a68e2f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 8 Oct 2026 18:58:57 +0100 Subject: [PATCH 2/3] petab1to2: warn when converting log10-transformed observables Parameters in the noise formulas of converted log10 observables (e.g., estimated noise standard deviations) keep their v1 meaning: they refer to log10(y), with the ln(10) factor sitting in the v2 noise formula. Warn about this once per conversion, listing the affected observables, so users of the converted problem don't misread them as natural-log noise parameters. Co-Authored-By: Claude Opus 5.5 --- petab/v2/petab1to2.py | 16 ++++++++++++++++ tests/v2/test_conversion.py | 9 +++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/petab/v2/petab1to2.py b/petab/v2/petab1to2.py index d61d94d8..56bd0cac 100644 --- a/petab/v2/petab1to2.py +++ b/petab/v2/petab1to2.py @@ -437,6 +437,22 @@ def get_noise_formula(row): df[v2.C.NOISE_DISTRIBUTION] = df.apply(get_noise_dist, axis=1) df[v1.C.NOISE_FORMULA] = df.apply(get_noise_formula, axis=1) + + is_log10 = df[v1.C.OBSERVABLE_TRANSFORMATION] == v1.C.LOG10 + if is_log10.any(): + log10_ids = ", ".join(df.loc[is_log10, v1.C.OBSERVABLE_ID]) + warnings.warn( + "PEtab v2 does not support log10-transformed observables. " + f"The noise distributions of observables {log10_ids} were " + "converted to log-normal or log-laplace, and their noise " + "formulas were multiplied by `log(10)`. Parameters in these " + "noise formulas (e.g., estimated noise parameters) keep their " + "PEtab v1 meaning, i.e., they refer to the log10-transformed " + "observable, not to its natural logarithm.", + # call to `petab1to2` + stacklevel=4, + ) + df.drop(columns=[v1.C.OBSERVABLE_TRANSFORMATION], inplace=True) def extract_placeholders(row: pd.Series, type_: str) -> str: diff --git a/tests/v2/test_conversion.py b/tests/v2/test_conversion.py index c5ada468..2193c2fa 100644 --- a/tests/v2/test_conversion.py +++ b/tests/v2/test_conversion.py @@ -60,7 +60,8 @@ def test_v1v2_observable_df_log10(): } ).set_index(v1.C.OBSERVABLE_ID) - new_df = v1v2_observable_df(observable_df) + with pytest.warns(UserWarning, match=r"observables obs2, obs3 were"): + new_df = v1v2_observable_df(observable_df) assert list(new_df[v2.C.NOISE_DISTRIBUTION]) == [ v2.C.LOG_NORMAL, @@ -169,7 +170,8 @@ def test_petab1to2_log10_noise_llh( parameters=[_K1, *noise_parameter_rows], ) v1_problem = v1.Problem.from_yaml(yaml_file) - v2_problem = petab1to2(yaml_file) + with pytest.warns(UserWarning, match=r"observables obs_B were"): + v2_problem = petab1to2(yaml_file) def simulate(measurement_df): return measurement_df.rename( @@ -466,6 +468,9 @@ def test_petab1to2_validate(tmp_path): @pytest.mark.filterwarnings( "ignore:.*Parameter scales are not supported in PEtab v2.*:UserWarning" ) +@pytest.mark.filterwarnings( + "ignore:.*does not support log10-transformed observables.*:UserWarning" +) @parametrize_or_skip def test_benchmark_collection(problem_id): """Test that we can upgrade all benchmark collection models.""" From 62fd87df3eca79209b231601f1b5507dbf9d555b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Fri, 9 Oct 2026 09:33:27 +0100 Subject: [PATCH 3/3] petab1to2: remove unused `re` import The merge of #532 removed the last use of `re`. Co-Authored-By: Claude Opus 5.5 --- petab/v2/petab1to2.py | 1 - 1 file changed, 1 deletion(-) diff --git a/petab/v2/petab1to2.py b/petab/v2/petab1to2.py index f576c03e..bd731691 100644 --- a/petab/v2/petab1to2.py +++ b/petab/v2/petab1to2.py @@ -3,7 +3,6 @@ from __future__ import annotations import math -import re import shutil import warnings from contextlib import suppress