Skip to content

Commit 4be4d4a

Browse files
dweindlclaude
andcommitted
Address review comments on log reparametrization
* `petab1to2`: also warn about converted `parameterScaleUniform` priors with `reparametrize="none"`, which change the maximum a posteriori estimate as well * `petab1to2`: point to `reparametrize="none"` if reparametrization fails, and drop the `parameter_ids` hint from the `reparametrize_log` error, which doesn't apply there * `petab1to2`: warn once about parameters skipped because of placeholders, not once per base * `reparametrize_log`: ignore non-positive bounds of non-estimated parameters, and raise `NotImplementedError` for SBML models before L2V2, which have no initial assignments * tests: likelihood after reparametrizing overrides with both bases, and the benchmark collection with `reparametrize="all"` Generated with AI assistance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent fd3334d commit 4be4d4a

4 files changed

Lines changed: 175 additions & 43 deletions

File tree

‎petab/v2/petab1to2.py‎

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -701,7 +701,11 @@ def update_prior(row) -> tuple[str, str | None]:
701701
has_scale_prior = parameter_df[v1.C.PARAMETER_SCALE].isin(
702702
[v1.C.LOG, v1.C.LOG10]
703703
) & parameter_df[v1.C.OBJECTIVE_PRIOR_TYPE].isin(
704-
[v1.C.PARAMETER_SCALE_NORMAL, v1.C.PARAMETER_SCALE_LAPLACE]
704+
[
705+
v1.C.PARAMETER_SCALE_NORMAL,
706+
v1.C.PARAMETER_SCALE_LAPLACE,
707+
v1.C.PARAMETER_SCALE_UNIFORM,
708+
]
705709
)
706710
if has_scale_prior.any():
707711
warnings.warn(
@@ -779,16 +783,13 @@ def _reparametrize_log_scaled(
779783
# A prior over the unscaled parameter. Its conversion already
780784
# preserves the objective function.
781785
continue
782-
if (
783-
row.get(v1.C.LOWER_BOUND, 1) <= 0
784-
or row.get(v1.C.UPPER_BOUND, 1) <= 0
786+
if int(row[v1.C.ESTIMATE]) and (
787+
row[v1.C.LOWER_BOUND] <= 0 or row[v1.C.UPPER_BOUND] <= 0
785788
):
786789
# v1 allows a lower bound of 0 for log-scaled parameters.
787790
# This would be -inf on log scale, where neither the v1 default
788791
# prior nor a parameterScaleUniform prior are proper.
789-
if int(row[v1.C.ESTIMATE]) and (
790-
reparametrize == "all" or prior_type is not None
791-
):
792+
if reparametrize == "all" or prior_type is not None:
792793
zero_bound.append(par_id)
793794
continue
794795

@@ -834,7 +835,7 @@ def _reparametrize_log_scaled(
834835
warnings.warn(
835836
f"Log-scaled parameters {', '.join(zero_bound)} were not "
836837
"reparametrized, because their bounds are not positive. "
837-
"They are estimated on linear scale, and their parameter-scale "
838+
"They are estimated on linear scale, and any parameter-scale "
838839
"priors were converted to the corresponding priors of the "
839840
"unscaled parameters, which changes the maximum a posteriori "
840841
"estimate.",
@@ -847,8 +848,8 @@ def _reparametrize_log_scaled(
847848
# drop selected parameters that don't have parameter-scale priors.
848849
# Otherwise, `reparametrize_log` will raise.
849850
slots = _get_override_slots(problem).values()
851+
excluded = set()
850852
for base, base_selected in selected.items():
851-
excluded = set()
852853
changed = True
853854
while changed:
854855
changed = False
@@ -866,22 +867,29 @@ def _reparametrize_log_scaled(
866867
base_selected -= optional
867868
excluded |= optional
868869
changed = True
869-
if excluded:
870-
warnings.warn(
871-
"Log-scaled parameters "
872-
f"{', '.join(sorted(excluded))} were not reparametrized, "
873-
"because they are used as overrides for placeholders that "
874-
"cannot be log-transformed.",
875-
stacklevel=4,
876-
)
870+
if excluded:
871+
warnings.warn(
872+
"Log-scaled parameters "
873+
f"{', '.join(sorted(excluded))} were not reparametrized, "
874+
"because they are used as overrides for placeholders that "
875+
"cannot be log-transformed.",
876+
stacklevel=4,
877+
)
877878

878879
for base, base_selected in selected.items():
879-
reparametrize_log(
880-
problem,
881-
# keep the parameter table order
882-
[p for p in candidates[base] if p in base_selected],
883-
base=base,
884-
priors={p: candidates[base][p] for p in base_selected},
885-
)
880+
try:
881+
reparametrize_log(
882+
problem,
883+
# keep the parameter table order
884+
[p for p in candidates[base] if p in base_selected],
885+
base=base,
886+
priors={p: candidates[base][p] for p in base_selected},
887+
)
888+
except NotImplementedError as e:
889+
raise NotImplementedError(
890+
"Log-scaled parameters could not be reparametrized. "
891+
"Use `reparametrize='none'` to estimate them on linear "
892+
"scale.\n" + str(e)
893+
) from e
886894

887895
return any(selected.values())

‎petab/v2/reparametrize.py‎

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,9 @@ def reparametrize_log(
9292
:return:
9393
A dictionary mapping old to new parameter IDs.
9494
:raises ValueError:
95-
If a parameter cannot be reparametrized, e.g., because its lower bound
96-
is not positive, or if its prior cannot be expressed in terms of the
97-
new parameter.
95+
If a parameter cannot be reparametrized, e.g., because it is estimated
96+
and its lower bound is not positive, or if its prior cannot be
97+
expressed in terms of the new parameter.
9898
:raises NotImplementedError:
9999
If the problem has features that are not supported yet,
100100
e.g., multiple or non-SBML models, mapping table entries for the
@@ -152,8 +152,14 @@ def reparametrize_log(
152152
raise NotImplementedError(
153153
f"Model entity `{p}' is not an SBML parameter."
154154
)
155+
sbml_model = model.sbml_model
156+
if (sbml_model.getLevel(), sbml_model.getVersion()) < (2, 2):
157+
raise NotImplementedError(
158+
"Reparametrization of model parameters requires "
159+
"initial assignments (SBML L2V2 or later)."
160+
)
155161
model_entities.append(p)
156-
if any(bound is not None and bound <= 0 for bound in (par.lb, par.ub)):
162+
if par.estimate and (par.lb <= 0 or par.ub <= 0):
157163
# (bounds are required for estimated parameters)
158164
raise ValueError(
159165
f"Bounds of parameter `{p}' must be positive, "
@@ -199,8 +205,9 @@ def reparametrize_log(
199205
new_parameters[p] = Parameter(
200206
id=p,
201207
name=par.name and f"{prefix.removesuffix('_')}({par.name})",
202-
lb=None if par.lb is None else log_b(par.lb),
203-
ub=None if par.ub is None else log_b(par.ub),
208+
# bounds of non-estimated parameters are irrelevant
209+
lb=log_b(par.lb) if par.lb is not None and par.lb > 0 else None,
210+
ub=log_b(par.ub) if par.ub is not None and par.ub > 0 else None,
204211
nominal_value=nominal_value,
205212
estimate=par.estimate,
206213
prior_distribution=prior_distribution,
@@ -380,8 +387,7 @@ def quote(values) -> str:
380387
if others:
381388
error += (
382389
f"\n - parameters that are not reparametrized: "
383-
f"{quote(others)} (consider including them in "
384-
"`parameter_ids')"
390+
f"{quote(others)}"
385391
)
386392
if non_positive:
387393
error += "\n - non-positive numbers: " + ", ".join(
@@ -396,8 +402,9 @@ def quote(values) -> str:
396402
"all overrides of a placeholder to be reparametrized parameters "
397403
"or positive numbers.\n"
398404
+ "\n".join(errors)
399-
+ "\nSplitting observables is not supported. Exclude the "
400-
"affected parameters or split the observables manually."
405+
+ "\nSplitting observables is not supported. Reparametrize "
406+
"all or none of the parameters overriding a placeholder, "
407+
"or split the observables manually."
401408
)
402409

403410
if not slots_to_transform:

‎tests/v2/test_conversion.py‎

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,87 @@ def test_petab1to2_reparametrize_priors(
363363
np.testing.assert_allclose(v2_pdf, v1_pdf, rtol=1e-12)
364364

365365

366+
@pytest.mark.filterwarnings(
367+
"ignore:.*Parameter scales are not supported in PEtab v2.*:UserWarning"
368+
)
369+
def test_petab1to2_reparametrize_llh(tmp_path):
370+
"""Test that reparametrizing overrides and parameters used in formulas
371+
preserves the likelihood, for both bases."""
372+
373+
def par(par_id, scale, nominal_value):
374+
return {
375+
v1.C.PARAMETER_ID: par_id,
376+
v1.C.PARAMETER_SCALE: scale,
377+
v1.C.LOWER_BOUND: 1e-3,
378+
v1.C.UPPER_BOUND: 1e3,
379+
v1.C.NOMINAL_VALUE: nominal_value,
380+
v1.C.ESTIMATE: 1,
381+
v1.C.OBJECTIVE_PRIOR_TYPE: v1.C.PARAMETER_SCALE_NORMAL,
382+
v1.C.OBJECTIVE_PRIOR_PARAMETERS: "0;1",
383+
}
384+
385+
yaml_file = _write_v1_problem(
386+
tmp_path,
387+
observables=[
388+
{
389+
v1.C.OBSERVABLE_ID: "obs_B",
390+
v1.C.OBSERVABLE_FORMULA: "observableParameter1_obs_B * B "
391+
"+ offset",
392+
v1.C.NOISE_FORMULA: "noiseParameter1_obs_B",
393+
}
394+
],
395+
measurements=[
396+
{
397+
v1.C.OBSERVABLE_ID: "obs_B",
398+
v1.C.SIMULATION_CONDITION_ID: "c0",
399+
v1.C.TIME: float(t),
400+
v1.C.MEASUREMENT: 0.3 + t,
401+
v1.C.OBSERVABLE_PARAMETERS: obs_par,
402+
v1.C.NOISE_PARAMETERS: noise_par,
403+
}
404+
for t, (obs_par, noise_par) in enumerate(
405+
[("s1", "sd"), ("s2", "0.2"), ("4", "sd")]
406+
)
407+
],
408+
parameters=[
409+
_K1,
410+
par("s1", v1.C.LOG10, 3.0),
411+
par("s2", v1.C.LOG10, 0.7),
412+
par("sd", v1.C.LOG, 0.25),
413+
par("offset", v1.C.LOG10, 0.13),
414+
],
415+
)
416+
v1_problem = v1.Problem.from_yaml(yaml_file)
417+
v2_problem = petab1to2(yaml_file, reparametrize="priors")
418+
assert [p.id for p in v2_problem.parameters] == [
419+
"k1",
420+
"log10_s1",
421+
"log10_s2",
422+
"log_sd",
423+
"log10_offset",
424+
]
425+
426+
def simulate(measurement_df):
427+
return measurement_df.rename(
428+
columns={v1.C.MEASUREMENT: v1.C.SIMULATION}
429+
).assign(**{v1.C.SIMULATION: [0.5, 0.6, 0.7]})
430+
431+
llh_v1 = calculate_llh_v1(
432+
v1_problem.measurement_df,
433+
simulate(v1_problem.measurement_df),
434+
v1_problem.observable_df,
435+
v1_problem.parameter_df,
436+
)
437+
llh_v2 = calculate_llh_v2(
438+
v2_problem.measurement_df,
439+
simulate(v2_problem.measurement_df),
440+
v2_problem.observable_df,
441+
v2_problem.parameter_df,
442+
)
443+
assert np.isfinite(llh_v1)
444+
assert llh_v2 == pytest.approx(llh_v1, rel=1e-12)
445+
446+
366447
@pytest.mark.filterwarnings(
367448
"ignore:.*Parameter scales are not supported in PEtab v2.*:UserWarning",
368449
"ignore:Initialisation priors.*:UserWarning",
@@ -568,10 +649,11 @@ def test_petab1to2_reparametrize_overrides_unresolvable(tmp_path):
568649
],
569650
overrides=["s1", "s2"],
570651
)
571-
with pytest.raises(
572-
NotImplementedError, match=r"not reparametrized: `s2' \("
573-
):
652+
with pytest.raises(NotImplementedError) as excinfo:
574653
petab1to2(yaml_file, reparametrize="priors")
654+
msg = str(excinfo.value)
655+
assert "Use `reparametrize='none'`" in msg
656+
assert "not reparametrized: `s2'\n" in msg
575657

576658

577659
@pytest.mark.filterwarnings(
@@ -618,11 +700,19 @@ def test_petab1to2_reparametrize_output_parameter(tmp_path):
618700
assert problem["obs_B"].formula == sympify_petab("10^log10_scaling * B")
619701

620702

621-
def test_petab1to2_reparametrize_warnings_and_errors(tmp_path):
703+
@pytest.mark.parametrize(
704+
"prior_type",
705+
[
706+
v1.C.PARAMETER_SCALE_NORMAL,
707+
v1.C.PARAMETER_SCALE_LAPLACE,
708+
v1.C.PARAMETER_SCALE_UNIFORM,
709+
],
710+
)
711+
def test_petab1to2_reparametrize_warnings_and_errors(tmp_path, prior_type):
622712
k1 = _K1 | {
623713
v1.C.PARAMETER_SCALE: v1.C.LOG10,
624-
v1.C.OBJECTIVE_PRIOR_TYPE: v1.C.PARAMETER_SCALE_NORMAL,
625-
v1.C.OBJECTIVE_PRIOR_PARAMETERS: "0;1",
714+
v1.C.OBJECTIVE_PRIOR_TYPE: prior_type,
715+
v1.C.OBJECTIVE_PRIOR_PARAMETERS: "-1;1",
626716
}
627717
yaml_file = _write_v1_problem(
628718
tmp_path,
@@ -1034,8 +1124,15 @@ def test_petab1to2_validate(tmp_path):
10341124
@pytest.mark.filterwarnings(
10351125
"ignore:.*Parameter-scale priors of log-scaled parameters.*:UserWarning"
10361126
)
1127+
@pytest.mark.filterwarnings(
1128+
"ignore:Log-scaled parameters .* were not reparametrized.*:UserWarning"
1129+
)
1130+
@pytest.mark.filterwarnings(
1131+
"ignore:Parameter .* has no positive nominal value.*:UserWarning"
1132+
)
1133+
@pytest.mark.parametrize("reparametrize", ["none", "all"])
10371134
@parametrize_or_skip
1038-
def test_benchmark_collection(problem_id):
1135+
def test_benchmark_collection(problem_id, reparametrize):
10391136
"""Test that we can upgrade all benchmark collection models."""
10401137
logging.basicConfig(level=logging.DEBUG)
10411138

@@ -1045,8 +1142,10 @@ def test_benchmark_collection(problem_id):
10451142

10461143
yaml_path = benchmark_models_petab.get_problem_yaml_path(problem_id)
10471144
try:
1048-
problem = petab1to2(yaml_path)
1145+
problem = petab1to2(yaml_path, reparametrize=reparametrize)
10491146
except NotImplementedError as e:
1147+
if "could not be reparametrized" in str(e):
1148+
raise
10501149
pytest.skip(str(e))
10511150
assert isinstance(problem, Problem)
10521151
assert len(problem.measurements)

‎tests/v2/test_reparametrize.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,24 @@ def test_reparametrize_log_invalid():
234234
with pytest.raises(NotImplementedError, match="mapping table"):
235235
reparametrize_log(problem, ["scaling"])
236236

237+
problem = _problem()
238+
doc = problem.model.sbml_model.getSBMLDocument()
239+
assert doc.setLevelAndVersion(2, 1)
240+
with pytest.raises(NotImplementedError, match="L2V2"):
241+
reparametrize_log(problem, ["k1"])
242+
243+
244+
def test_reparametrize_log_fixed_nonpositive_bounds():
245+
"""Bounds of non-estimated parameters are irrelevant."""
246+
problem = _problem()
247+
problem["offset2"].lb = 0
248+
problem["offset2"].ub = 10
249+
reparametrize_log(problem, ["offset1", "offset2"])
250+
par = problem["log10_offset2"]
251+
assert par.lb is None
252+
assert par.ub == 1
253+
assert par.nominal_value == pytest.approx(math.log10(6))
254+
237255

238256
def test_reparametrize_log_unresolvable_override():
239257
# offset1 is reparametrized, offset2 is not
@@ -248,7 +266,7 @@ def test_reparametrize_log_unresolvable_override():
248266
"Observable placeholder `offset_A' of observable `obs_A' "
249267
"(overridden by `offset1') also receives:" in msg
250268
)
251-
assert "not reparametrized: `offset2' (consider including" in msg
269+
assert "not reparametrized: `offset2'\n" in msg
252270
assert "non-positive" not in msg
253271

254272
# non-positive numeric overrides, in several placeholders, reported at

0 commit comments

Comments
 (0)