Skip to content

Commit e46ef71

Browse files
FFroehlichclaude
andauthored
v2: split and validate multi-model problems (#537)
Closes #392. PEtab v2 problems can have more than one model. So far `Problem.validate()` only warned about this, and every task that touches `Problem.model` failed. As a result, `validate().has_errors()` was always true for such problems. **Changes** * New `Problem.split_by_model() -> dict[str, Problem]`. It returns one single-model problem per model, containing: * that model's measurements; * the experiments and conditions they reference; * the measured observables; * the mappings that don't resolve in another model (so extension mappings such as SciML's are kept); * the shared parameters that are valid for that model. It raises on duplicate model IDs and on measurements that aren't assigned to a model. With a single model, `modelId` stays optional. * `Problem.validate()` on multi-model problems: * checks the model IDs (`CheckMeasurementModelId`, duplicate model IDs) on the full problem and stops there if they're invalid; * runs `CheckUniquePrimaryKeys`, `CheckMappingTable`, `CheckUnusedExperiments` and `CheckUnusedConditions` on the full problem, because the split drops unused entries and other models' mappings; * runs the remaining tasks on each part, and prefixes each issue's message with ``Model `<id>`: ``; * reports parameters that no model uses as an error, if `CheckAllParametersPresentInParameterTable` is requested. The single-model extraneous-parameter check can't see them after the split. * `Problem.__str__` no longer raises for multi-model problems. * `Problem.add_measurement(..., model_id=...)`. **Downstream**: pyPESTO ICB-DCM/pyPESTO#1772 adds multi-model PEtab v2 support. It currently carries its own copies of these helpers (`split_petab_problem_by_model`, `_validate_petab_v2_problem`), which it can drop once this is released. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 90f7068 commit e46ef71

2 files changed

Lines changed: 379 additions & 13 deletions

File tree

‎petab/v2/core.py‎

Lines changed: 216 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,7 +1180,9 @@ def __repr__(self):
11801180
def __str__(self):
11811181
pid = repr(self.id) if self.id else "without ID"
11821182

1183-
model = f"with models {self.models}" if self.model else "without model"
1183+
model = (
1184+
f"with models {self.models}" if self.models else "without model"
1185+
)
11841186

11851187
ne = len(self.experiments)
11861188
experiments = f"{ne} experiments"
@@ -1990,20 +1992,33 @@ def validate(
19901992
)
19911993
)
19921994

1995+
validation_tasks = validation_tasks or self.validation_tasks
19931996
if len(self.models) > 1:
1994-
# TODO https://github.com/PEtab-dev/libpetab-python/issues/392
1995-
# We might just want to split the problem into multiple
1996-
# problems, one for each model, and then validate each
1997-
# problem separately.
1998-
validation_results.append(
1999-
ValidationIssue(
2000-
ValidationIssueSeverity.WARNING,
2001-
"Problem contains multiple models. "
2002-
"Validation is not yet fully supported.",
2003-
)
1997+
validation_results.extend(
1998+
self._validate_multi_model(validation_tasks)
1999+
)
2000+
else:
2001+
validation_results.extend(
2002+
self._run_validation_tasks(validation_tasks)
20042003
)
20052004

2006-
for task in validation_tasks or self.validation_tasks:
2005+
return validation_results
2006+
2007+
def _run_validation_tasks(
2008+
self, validation_tasks: list[ValidationTask]
2009+
) -> ValidationResultList:
2010+
"""Run the given validation tasks on this problem.
2011+
2012+
Stops at the first critical issue.
2013+
"""
2014+
from ..v2.lint import (
2015+
ValidationIssue,
2016+
ValidationIssueSeverity,
2017+
ValidationResultList,
2018+
)
2019+
2020+
validation_results = ValidationResultList()
2021+
for task in validation_tasks:
20072022
try:
20082023
cur_result = task.run(self)
20092024
except Exception as e: # noqa BLE001
@@ -2021,6 +2036,192 @@ def validate(
20212036

20222037
return validation_results
20232038

2039+
def _validate_multi_model(
2040+
self, validation_tasks: list[ValidationTask]
2041+
) -> ValidationResultList:
2042+
"""Validate a problem with multiple models.
2043+
2044+
Runs the validation tasks on each part of
2045+
:meth:`Problem.split_by_model`. Checks of the shared tables
2046+
(model IDs of measurements, unique IDs, the mapping table, unused
2047+
experiments, conditions and parameters) run on the full problem.
2048+
See https://github.com/PEtab-dev/libpetab-python/issues/392.
2049+
"""
2050+
from ..v2.lint import (
2051+
CheckAllParametersPresentInParameterTable,
2052+
CheckMappingTable,
2053+
CheckMeasurementModelId,
2054+
CheckUniquePrimaryKeys,
2055+
CheckUnusedConditions,
2056+
CheckUnusedExperiments,
2057+
ValidationError,
2058+
ValidationResultList,
2059+
)
2060+
2061+
validation_results = ValidationResultList()
2062+
2063+
model_ids = [model.model_id for model in self.models]
2064+
if duplicates := sorted(
2065+
{id_ for id_ in model_ids if model_ids.count(id_) > 1}
2066+
):
2067+
validation_results.append(
2068+
ValidationError(f"Duplicate model IDs: {duplicates}")
2069+
)
2070+
# always required, otherwise the problem can't be split
2071+
validation_results.extend(
2072+
self._run_validation_tasks([CheckMeasurementModelId()])
2073+
)
2074+
if validation_results.has_errors():
2075+
return validation_results
2076+
2077+
# the split drops unused or other models' table entries,
2078+
# so checks of the shared tables have to run on the full problem
2079+
full_problem_task_types = (
2080+
CheckUniquePrimaryKeys,
2081+
CheckMappingTable,
2082+
CheckUnusedExperiments,
2083+
CheckUnusedConditions,
2084+
)
2085+
validation_results.extend(
2086+
self._run_validation_tasks(
2087+
[
2088+
task
2089+
for task in validation_tasks
2090+
if isinstance(task, full_problem_task_types)
2091+
]
2092+
)
2093+
)
2094+
2095+
sub_problems = self.split_by_model()
2096+
for model_id, sub_problem in sub_problems.items():
2097+
for issue in sub_problem._run_validation_tasks(
2098+
[
2099+
task
2100+
for task in validation_tasks
2101+
if not isinstance(task, full_problem_task_types)
2102+
]
2103+
):
2104+
issue.message = f"Model `{model_id}`: {issue.message}"
2105+
validation_results.append(issue)
2106+
2107+
# extraneous parameters are dropped by the split
2108+
if not any(
2109+
isinstance(task, CheckAllParametersPresentInParameterTable)
2110+
for task in validation_tasks
2111+
):
2112+
return validation_results
2113+
used_parameter_ids = set().union(
2114+
*(sub_problem.x_ids for sub_problem in sub_problems.values())
2115+
)
2116+
if unused := [x for x in self.x_ids if x not in used_parameter_ids]:
2117+
validation_results.append(
2118+
ValidationError(
2119+
"Extraneous parameter(s) in parameter table, not used "
2120+
f"by any model: {unused}"
2121+
)
2122+
)
2123+
2124+
return validation_results
2125+
2126+
def split_by_model(self) -> dict[str, Problem]:
2127+
"""Split the problem into one single-model problem per model.
2128+
2129+
Each part contains the model, its measurements, and the experiments,
2130+
conditions, observables, mappings and parameters these require.
2131+
Mappings to entities of other models are dropped, all other mappings
2132+
(e.g., to extension entities) are kept.
2133+
The parts share their models and table entries with this problem.
2134+
2135+
See https://github.com/PEtab-dev/libpetab-python/issues/392.
2136+
2137+
:returns: The single-model problems, keyed by model ID.
2138+
:raises ValueError: If model IDs are not unique, or if any
2139+
measurement is not assigned to a model.
2140+
"""
2141+
from ..v2.lint import get_valid_parameters_for_parameter_table
2142+
2143+
model_ids = {model.model_id for model in self.models}
2144+
if len(model_ids) != len(self.models):
2145+
raise ValueError("Model IDs must be unique.")
2146+
2147+
def get_model_id(measurement: Measurement) -> str | None:
2148+
# with a single model, the model ID is optional
2149+
if measurement.model_id is None and len(self.models) == 1:
2150+
return self.models[0].model_id
2151+
return measurement.model_id
2152+
2153+
if unassigned := [
2154+
m for m in self.measurements if get_model_id(m) not in model_ids
2155+
]:
2156+
raise ValueError(
2157+
f"Measurement not assigned to any model: {unassigned[0]}"
2158+
)
2159+
2160+
sub_problems = {}
2161+
for model in self.models:
2162+
measurements = [
2163+
m
2164+
for m in self.measurements
2165+
if get_model_id(m) == model.model_id
2166+
]
2167+
experiment_ids = {m.experiment_id for m in measurements}
2168+
experiments = [
2169+
e for e in self.experiments if e.id in experiment_ids
2170+
]
2171+
condition_ids = {
2172+
condition_id
2173+
for e in experiments
2174+
for period in e.periods
2175+
for condition_id in period.condition_ids
2176+
}
2177+
observable_ids = {m.observable_id for m in measurements}
2178+
2179+
sub_problem = Problem(
2180+
models=[model],
2181+
condition_tables=[
2182+
ConditionTable(
2183+
[c for c in self.conditions if c.id in condition_ids]
2184+
)
2185+
],
2186+
experiment_tables=[ExperimentTable(experiments)],
2187+
observable_tables=[
2188+
ObservableTable(
2189+
[o for o in self.observables if o.id in observable_ids]
2190+
)
2191+
],
2192+
measurement_tables=[MeasurementTable(measurements)],
2193+
mapping_tables=[
2194+
MappingTable(
2195+
[
2196+
m
2197+
for m in self.mappings
2198+
if m.model_id is None
2199+
or model.has_entity_with_id(m.model_id)
2200+
or not any(
2201+
other.has_entity_with_id(m.model_id)
2202+
for other in self.models
2203+
)
2204+
]
2205+
)
2206+
],
2207+
extensions=self.extensions,
2208+
config=self.config,
2209+
)
2210+
sub_problem.validation_tasks = self.validation_tasks.copy()
2211+
# get_valid_parameters_for_parameter_table doesn't depend on the
2212+
# parameter table, so we can use it before adding the parameters
2213+
valid_parameter_ids = get_valid_parameters_for_parameter_table(
2214+
sub_problem
2215+
)
2216+
sub_problem.parameter_tables = [
2217+
ParameterTable(
2218+
[p for p in self.parameters if p.id in valid_parameter_ids]
2219+
)
2220+
]
2221+
sub_problems[model.model_id] = sub_problem
2222+
2223+
return sub_problems
2224+
20242225
def assert_valid(self, **kwargs) -> None:
20252226
"""Assert that the PEtab problem is valid.
20262227
@@ -2188,6 +2389,7 @@ def add_measurement(
21882389
| float
21892390
| None = None,
21902391
noise_parameters: Sequence[str | float] | str | float | None = None,
2392+
model_id: str | None = None,
21912393
):
21922394
"""Add a measurement to the problem.
21932395
@@ -2201,6 +2403,7 @@ def add_measurement(
22012403
measurement: The measurement value
22022404
observable_parameters: The observable parameters
22032405
noise_parameters: The noise parameters
2406+
model_id: The ID of the model the measurement belongs to
22042407
"""
22052408
if observable_parameters is not None and not isinstance(
22062409
observable_parameters, Sequence
@@ -2222,6 +2425,7 @@ def add_measurement(
22222425
measurement=measurement,
22232426
observable_parameters=observable_parameters,
22242427
noise_parameters=noise_parameters,
2428+
model_id=model_id,
22252429
)
22262430
)
22272431

0 commit comments

Comments
 (0)