From 512c56c8fbe35868f7b3e1428fe8d1bb4bd59212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 28 Nov 2024 20:46:45 +0000 Subject: [PATCH 01/92] add jax serialisation --- python/sdist/amici/jax.template.py | 17 +++----- python/sdist/amici/jax/model.py | 4 +- python/sdist/amici/jax/petab.py | 43 ++++++++++++++++++- python/tests/test_jax.py | 29 +++++++++++++ .../benchmark-models/test_petab_benchmark.py | 12 +----- 5 files changed, 83 insertions(+), 22 deletions(-) diff --git a/python/sdist/amici/jax.template.py b/python/sdist/amici/jax.template.py index 367ba9e500..eda47b4f09 100644 --- a/python/sdist/amici/jax.template.py +++ b/python/sdist/amici/jax.template.py @@ -1,17 +1,18 @@ -import jax.numpy as jnp -from interpax import interp1d +from pathlib import Path from amici.jax.model import JAXModel +# ruff: noqa: F821, F841 + class JAXModel_TPL_MODEL_NAME(JAXModel): api_version = TPL_MODEL_API_VERSION def __init__(self): + self.jax_py_file = Path(__file__).resolve() super().__init__() def _xdot(self, t, x, args): - pk, tcl = args TPL_X_SYMS = x @@ -24,7 +25,6 @@ def _xdot(self, t, x, args): return TPL_XDOT_RET def _w(self, t, x, pk, tcl): - TPL_X_SYMS = x TPL_PK_SYMS = pk TPL_TCL_SYMS = tcl @@ -34,7 +34,6 @@ def _w(self, t, x, pk, tcl): return TPL_W_RET def _x0(self, pk): - TPL_PK_SYMS = pk TPL_X0_EQ @@ -42,7 +41,6 @@ def _x0(self, pk): return TPL_X0_RET def _x_solver(self, x): - TPL_X_RDATA_SYMS = x TPL_X_SOLVER_EQ @@ -50,7 +48,6 @@ def _x_solver(self, x): return TPL_X_SOLVER_RET def _x_rdata(self, x, tcl): - TPL_X_SYMS = x TPL_TCL_SYMS = tcl @@ -59,7 +56,6 @@ def _x_rdata(self, x, tcl): return TPL_X_RDATA_RET def _tcl(self, x, pk): - TPL_X_RDATA_SYMS = x TPL_PK_SYMS = pk @@ -68,7 +64,6 @@ def _tcl(self, x, pk): return TPL_TOTAL_CL_RET def _y(self, t, x, pk, tcl): - TPL_X_SYMS = x TPL_PK_SYMS = pk TPL_W_SYMS = self._w(t, x, pk, tcl) @@ -86,7 +81,6 @@ def _sigmay(self, y, pk): return TPL_SIGMAY_RET - def _nllh(self, t, x, pk, tcl, my, iy): y = self._y(t, x, pk, tcl) TPL_Y_SYMS = y @@ -107,3 +101,6 @@ def state_ids(self): @property def parameter_ids(self): return TPL_PK_IDS + + +Model = JAXModel_TPL_MODEL_NAME diff --git a/python/sdist/amici/jax/model.py b/python/sdist/amici/jax/model.py index a7b274027a..e037c44a2f 100644 --- a/python/sdist/amici/jax/model.py +++ b/python/sdist/amici/jax/model.py @@ -3,6 +3,7 @@ # ruff: noqa: F821 F722 from abc import abstractmethod +from pathlib import Path import diffrax import equinox as eqx @@ -18,8 +19,9 @@ class JAXModel(eqx.Module): classes inheriting from JAXModel. """ - MODEL_API_VERSION = "0.0.1" + MODEL_API_VERSION = "0.0.2" api_version: str + jax_py_file: Path def __init__(self): if self.api_version != self.MODEL_API_VERSION: diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index 6ddfb7c074..fc74a9f50f 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -1,7 +1,8 @@ """PEtab wrappers for JAX models.""" "" - +import shutil from numbers import Number from collections.abc import Iterable +from pathlib import Path import diffrax import equinox as eqx @@ -12,6 +13,7 @@ import pandas as pd import petab.v1 as petab +from amici import _module_from_path from amici.petab.parameter_mapping import ( ParameterMappingForCondition, create_parameter_mapping, @@ -84,6 +86,45 @@ def __init__(self, model: JAXModel, petab_problem: petab.Problem): self._measurements = self._get_measurements(scs) self.parameters = self._get_nominal_parameter_values() + def save(self, directory: Path): + """ + Save the problem to a file. + + :param directory: + Directory to save the problem to. + """ + self._petab_problem.to_files( + prefix_path=directory, + model_file="model", + condition_file="conditions.tsv", + measurement_file="measurements.tsv", + parameter_file="parameters.tsv", + observable_file="observables.tsv", + yaml_file="problem.yaml", + ) + shutil.copy(self.model.jax_py_file, directory / "jax_py_file.py") + with open(directory / "parameters.pkl", "wb") as f: + eqx.tree_serialise_leaves(f, self) + + @classmethod + def load(cls, directory: Path): + """ + Load a problem from a file. + + :param directory: + Directory to load the problem from. + + :return: + Loaded problem instance. + """ + petab_problem = petab.Problem.from_yaml( + directory / "problem.yaml", + ) + model = _module_from_path("jax", directory / "jax_py_file.py").Model() + problem = cls(model, petab_problem) + with open(directory / "parameters.pkl", "rb") as f: + return eqx.tree_deserialise_leaves(f, problem) + def _get_parameter_mappings( self, simulation_conditions: pd.DataFrame ) -> dict[str, ParameterMappingForCondition]: diff --git a/python/tests/test_jax.py b/python/tests/test_jax.py index 3254667c50..3055d77983 100644 --- a/python/tests/test_jax.py +++ b/python/tests/test_jax.py @@ -1,10 +1,12 @@ import pytest import amici +from pathlib import Path pytest.importorskip("jax") import amici.jax import jax.numpy as jnp +import jax.random as jr import jax import diffrax import numpy as np @@ -12,6 +14,8 @@ from amici.pysb_import import pysb2amici from amici.testing import TemporaryDirectoryWinSafe, skip_on_valgrind +from amici.petab.petab_import import import_petab_problem +from amici.jax import JAXProblem from numpy.testing import assert_allclose pysb = pytest.importorskip("pysb") @@ -222,3 +226,28 @@ def check_fields_jax( rtol=1e-5, err_msg=f"field {field} does not match", ) + + +@skip_on_valgrind +def test_serialisation(lotka_volterra): + petab_problem = lotka_volterra + with TemporaryDirectoryWinSafe( + prefix=petab_problem.model.model_id + ) as model_dir: + jax_model = import_petab_problem( + petab_problem, jax=True, model_output_dir=model_dir + ) + jax_problem = JAXProblem(jax_model, petab_problem) + # change parameters to random values to test serialisation + jax_problem.update_parameters( + jax_problem.parameters + + jr.normal(jr.PRNGKey(0), jax_problem.parameters.shape) + ) + + with TemporaryDirectoryWinSafe() as outdir: + outdir = Path(outdir) + jax_problem.save(outdir) + jax_problem_loaded = JAXProblem.load(outdir) + assert_allclose( + jax_problem.parameters, jax_problem_loaded.parameters + ) diff --git a/tests/benchmark-models/test_petab_benchmark.py b/tests/benchmark-models/test_petab_benchmark.py index 7a0afc6832..2c56089409 100644 --- a/tests/benchmark-models/test_petab_benchmark.py +++ b/tests/benchmark-models/test_petab_benchmark.py @@ -338,12 +338,6 @@ def test_jax_llh(benchmark_problem): jax=True, ) jax_problem = JAXProblem(jax_model, petab_problem) - simulation_conditions = ( - petab_problem.get_simulation_conditions_from_measurement_df() - ) - simulation_conditions = tuple( - tuple(row) for _, row in simulation_conditions.iterrows() - ) if problem_parameters: jax_problem = eqx.tree_at( lambda x: x.parameters, @@ -355,11 +349,9 @@ def test_jax_llh(benchmark_problem): if problem_id in problems_for_gradient_check_jax: (llh_jax, _), sllh_jax = eqx.filter_jit( eqx.filter_value_and_grad(run_simulations, has_aux=True) - )(jax_problem, simulation_conditions) + )(jax_problem) else: - llh_jax, _ = beartype(eqx.filter_jit(run_simulations))( - jax_problem, simulation_conditions - ) + llh_jax, _ = beartype(eqx.filter_jit(run_simulations))(jax_problem) np.testing.assert_allclose( llh_jax, From 9fd5835e0accc9c44b183f520528e59bebbd2172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 28 Nov 2024 20:49:55 +0000 Subject: [PATCH 02/92] doc --- python/sdist/amici/jax/petab.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index fc74a9f50f..2c823259fe 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -88,7 +88,7 @@ def __init__(self, model: JAXModel, petab_problem: petab.Problem): def save(self, directory: Path): """ - Save the problem to a file. + Save the problem to a directory. :param directory: Directory to save the problem to. @@ -109,7 +109,7 @@ def save(self, directory: Path): @classmethod def load(cls, directory: Path): """ - Load a problem from a file. + Load a problem from a directory. :param directory: Directory to load the problem from. From 862586db6aba4015d674c78c265a7a4092010507 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 28 Nov 2024 21:04:43 +0000 Subject: [PATCH 03/92] no compilation for jax --- python/sdist/amici/petab/petab_import.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/python/sdist/amici/petab/petab_import.py b/python/sdist/amici/petab/petab_import.py index 42a4d85dc4..87ec3fbfec 100644 --- a/python/sdist/amici/petab/petab_import.py +++ b/python/sdist/amici/petab/petab_import.py @@ -66,7 +66,8 @@ def import_petab_problem( parameters are required, this should be set to ``False``. :param jax: - Whether to load the jax version of the model. + Whether to load the jax version of the model. Note that this disables + compilation of the model module unless `compile` is set to `True`. :param kwargs: Additional keyword arguments to be passed to @@ -145,6 +146,7 @@ def import_petab_problem( petab_problem, model_name=model_name, model_output_dir=model_output_dir, + compile=kwargs.pop("compile", not jax), **kwargs, ) else: @@ -153,14 +155,19 @@ def import_petab_problem( model_name=model_name, model_output_dir=model_output_dir, non_estimated_parameters_as_constants=non_estimated_parameters_as_constants, + compile=kwargs.pop("compile", not jax), **kwargs, ) # import model - model_module = amici.import_model_module(model_name, model_output_dir) + if not jax: + model_module = amici.import_model_module(model_name, model_output_dir) - if jax: - model = model_module.get_jax_model() + else: + jax_model_module = amici._module_from_path( + "jax", Path(model_output_dir) / model_name / "jax.py" + ) + model = jax_model_module.Model() logger.info( f"Successfully loaded jax model {model_name} " From 674c48101cc8aecd77a7729cc0974301f98dd01d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 28 Nov 2024 21:09:09 +0000 Subject: [PATCH 04/92] bad ruff --- python/sdist/amici/jax.template.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python/sdist/amici/jax.template.py b/python/sdist/amici/jax.template.py index eda47b4f09..9b566281ca 100644 --- a/python/sdist/amici/jax.template.py +++ b/python/sdist/amici/jax.template.py @@ -1,9 +1,10 @@ +# ruff: noqa: F401, F821, F841 +import jax.numpy as jnp +from interpax import interp1d from pathlib import Path from amici.jax.model import JAXModel -# ruff: noqa: F821, F841 - class JAXModel_TPL_MODEL_NAME(JAXModel): api_version = TPL_MODEL_API_VERSION From 3e7d453ce94da3b2a1d890d50a38a59e2347c557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 28 Nov 2024 21:43:03 +0000 Subject: [PATCH 05/92] Update ExampleJaxPEtab.ipynb --- python/examples/example_jax_petab/ExampleJaxPEtab.ipynb | 2 -- 1 file changed, 2 deletions(-) diff --git a/python/examples/example_jax_petab/ExampleJaxPEtab.ipynb b/python/examples/example_jax_petab/ExampleJaxPEtab.ipynb index 10369f74b0..89a47e8ed3 100644 --- a/python/examples/example_jax_petab/ExampleJaxPEtab.ipynb +++ b/python/examples/example_jax_petab/ExampleJaxPEtab.ipynb @@ -52,7 +52,6 @@ "# Import the PEtab problem as a JAX-compatible AMICI model\n", "jax_model = import_petab_problem(\n", " petab_problem,\n", - " compile_=True, # do not compile regular amici model\n", " verbose=False, # no text output\n", " jax=True, # return jax model\n", ")" @@ -978,7 +977,6 @@ "# Import the PEtab problem as a standard AMICI model\n", "amici_model = import_petab_problem(\n", " petab_problem,\n", - " compile_=False, # do not recompile\n", " verbose=False,\n", " jax=False, # load the amici model this time\n", ")\n", From b2a95b13399a05296258c4a4daa5b03e2d9f8877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 28 Nov 2024 21:45:13 +0000 Subject: [PATCH 06/92] bad ruff --- python/tests/test_jax.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python/tests/test_jax.py b/python/tests/test_jax.py index 3055d77983..30e205ca26 100644 --- a/python/tests/test_jax.py +++ b/python/tests/test_jax.py @@ -17,6 +17,7 @@ from amici.petab.petab_import import import_petab_problem from amici.jax import JAXProblem from numpy.testing import assert_allclose +from test_petab_objective import lotka_volterra # noqa: F401 pysb = pytest.importorskip("pysb") @@ -229,7 +230,7 @@ def check_fields_jax( @skip_on_valgrind -def test_serialisation(lotka_volterra): +def test_serialisation(lotka_volterra): # noqa: F811 petab_problem = lotka_volterra with TemporaryDirectoryWinSafe( prefix=petab_problem.model.model_id From 8b713e6a516cb29c56d82687bbdb4f3db6c0027d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 28 Nov 2024 22:18:24 +0000 Subject: [PATCH 07/92] Update ExampleJaxPEtab.ipynb --- .../example_jax_petab/ExampleJaxPEtab.ipynb | 753 ++++-------------- 1 file changed, 133 insertions(+), 620 deletions(-) diff --git a/python/examples/example_jax_petab/ExampleJaxPEtab.ipynb b/python/examples/example_jax_petab/ExampleJaxPEtab.ipynb index 89a47e8ed3..855860e242 100644 --- a/python/examples/example_jax_petab/ExampleJaxPEtab.ipynb +++ b/python/examples/example_jax_petab/ExampleJaxPEtab.ipynb @@ -25,16 +25,10 @@ ] }, { + "metadata": {}, "cell_type": "code", - "execution_count": 1, - "id": "6ada3fb8", - "metadata": { - "ExecuteTime": { - "end_time": "2024-11-19T09:50:53.712145Z", - "start_time": "2024-11-19T09:50:47.191184Z" - } - }, "outputs": [], + "execution_count": null, "source": [ "from amici.petab.petab_import import import_petab_problem\n", "import petab.v1 as petab\n", @@ -55,29 +49,24 @@ " verbose=False, # no text output\n", " jax=True, # return jax model\n", ")" - ] + ], + "id": "c71c96da0da3144a" }, { - "cell_type": "markdown", - "id": "5258566d99c89ba4", "metadata": {}, + "cell_type": "markdown", "source": [ "## Simulation\n", "\n", "In principle, we can already use this model for simulation using the [simulate_condition](https://amici.readthedocs.io/en/latest/generated/amici.jax.html#amici.jax.JAXModel.simulate_condition) method. However, this approach can be cumbersome as timepoints, data etc. need to be specified manually. Instead, we process the PEtab problem into a [JAXProblem](https://amici.readthedocs.io/en/latest/generated/amici.jax.html#amici.jax.JAXProblem), which enables efficient simulation using [amici.jax.run_simulations]((https://amici.readthedocs.io/en/latest/generated/amici.jax.html#amici.jax.run_simulations)." - ] + ], + "id": "7e0f1c27bd71ee1f" }, { + "metadata": {}, "cell_type": "code", - "execution_count": 2, - "id": "76c1331372cd51b4", - "metadata": { - "ExecuteTime": { - "end_time": "2024-11-19T09:50:56.042924Z", - "start_time": "2024-11-19T09:50:53.718372Z" - } - }, "outputs": [], + "execution_count": null, "source": [ "from amici.jax import JAXProblem, run_simulations\n", "\n", @@ -86,294 +75,44 @@ "\n", "# Run simulations and compute the log-likelihood\n", "llh, results = run_simulations(jax_problem)" - ] + ], + "id": "ccecc9a29acc7b73" }, { - "cell_type": "markdown", - "id": "5f8684d76368bd76", "metadata": {}, - "source": "This simulates the model for all conditions using the nominal parameter values. Simple, right? Now, let’s take a look at the simulation results." + "cell_type": "markdown", + "source": "This simulates the model for all conditions using the nominal parameter values. Simple, right? Now, let’s take a look at the simulation results.", + "id": "415962751301c64a" }, { + "metadata": {}, "cell_type": "code", - "execution_count": 3, - "id": "2fc284bd3bfb3a62", - "metadata": { - "ExecuteTime": { - "end_time": "2024-11-19T09:50:56.141898Z", - "start_time": "2024-11-19T09:50:56.134945Z" - }, - "scrolled": true - }, - "outputs": [ - { - "data": { - "text/plain": [ - "(Array(nan, dtype=float32),\n", - " {'stats_dyn': {'max_steps': 1024,\n", - " 'num_accepted_steps': Array(778, dtype=int32, weak_type=True),\n", - " 'num_rejected_steps': Array(246, dtype=int32, weak_type=True),\n", - " 'num_steps': Array(1024, dtype=int32, weak_type=True)},\n", - " 'stats_posteq': None,\n", - " 'stats_preeq': None,\n", - " 'ts': Array([ 0. , 0. , 0. , 2.5, 2.5, 2.5, 5. , 5. , 5. ,\n", - " 10. , 10. , 10. , 15. , 15. , 15. , 20. , 20. , 20. ,\n", - " 30. , 30. , 30. , 40. , 40. , 40. , 50. , 50. , 50. ,\n", - " 60. , 60. , 60. , 80. , 80. , 80. , 100. , 100. , 100. ,\n", - " 120. , 120. , 120. , 160. , 160. , 160. , 200. , 200. , 200. ,\n", - " 240. , 240. , 240. ], dtype=float32),\n", - " 'x': Array([[143.8668, 63.7332, 0. , 0. , 0. , 0. ,\n", - " 0. , 0. ],\n", - " [143.8668, 63.7332, 0. , 0. , 0. , 0. ,\n", - " 0. , 0. ],\n", - " [143.8668, 63.7332, 0. , 0. , 0. , 0. ,\n", - " 0. , 0. ],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf],\n", - " [ inf, inf, inf, inf, inf, inf,\n", - " inf, inf]], dtype=float32)})" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], + "execution_count": null, "source": [ "# Define the simulation condition\n", "simulation_condition = (\"model1_data1\",)\n", "\n", "# Access the results for the specified condition\n", "results[simulation_condition]" - ] + ], + "id": "596b86e45e18fe3d" }, { - "cell_type": "markdown", - "id": "aa46125e508d38d3", "metadata": {}, + "cell_type": "markdown", "source": [ "Unfortunately, the simulation failed! As seen in the output, the simulation broke down after the initial timepoint, indicated by the `inf` values in the state variables `results[simulation_condition][1].x` and the `nan` likelihood value. A closer inspection of this variable provides additional clues about what might have gone wrong.\n", "\n", "The issue stems from using single precision, as indicated by the `float32` dtype of state variables. Single precision is generally a [bad idea](https://docs.kidger.site/diffrax/examples/stiff_ode/) for stiff systems like the Böhm model. Let’s retry the simulation with double precision." - ] + ], + "id": "a1b173e013f9210a" }, { + "metadata": {}, "cell_type": "code", - "execution_count": 4, - "id": "8e5006774534ba3a", - "metadata": { - "ExecuteTime": { - "end_time": "2024-11-19T09:50:58.227222Z", - "start_time": "2024-11-19T09:50:56.235939Z" - }, - "scrolled": true - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{('model1_data1',): (Array(-138.22199834, dtype=float64),\n", - " {'stats_dyn': {'max_steps': 1024,\n", - " 'num_accepted_steps': Array(125, dtype=int64, weak_type=True),\n", - " 'num_rejected_steps': Array(7, dtype=int64, weak_type=True),\n", - " 'num_steps': Array(132, dtype=int64, weak_type=True)},\n", - " 'stats_posteq': None,\n", - " 'stats_preeq': None,\n", - " 'ts': Array([ 0. , 0. , 0. , 2.5, 2.5, 2.5, 5. , 5. , 5. ,\n", - " 10. , 10. , 10. , 15. , 15. , 15. , 20. , 20. , 20. ,\n", - " 30. , 30. , 30. , 40. , 40. , 40. , 50. , 50. , 50. ,\n", - " 60. , 60. , 60. , 80. , 80. , 80. , 100. , 100. , 100. ,\n", - " 120. , 120. , 120. , 160. , 160. , 160. , 200. , 200. , 200. ,\n", - " 240. , 240. , 240. ], dtype=float64),\n", - " 'x': Array([[1.43866806e+02, 6.37332001e+01, 0.00000000e+00, 0.00000000e+00,\n", - " 0.00000000e+00, 0.00000000e+00, 0.00000000e+00, 0.00000000e+00],\n", - " [1.43866806e+02, 6.37332001e+01, 0.00000000e+00, 0.00000000e+00,\n", - " 0.00000000e+00, 0.00000000e+00, 0.00000000e+00, 0.00000000e+00],\n", - " [1.43866806e+02, 6.37332001e+01, 0.00000000e+00, 0.00000000e+00,\n", - " 0.00000000e+00, 0.00000000e+00, 0.00000000e+00, 0.00000000e+00],\n", - " [5.34614747e+01, 2.88662915e+01, 1.73038463e+01, 5.38666098e-05,\n", - " 1.57043241e-05, 1.12989551e+02, 1.44740461e+00, 2.65965680e+01],\n", - " [5.34614747e+01, 2.88662915e+01, 1.73038463e+01, 5.38666098e-05,\n", - " 1.57043241e-05, 1.12989551e+02, 1.44740461e+00, 2.65965680e+01],\n", - " [5.34614747e+01, 2.88662915e+01, 1.73038463e+01, 5.38666098e-05,\n", - " 1.57043241e-05, 1.12989551e+02, 1.44740461e+00, 2.65965680e+01],\n", - " [3.40645243e+01, 1.96396741e+01, 2.10101056e+01, 2.04431389e-05,\n", - " 6.79533169e-06, 1.36155797e+02, 3.93060446e+00, 3.39422194e+01],\n", - " [3.40645243e+01, 1.96396741e+01, 2.10101056e+01, 2.04431389e-05,\n", - " 6.79533169e-06, 1.36155797e+02, 3.93060446e+00, 3.39422194e+01],\n", - " [3.40645243e+01, 1.96396741e+01, 2.10101056e+01, 2.04431389e-05,\n", - " 6.79533169e-06, 1.36155797e+02, 3.93060446e+00, 3.39422194e+01],\n", - " [2.17740069e+01, 1.28936829e+01, 2.26400305e+01, 7.29828626e-06,\n", - " 2.55916689e-06, 1.49922977e+02, 9.56261350e+00, 3.90845534e+01],\n", - " [2.17740069e+01, 1.28936829e+01, 2.26400305e+01, 7.29828626e-06,\n", - " 2.55916689e-06, 1.49922977e+02, 9.56261350e+00, 3.90845534e+01],\n", - " [2.17740069e+01, 1.28936829e+01, 2.26400305e+01, 7.29828626e-06,\n", - " 2.55916689e-06, 1.49922977e+02, 9.56261350e+00, 3.90845534e+01],\n", - " [1.78289538e+01, 1.02603483e+01, 2.23703281e+01, 4.27571773e-06,\n", - " 1.41605997e-06, 1.53605377e+02, 1.53104054e+01, 4.07264964e+01],\n", - " [1.78289538e+01, 1.02603483e+01, 2.23703281e+01, 4.27571773e-06,\n", - " 1.41605997e-06, 1.53605377e+02, 1.53104054e+01, 4.07264964e+01],\n", - " [1.78289538e+01, 1.02603483e+01, 2.23703281e+01, 4.27571773e-06,\n", - " 1.41605997e-06, 1.53605377e+02, 1.53104054e+01, 4.07264964e+01],\n", - " [1.63397301e+01, 8.95194886e+00, 2.15687556e+01, 3.13802765e-06,\n", - " 9.41897178e-07, 1.54369347e+02, 2.09093940e+01, 4.12091821e+01],\n", - " [1.63397301e+01, 8.95194886e+00, 2.15687556e+01, 3.13802765e-06,\n", - " 9.41897178e-07, 1.54369347e+02, 2.09093940e+01, 4.12091821e+01],\n", - " [1.63397301e+01, 8.95194886e+00, 2.15687556e+01, 3.13802765e-06,\n", - " 9.41897178e-07, 1.54369347e+02, 2.09093940e+01, 4.12091821e+01],\n", - " [1.59598663e+01, 7.84978463e+00, 1.95400559e+01, 2.28580865e-06,\n", - " 5.52965361e-07, 1.52878988e+02, 3.13834269e+01, 4.08423997e+01],\n", - " [1.59598663e+01, 7.84978463e+00, 1.95400559e+01, 2.28580865e-06,\n", - " 5.52965361e-07, 1.52878988e+02, 3.13834269e+01, 4.08423997e+01],\n", - " [1.59598663e+01, 7.84978463e+00, 1.95400559e+01, 2.28580865e-06,\n", - " 5.52965361e-07, 1.52878988e+02, 3.13834269e+01, 4.08423997e+01],\n", - " [1.68960409e+01, 7.57954992e+00, 1.74766781e+01, 1.95598628e-06,\n", - " 3.93623013e-07, 1.49923893e+02, 4.08004734e+01, 3.97639408e+01],\n", - " [1.68960409e+01, 7.57954992e+00, 1.74766781e+01, 1.95598628e-06,\n", - " 3.93623013e-07, 1.49923893e+02, 4.08004734e+01, 3.97639408e+01],\n", - " [1.68960409e+01, 7.57954992e+00, 1.74766781e+01, 1.95598628e-06,\n", - " 3.93623013e-07, 1.49923893e+02, 4.08004734e+01, 3.97639408e+01],\n", - " [1.83667585e+01, 7.66955396e+00, 1.55594015e+01, 1.76473276e-06,\n", - " 3.07719966e-07, 1.46418868e+02, 4.91998176e+01, 3.84066930e+01],\n", - " [1.83667585e+01, 7.66955396e+00, 1.55594015e+01, 1.76473276e-06,\n", - " 3.07719966e-07, 1.46418868e+02, 4.91998176e+01, 3.84066930e+01],\n", - " [1.83667585e+01, 7.66955396e+00, 1.55594015e+01, 1.76473276e-06,\n", - " 3.07719966e-07, 1.46418868e+02, 4.91998176e+01, 3.84066930e+01],\n", - " [2.01288255e+01, 7.95104827e+00, 1.38272785e+01, 1.61833093e-06,\n", - " 2.52512177e-07, 1.42637837e+02, 5.66687226e+01, 3.69287741e+01],\n", - " [2.01288255e+01, 7.95104827e+00, 1.38272785e+01, 1.61833093e-06,\n", - " 2.52512177e-07, 1.42637837e+02, 5.66687226e+01, 3.69287741e+01],\n", - " [2.01288255e+01, 7.95104827e+00, 1.38272785e+01, 1.61833093e-06,\n", - " 2.52512177e-07, 1.42637837e+02, 5.66687226e+01, 3.69287741e+01],\n", - " [2.42069672e+01, 8.82343809e+00, 1.09015504e+01, 1.36440625e-06,\n", - " 1.81275253e-07, 1.34584160e+02, 6.91907904e+01, 3.38618223e+01],\n", - " [2.42069672e+01, 8.82343809e+00, 1.09015504e+01, 1.36440625e-06,\n", - " 1.81275253e-07, 1.34584160e+02, 6.91907904e+01, 3.38618223e+01],\n", - " [2.42069672e+01, 8.82343809e+00, 1.09015504e+01, 1.36440625e-06,\n", - " 1.81275253e-07, 1.34584160e+02, 6.91907904e+01, 3.38618223e+01],\n", - " [2.88236929e+01, 9.92100237e+00, 8.58815552e+00, 1.12770626e-06,\n", - " 1.33599425e-07, 1.26069389e+02, 7.90544164e+01, 3.08213014e+01],\n", - " [2.88236929e+01, 9.92100237e+00, 8.58815552e+00, 1.12770626e-06,\n", - " 1.33599425e-07, 1.26069389e+02, 7.90544164e+01, 3.08213014e+01],\n", - " [2.88236929e+01, 9.92100237e+00, 8.58815552e+00, 1.12770626e-06,\n", - " 1.33599425e-07, 1.26069389e+02, 7.90544164e+01, 3.08213014e+01],\n", - " [3.38427746e+01, 1.11365012e+01, 6.75633027e+00, 9.06279023e-07,\n", - " 9.81352036e-08, 1.17230823e+02, 8.68156402e+01, 2.78994196e+01],\n", - " [3.38427746e+01, 1.11365012e+01, 6.75633027e+00, 9.06279023e-07,\n", - " 9.81352036e-08, 1.17230823e+02, 8.68156402e+01, 2.78994196e+01],\n", - " [3.38427746e+01, 1.11365012e+01, 6.75633027e+00, 9.06279023e-07,\n", - " 9.81352036e-08, 1.17230823e+02, 8.68156402e+01, 2.78994196e+01],\n", - " [4.45767678e+01, 1.36929100e+01, 4.13936161e+00, 5.34332520e-07,\n", - " 5.04178629e-08, 9.91750041e+01, 9.76743159e+01, 2.25642862e+01],\n", - " [4.45767678e+01, 1.36929100e+01, 4.13936161e+00, 5.34332520e-07,\n", - " 5.04178629e-08, 9.91750041e+01, 9.76743159e+01, 2.25642862e+01],\n", - " [4.45767678e+01, 1.36929100e+01, 4.13936161e+00, 5.34332520e-07,\n", - " 5.04178629e-08, 9.91750041e+01, 9.76743159e+01, 2.25642862e+01],\n", - " [5.53512751e+01, 1.61684905e+01, 2.47997315e+00, 2.79973425e-07,\n", - " 2.38894456e-08, 8.17101310e+01, 1.04245916e+02, 1.80088542e+01],\n", - " [5.53512751e+01, 1.61684905e+01, 2.47997315e+00, 2.79973425e-07,\n", - " 2.38894456e-08, 8.17101310e+01, 1.04245916e+02, 1.80088542e+01],\n", - " [5.53512751e+01, 1.61684905e+01, 2.47997315e+00, 2.79973425e-07,\n", - " 2.38894456e-08, 8.17101310e+01, 1.04245916e+02, 1.80088542e+01],\n", - " [6.52754860e+01, 1.83796881e+01, 1.44531833e+00, 1.32320205e-07,\n", - " 1.04906457e-08, 6.59469727e+01, 1.08115837e+02, 1.42437160e+01],\n", - " [6.52754860e+01, 1.83796881e+01, 1.44531833e+00, 1.32320205e-07,\n", - " 1.04906457e-08, 6.59469727e+01, 1.08115837e+02, 1.42437160e+01],\n", - " [6.52754860e+01, 1.83796881e+01, 1.44531833e+00, 1.32320205e-07,\n", - " 1.04906457e-08, 6.59469727e+01, 1.08115837e+02, 1.42437160e+01]], dtype=float64)})}" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], + "execution_count": null, "source": [ "import jax\n", "\n", @@ -384,37 +123,20 @@ "llh, results = run_simulations(jax_problem)\n", "\n", "results" - ] + ], + "id": "f4f5ff705a3f7402" }, { - "cell_type": "markdown", - "id": "fea37568206351f7", "metadata": {}, - "source": "Success! The simulation completed successfully, and we can now plot the resulting state trajectories." + "cell_type": "markdown", + "source": "Success! The simulation completed successfully, and we can now plot the resulting state trajectories.", + "id": "fe4d3b40ee3efdf2" }, { + "metadata": {}, "cell_type": "code", - "execution_count": 5, - "id": "95c75d098d3a1822", - "metadata": { - "ExecuteTime": { - "end_time": "2024-11-19T09:50:58.490052Z", - "start_time": "2024-11-19T09:50:58.305876Z" - }, - "scrolled": true - }, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], + "execution_count": null, "source": [ "%matplotlib inline\n", "import matplotlib.pyplot as plt\n", @@ -449,70 +171,41 @@ "\n", "# Plot the simulation results\n", "plot_simulation(results)" - ] + ], + "id": "72f1ed397105e14a" }, { - "cell_type": "markdown", - "id": "f57c07211b781ab5", "metadata": {}, - "source": "`run_simulations` enables users to specify the simulation conditions to be executed. For more complex models, this allows for restricting simulations to a subset of conditions. Since the Böhm model includes only a single condition, we demonstrate this functionality by simulating no condition at all." + "cell_type": "markdown", + "source": "`run_simulations` enables users to specify the simulation conditions to be executed. For more complex models, this allows for restricting simulations to a subset of conditions. Since the Böhm model includes only a single condition, we demonstrate this functionality by simulating no condition at all.", + "id": "4fa97c33719c2277" }, { + "metadata": {}, "cell_type": "code", - "execution_count": 6, - "id": "2f2e1c7023ad261b", - "metadata": { - "ExecuteTime": { - "end_time": "2024-11-19T09:50:58.505973Z", - "start_time": "2024-11-19T09:50:58.501775Z" - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{}" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], + "execution_count": null, "source": [ "llh, results = run_simulations(jax_problem, simulation_conditions=tuple())\n", "results" - ] + ], + "id": "7950774a3e989042" }, { - "cell_type": "markdown", - "id": "0b729e1b-3c75-4a87-a33b-0a54622609e7", "metadata": {}, + "cell_type": "markdown", "source": [ "## Updating Parameters\n", "\n", "As next step, we will update the parameter values used for simulation. However, if we attempt to directly modify the values in `JAXModel.parameters`, we encounter a `FrozenInstanceError`." - ] + ], + "id": "98b8516a75ce4d12" }, { + "metadata": {}, "cell_type": "code", - "execution_count": 7, - "id": "75df1ab9e8a738a0", - "metadata": { - "ExecuteTime": { - "end_time": "2024-11-19T09:50:58.685750Z", - "start_time": "2024-11-19T09:50:58.575034Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Error: cannot assign to field 'parameters'\n" - ] - } - ], + "outputs": [], + "execution_count": null, "source": [ "from dataclasses import FrozenInstanceError\n", "import jax\n", @@ -530,40 +223,24 @@ " jax_problem.parameters += noise\n", "except FrozenInstanceError as e:\n", " print(\"Error:\", e)" - ] + ], + "id": "3d278a3d21e709d" }, { - "cell_type": "markdown", - "id": "b91941cf707704c3", "metadata": {}, + "cell_type": "markdown", "source": [ "The root cause of this error lies in the fact that, to enable autodiff, direct modifications of attributes are not allowed in [equinox](https://docs.kidger.site/equinox/), which AMICI utilizes under the hood. Consequently, attributes of instances like `JAXModel` or `JAXProblem` cannot be updated directly — this is the price we have to pay for autodiff.\n", "\n", "However, `JAXProblem` provides a convenient method called [update_parameters](https://amici.readthedocs.io/en/latest/generated/amici.jax.html#amici.jax.JAXProblem.update_parameters). The caveat is that this method creates a new JAXProblem instance instead of modifying the existing one." - ] + ], + "id": "4cc3d595de4a4085" }, { + "metadata": {}, "cell_type": "code", - "execution_count": 8, - "id": "feb125b6-4f84-427c-b870-421a328eee81", - "metadata": { - "ExecuteTime": { - "end_time": "2024-11-19T09:51:00.631866Z", - "start_time": "2024-11-19T09:50:58.702698Z" - } - }, - "outputs": [ - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAsAAAAIjCAYAAAAN/63DAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAA9hAAAPYQGoP6dpAADxiUlEQVR4nOzdd1hUZ/bA8e9UGLo0AQVERUQIdrGDUWNJTEzMGk1MNBpNsmvKmmjKxlg21fxck5ieYElvm7gx1S5GBcWuKChiB1Fp0qfc3x8Do8QGCgzlfJ6HR+bOnXvPzHXg8M55z6tSFEVBCCGEEEKIJkJt7wCEEEIIIYSoS5IACyGEEEKIJkUSYCGEEEII0aRIAiyEEEIIIZoUSYCFEEIIIUSTIgmwEEIIIYRoUiQBFkIIIYQQTYokwEIIIYQQokmRBFgIIYQQQjQpkgALIRq8efPm0b59eywWi71DuawjR46gUqlYsmRJtR+7bt06VCoV69atq/G4riY2NpbY2Ng6PWdt6tmzJzNmzLB3GEKIekISYCFEg5afn8/rr7/OM888g1pt/ZF2vclmQ5GSksI///lPevfujaOjIyqViiNHjtg7LACKioqYPXv2DSXsBQUFzJo1i6FDh+Lp6XnV6xkbG8uECROuecxnnnmGd999l8zMzOuOSwjReEgCLIRo0BYtWoTJZGLs2LH2DqXObN68mbfffpvz588THh5u73AqKSoqYs6cOTeUAJ89e5a5c+eyf/9+OnbsWCNx3XHHHbi5ufHee+/VyPGEEA2bJMBCiAZt8eLF3H777Tg6Oto7lDpz++23k5uby549e7jvvvvsHU6N8/f3JyMjg6NHj/LGG2/UyDHVajV33303n376KYqi1MgxhRANlyTAQogGKz09nd27dzNo0KCr7jd79mxUKhWpqamMGzcOd3d3fHx8mDlzJoqicPz4cdsIoZ+fH/Pnz7/kGFlZWUyaNInmzZvj6OhIx44dWbp06SX75ebmMmHCBNzd3fHw8GD8+PHk5uZeNq4DBw5w99134+npiaOjI926deOnn3665vP29PTE1dX1mvtV1UcffUSbNm0wGAz06NGDDRs2XLJPWVkZL774Il27dsXd3R1nZ2f69evH2rVrbfscOXIEHx8fAObMmYNKpUKlUjF79mwAdu/ezYQJE2jdujWOjo74+fkxceJEzp07V+lcDg4O+Pn51djzqzB48GCOHj3Kzp07a/zYQoiGRRJgIUSDtWnTJgC6dOlSpf3vueceLBYLr732GtHR0bz00ku8+eabDB48mBYtWvD666/Ttm1bnn76aeLj422PKy4uJjY2ls8++4z77ruPN954A3d3dyZMmMBbb71l209RFO644w4+++wzxo0bx0svvcSJEycYP378JbHs27ePnj17sn//fp599lnmz5+Ps7MzI0eO5Mcff7zBV6bq4uLiePjhh/Hz82PevHn06dOH22+/nePHj1faLz8/n08++YTY2Fhef/11Zs+ezZkzZxgyZIgtofTx8eH9998H4M477+Szzz7js88+46677gJg5cqVHD58mAcffJCFCxcyZswYvv76a4YPH14no7Jdu3YFYOPGjbV+LiFEPacIIUQD9cILLyiAcv78+avuN2vWLAVQpkyZYttmMpmUli1bKiqVSnnttdds23NychSDwaCMHz/etu3NN99UAOXzzz+3bSsrK1N69eqluLi4KPn5+YqiKMqyZcsUQJk3b16l8/Tr108BlMWLF9u2Dxw4ULnpppuUkpIS2zaLxaL07t1bCQ0NtW1bu3atAihr16697HN74403FEBJT0+/6mtwOWVlZYqvr6/SqVMnpbS01Lb9o48+UgAlJiam0vO4eB9Fsb5WzZs3VyZOnGjbdubMGQVQZs2adcn5ioqKLtn21VdfKYASHx9/2Ri3bt16yWt3I/R6vfLoo4/WyLGEEA2XjAALIRqsc+fOodVqcXFxqdL+Dz30kO17jUZDt27dUBSFSZMm2bZ7eHgQFhbG4cOHbdt+/fVX/Pz8Kk200+l0PP744xQUFLB+/XrbflqtlkcffbTSeR577LFKcWRnZ7NmzRpGjx7N+fPnOXv2LGfPnuXcuXMMGTKEgwcPcvLkyeq9GNchKSmJrKwsHnnkEfR6vW17RQnHxTQajW0fi8VCdnY2JpOJbt26sX379iqdz2Aw2L4vKSnh7Nmz9OzZE6DKx7hRzZo14+zZs3VyLiFE/aW1dwBCCFFXgoKCKt12d3fH0dERb2/vS7ZfXJd69OhRQkNDbW3WKlR0YDh69KjtX39//0sS8rCwsEq3Dx06hKIozJw5k5kzZ1421qysLFq0aFGNZ1d9FXGHhoZW2q7T6WjduvUl+y9dupT58+dz4MABjEajbXtISEiVzpednc2cOXP4+uuvycrKqnRfXl5edcO/LoqioFKp6uRcQoj6SxJgIUSD5eXlhclk4vz581WaFKbRaKq0DajVmtSKBTuefvpphgwZctl92rZtW2vnvx6ff/45EyZMYOTIkUyfPh1fX180Gg2vvvoqaWlpVTrG6NGj2bRpE9OnT6dTp064uLhgsVgYOnRonS1ikpube8kfPEKIpkcSYCFEg9W+fXvA2g0iKiqq1s4THBzM7t27sVgslUaBDxw4YLu/4t/Vq1dTUFBQaRQ4JSWl0vEqRld1Ot01O1jUpoq4Dx48yM0332zbbjQaSU9Pr9SD9/vvv6d169b88MMPlUZQZ82aVemYVxpdzcnJYfXq1cyZM4cXX3zRtv3gwYM18lyq4uTJk5SVldW73slCiLonNcBCiAarV69egLWWtTYNHz6czMxMvvnmG9s2k8nEwoULcXFxISYmxrafyWSydUIAMJvNLFy4sNLxfH19iY2N5cMPPyQjI+OS8505c6aWnkll3bp1w8fHhw8++ICysjLb9iVLllzSuq1ipPzikfHExEQ2b95caT8nJyeAKj0e4M0337yRp1At27ZtA6B37951dk4hRP0kI8BCiAardevWREZGsmrVKiZOnFhr55kyZQoffvghEyZMYNu2bbRq1Yrvv/+ejRs38uabb9rKL0aMGEGfPn149tlnOXLkCB06dOCHH364bH3ru+++S9++fbnpppuYPHkyrVu35vTp02zevJkTJ06wa9euK8aTl5dnS6orWnq98847eHh44OHhwdSpU6v0vHQ6HS+99BIPP/wwN998M/fccw/p6eksXrz4khrg2267jR9++IE777yTW2+9lfT0dD744AM6dOhAQUGBbT+DwUCHDh345ptvaNeuHZ6enkRGRhIZGUn//v2ZN28eRqORFi1asGLFCtLT0y8b2zvvvENubi6nTp0CYPny5Zw4cQKAxx577JJJeheLjY1l/fr1lyTbK1euJCgoiM6dO1fp9RFCNGJ27EAhhBA37D//+Y/i4uJy2RZbFSraoJ05c6bS9vHjxyvOzs6X7B8TE6NERERU2nb69GnlwQcfVLy9vRW9Xq/cdNNNl23Nde7cOeX+++9X3NzcFHd3d+X+++9XduzYcdlWXmlpacoDDzyg+Pn5KTqdTmnRooVy2223Kd9//71tn8u1QUtPT1eAy34FBwdf+cW6gvfe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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], + "execution_count": null, "source": [ "# Update the parameters and create a new JAXProblem instance\n", "jax_problem = jax_problem.update_parameters(jax_problem.parameters + noise)\n", @@ -573,221 +250,105 @@ "\n", "# Plot the simulation results\n", "plot_simulation(results)" - ] + ], + "id": "e47748376059628b" }, { - "cell_type": "markdown", - "id": "e73bdd447a4d48c8", "metadata": {}, + "cell_type": "markdown", "source": [ "## Computing Gradients\n", "\n", "Similar to updating attributes, computing gradients in the JAX ecosystem can feel a bit unconventional if you’re not familiar with the JAX ecosysmt. JAX offers [powerful automatic differentiation](https://jax.readthedocs.io/en/latest/automatic-differentiation.html) through the `jax.grad` function. However, to use `jax.grad` with `JAXProblem`, we need to specify which parts of the `JAXProblem` should be treated as static." - ] + ], + "id": "660baf605a4e8339" }, { + "metadata": {}, "cell_type": "code", - "execution_count": 9, - "id": "a8918f59607e6525", - "metadata": { - "ExecuteTime": { - "end_time": "2024-11-19T09:51:00.662578Z", - "start_time": "2024-11-19T09:51:00.649386Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Error: Argument 'ParameterMappingForCondition(map_sim_var={'Epo_degradation_BaF3': 'Epo_degradation_BaF3', 'k_exp_hetero': 'k_exp_hetero', 'k_exp_homo': 'k_exp_homo', 'k_imp_hetero': 'k_imp_hetero', 'k_imp_homo': 'k_imp_homo', 'k_phos': 'k_phos', 'ratio': 0.693, 'specC17': 0.107, 'noiseParameter1_pSTAT5A_rel': 'sd_pSTAT5A_rel', 'noiseParameter1_pSTAT5B_rel': 'sd_pSTAT5B_rel', 'noiseParameter1_rSTAT5A_rel': 'sd_rSTAT5A_rel'},scale_map_sim_var={'Epo_degradation_BaF3': 'log10', 'k_exp_hetero': 'log10', 'k_exp_homo': 'log10', 'k_imp_hetero': 'log10', 'k_imp_homo': 'log10', 'k_phos': 'log10', 'ratio': 'lin', 'specC17': 'lin', 'noiseParameter1_pSTAT5A_rel': 'log10', 'noiseParameter1_pSTAT5B_rel': 'log10', 'noiseParameter1_rSTAT5A_rel': 'log10'},map_preeq_fix={},scale_map_preeq_fix={},map_sim_fix={},scale_map_sim_fix={})' of type is not a valid JAX type.\n" - ] - } - ], + "outputs": [], + "execution_count": null, "source": [ "try:\n", " # Attempt to compute the gradient of the run_simulations function\n", " jax.grad(run_simulations, has_aux=True)(jax_problem)\n", "except TypeError as e:\n", " print(\"Error:\", e)" - ] + ], + "id": "7033d09cc81b7f69" }, { - "cell_type": "markdown", - "id": "922a9ffd94c99607", "metadata": {}, - "source": "Fortunately, `equinox` simplifies this process by offering [filter_grad](https://docs.kidger.site/equinox/api/transformations/#equinox.filter_grad), which enables autodiff functionality that is compatible with `JAXProblem` and, in theory, also with `JAXModel`." + "cell_type": "markdown", + "source": "Fortunately, `equinox` simplifies this process by offering [filter_grad](https://docs.kidger.site/equinox/api/transformations/#equinox.filter_grad), which enables autodiff functionality that is compatible with `JAXProblem` and, in theory, also with `JAXModel`.", + "id": "dc9bc07cde00a926" }, { + "metadata": {}, "cell_type": "code", - "execution_count": 10, - "id": "e2c635b6-79db-4e78-8738-789af29110b5", - "metadata": { - "ExecuteTime": { - "end_time": "2024-11-19T09:51:07.293314Z", - "start_time": "2024-11-19T09:51:00.709141Z" - } - }, "outputs": [], + "execution_count": null, "source": [ "import equinox as eqx\n", "\n", "# Compute the gradient using equinox's filter_grad, preserving auxiliary outputs\n", "grad, _ = eqx.filter_grad(run_simulations, has_aux=True)(jax_problem)" - ] + ], + "id": "a6704182200e6438" }, { - "cell_type": "markdown", - "id": "8fd639ad39948e72", "metadata": {}, - "source": "Functions transformed by `filter_grad` return gradients that share the same structure as the first argument (unless specified otherwise). This allows us to access the gradient with respect to the parameters attribute directly `via grad.parameters`." + "cell_type": "markdown", + "source": "Functions transformed by `filter_grad` return gradients that share the same structure as the first argument (unless specified otherwise). This allows us to access the gradient with respect to the parameters attribute directly `via grad.parameters`.", + "id": "851c3ec94cb5d086" }, { + "metadata": {}, "cell_type": "code", - "execution_count": 11, - "id": "ab9225bf704e9ed5", - "metadata": { - "ExecuteTime": { - "end_time": "2024-11-19T09:51:07.310244Z", - "start_time": "2024-11-19T09:51:07.306293Z" - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "Array([ 2.39759630e+01, -1.36704159e-01, 1.33625245e+01, 3.25229304e+01,\n", - " 4.88660333e-05, 5.39482681e+01, -5.13624151e+00, -2.90885864e-02,\n", - " 6.08639536e+01], dtype=float64)" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "grad.parameters" - ] + "outputs": [], + "execution_count": null, + "source": "grad.parameters", + "id": "c00c1581d7173d7a" }, { - "cell_type": "markdown", - "id": "5793acc4ad8908be", "metadata": {}, - "source": "Attributes for which derivatives cannot be computed (typically anything that is not a [jax.numpy.array](https://jax.readthedocs.io/en/latest/_autosummary/jax.numpy.array.html)) are automatically set to `None`." + "cell_type": "markdown", + "source": "Attributes for which derivatives cannot be computed (typically anything that is not a [jax.numpy.array](https://jax.readthedocs.io/en/latest/_autosummary/jax.numpy.array.html)) are automatically set to `None`.", + "id": "375b835fecc5a022" }, { + "metadata": {}, "cell_type": "code", - "execution_count": 12, - "id": "77e6bc4fa3e6970a", - "metadata": { - "ExecuteTime": { - "end_time": "2024-11-19T09:51:07.398319Z", - "start_time": "2024-11-19T09:51:07.392032Z" - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "JAXProblem(\n", - " parameters=f64[9],\n", - " model=JAXModel_Boehm_JProteomeRes2014(api_version='0.0.1'),\n", - " _parameter_mappings={'model1_data1': None},\n", - " _measurements={('model1_data1',): (f64[3], f64[45], f64[0], f64[48], None)},\n", - " _petab_problem=None\n", - ")" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "grad" - ] + "outputs": [], + "execution_count": null, + "source": "grad", + "id": "f7c17f7459d0151f" }, { - "cell_type": "markdown", - "id": "75fc08817f1b4734", "metadata": {}, - "source": "Observant readers may notice that the gradient above appears to include numeric values for derivatives with respect to some measurements. However, `simulation_conditions` internally disables gradient computations using `jax.lax.stop_gradient`, resulting in these values being zeroed out." + "cell_type": "markdown", + "source": "Observant readers may notice that the gradient above appears to include numeric values for derivatives with respect to some measurements. However, `simulation_conditions` internally disables gradient computations using `jax.lax.stop_gradient`, resulting in these values being zeroed out.", + "id": "8eb7cc3db510c826" }, { + "metadata": {}, "cell_type": "code", - "execution_count": 13, - "id": "a8b7634e-7bd8-41ae-a6dc-1d0f29993ac0", - "metadata": { - "ExecuteTime": { - "end_time": "2024-11-19T09:51:07.455764Z", - "start_time": "2024-11-19T09:51:07.450233Z" - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "(Array([0., 0., 0.], dtype=float64),\n", - " Array([0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,\n", - " 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,\n", - " 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.], dtype=float64),\n", - " Array([], shape=(0,), dtype=float64),\n", - " Array([0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,\n", - " 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,\n", - " 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.], dtype=float64),\n", - " None)" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "grad._measurements[simulation_condition]" - ] + "outputs": [], + "execution_count": null, + "source": "grad._measurements[simulation_condition]", + "id": "3badd4402cf6b8c6" }, { - "cell_type": "markdown", - "id": "3c6c4f2d3a2673a2", "metadata": {}, - "source": "However, we can compute derivatives with respect to data elements using `JAXModel.simulate_condition`. In the example below, we differentiate the observables `y` (specified by passing `y` to the `ret` argument) with respect to the timepoints at which the model outputs are computed after the solving the differential equation. While this might not be particularly practical, it serves as an nice illustration of the power of automatic differentiation." + "cell_type": "markdown", + "source": "However, we can compute derivatives with respect to data elements using `JAXModel.simulate_condition`. In the example below, we differentiate the observables `y` (specified by passing `y` to the `ret` argument) with respect to the timepoints at which the model outputs are computed after the solving the differential equation. While this might not be particularly practical, it serves as an nice illustration of the power of automatic differentiation.", + "id": "58eb04393a1463d" }, { + "metadata": {}, "cell_type": "code", - "execution_count": 14, - "id": "2a843410-4af4-4ff7-8b67-9293a5820caf", - "metadata": { - "ExecuteTime": { - "end_time": "2024-11-19T09:51:13.735937Z", - "start_time": "2024-11-19T09:51:07.494491Z" - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "Array([[ 0.00000000e+00, 0.00000000e+00, 0.00000000e+00, ...,\n", - " 0.00000000e+00, 0.00000000e+00, 0.00000000e+00],\n", - " [ 0.00000000e+00, 0.00000000e+00, 0.00000000e+00, ...,\n", - " 0.00000000e+00, 0.00000000e+00, 0.00000000e+00],\n", - " [ 0.00000000e+00, 0.00000000e+00, 0.00000000e+00, ...,\n", - " 0.00000000e+00, 0.00000000e+00, 0.00000000e+00],\n", - " ...,\n", - " [ 0.00000000e+00, 0.00000000e+00, 0.00000000e+00, ...,\n", - " -1.30871686e-01, 0.00000000e+00, -3.80465095e-11],\n", - " [ 0.00000000e+00, 0.00000000e+00, 0.00000000e+00, ...,\n", - " 0.00000000e+00, -2.69250222e-01, -7.93596886e-11],\n", - " [ 0.00000000e+00, 0.00000000e+00, 0.00000000e+00, ...,\n", - " 0.00000000e+00, 0.00000000e+00, -2.29968854e-02]], dtype=float64)" - ] - }, - "execution_count": 14, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], + "execution_count": null, "source": [ "import jax.numpy as jnp\n", "import diffrax\n", @@ -828,29 +389,24 @@ "# Compute the gradient with respect to `ts_dyn`\n", "g = grad_ts_dyn(ts_dyn)\n", "g" - ] + ], + "id": "1a91aff44b93157" }, { - "cell_type": "markdown", - "id": "a9cec2a77b30669d", "metadata": {}, + "cell_type": "markdown", "source": [ "## Compilation & Profiling\n", "\n", "To maximize performance with JAX, code should be just-in-time (JIT) compiled. This can be achieved using the `jax.jit` or `equinox.filter_jit` decorators. While JIT compilation introduces some overhead during the first function call, it significantly improves performance for subsequent calls. To demonstrate this, we will first clear the JIT cache and then profile the execution." - ] + ], + "id": "9f870da7754e139c" }, { + "metadata": {}, "cell_type": "code", - "execution_count": 15, - "id": "d1f79c45ab2eccdc", - "metadata": { - "ExecuteTime": { - "end_time": "2024-11-19T09:51:14.292251Z", - "start_time": "2024-11-19T09:51:13.834276Z" - } - }, "outputs": [], + "execution_count": null, "source": [ "from time import time\n", "\n", @@ -859,28 +415,14 @@ "\n", "# Define a JIT-compiled gradient function with auxiliary outputs\n", "gradfun = eqx.filter_jit(eqx.filter_grad(run_simulations, has_aux=True))" - ] + ], + "id": "58ebdc110ea7457e" }, { + "metadata": {}, "cell_type": "code", - "execution_count": 16, - "id": "b44881332070e2b0", - "metadata": { - "ExecuteTime": { - "end_time": "2024-11-19T09:51:23.060962Z", - "start_time": "2024-11-19T09:51:14.309832Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Function compilation time: 2.53 seconds\n", - "Gradient compilation time: 6.21 seconds\n" - ] - } - ], + "outputs": [], + "execution_count": null, "source": [ "# Measure the time taken for the first function call (including compilation)\n", "start = time()\n", @@ -891,27 +433,14 @@ "start = time()\n", "gradfun(jax_problem)\n", "print(f\"Gradient compilation time: {time() - start:.2f} seconds\")" - ] + ], + "id": "e1242075f7e0faf" }, { + "metadata": {}, "cell_type": "code", - "execution_count": 17, - "id": "a3e1463209074861", - "metadata": { - "ExecuteTime": { - "end_time": "2024-11-19T09:51:25.374277Z", - "start_time": "2024-11-19T09:51:23.078334Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "16.6 ms ± 609 μs per loop (mean ± std. dev. of 7 runs, 1 loop each)\n" - ] - } - ], + "outputs": [], + "execution_count": null, "source": [ "%%timeit\n", "run_simulations(\n", @@ -924,27 +453,14 @@ " dcoeff=0.0, # recommended value for stiff systems\n", " ),\n", ")" - ] + ], + "id": "27181f367ccb1817" }, { + "metadata": {}, "cell_type": "code", - "execution_count": 18, - "id": "2f074fbbebf834c6", - "metadata": { - "ExecuteTime": { - "end_time": "2024-11-19T09:51:31.394645Z", - "start_time": "2024-11-19T09:51:25.459759Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "39.8 ms ± 854 μs per loop (mean ± std. dev. of 7 runs, 1 loop each)\n" - ] - } - ], + "outputs": [], + "execution_count": null, "source": [ "%%timeit \n", "gradfun(\n", @@ -957,19 +473,14 @@ " dcoeff=0.0, # recommended value for stiff systems\n", " ),\n", ")" - ] + ], + "id": "5b8d3a6162a3ae55" }, { + "metadata": {}, "cell_type": "code", - "execution_count": 19, - "id": "5f68c5fcc16b637", - "metadata": { - "ExecuteTime": { - "end_time": "2024-11-19T09:51:55.244925Z", - "start_time": "2024-11-19T09:51:31.477484Z" - } - }, "outputs": [], + "execution_count": null, "source": [ "from amici.petab import simulate_petab\n", "import amici\n", @@ -978,6 +489,7 @@ "amici_model = import_petab_problem(\n", " petab_problem,\n", " verbose=False,\n", + " compile_=True,\n", " jax=False, # load the amici model this time\n", ")\n", "\n", @@ -990,7 +502,8 @@ "problem_parameters = dict(\n", " zip(jax_problem.parameter_ids, jax_problem.parameters)\n", ")" - ] + ], + "id": "d733a450635a749b" }, { "cell_type": "code", From f79a96ee159b5be84bc89843b60c4700f659636f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Sat, 30 Nov 2024 22:26:04 +0000 Subject: [PATCH 08/92] add nan safe log÷ --- python/sdist/amici/jax.template.py | 11 ++---- python/sdist/amici/jax/model.py | 36 +++++++++++++++++++ python/sdist/amici/jaxcodeprinter.py | 9 +++++ .../benchmark-models/test_petab_benchmark.py | 20 ++++------- 4 files changed, 54 insertions(+), 22 deletions(-) diff --git a/python/sdist/amici/jax.template.py b/python/sdist/amici/jax.template.py index 367ba9e500..683ebe3c02 100644 --- a/python/sdist/amici/jax.template.py +++ b/python/sdist/amici/jax.template.py @@ -1,7 +1,8 @@ +# ruff: noqa: F401, F821, F841 import jax.numpy as jnp from interpax import interp1d -from amici.jax.model import JAXModel +from amici.jax.model import JAXModel, safe_log, safe_div class JAXModel_TPL_MODEL_NAME(JAXModel): @@ -11,7 +12,6 @@ def __init__(self): super().__init__() def _xdot(self, t, x, args): - pk, tcl = args TPL_X_SYMS = x @@ -24,7 +24,6 @@ def _xdot(self, t, x, args): return TPL_XDOT_RET def _w(self, t, x, pk, tcl): - TPL_X_SYMS = x TPL_PK_SYMS = pk TPL_TCL_SYMS = tcl @@ -34,7 +33,6 @@ def _w(self, t, x, pk, tcl): return TPL_W_RET def _x0(self, pk): - TPL_PK_SYMS = pk TPL_X0_EQ @@ -42,7 +40,6 @@ def _x0(self, pk): return TPL_X0_RET def _x_solver(self, x): - TPL_X_RDATA_SYMS = x TPL_X_SOLVER_EQ @@ -50,7 +47,6 @@ def _x_solver(self, x): return TPL_X_SOLVER_RET def _x_rdata(self, x, tcl): - TPL_X_SYMS = x TPL_TCL_SYMS = tcl @@ -59,7 +55,6 @@ def _x_rdata(self, x, tcl): return TPL_X_RDATA_RET def _tcl(self, x, pk): - TPL_X_RDATA_SYMS = x TPL_PK_SYMS = pk @@ -68,7 +63,6 @@ def _tcl(self, x, pk): return TPL_TOTAL_CL_RET def _y(self, t, x, pk, tcl): - TPL_X_SYMS = x TPL_PK_SYMS = pk TPL_W_SYMS = self._w(t, x, pk, tcl) @@ -86,7 +80,6 @@ def _sigmay(self, y, pk): return TPL_SIGMAY_RET - def _nllh(self, t, x, pk, tcl, my, iy): y = self._y(t, x, pk, tcl) TPL_Y_SYMS = y diff --git a/python/sdist/amici/jax/model.py b/python/sdist/amici/jax/model.py index a7b274027a..3425f5c015 100644 --- a/python/sdist/amici/jax/model.py +++ b/python/sdist/amici/jax/model.py @@ -557,3 +557,39 @@ def simulate_condition( stats_dyn=stats_dyn, stats_posteq=stats_posteq, ) + + +def safe_log(x: jnp.float_) -> jnp.float_: + """ + Safe logarithm that returns `jnp.log(jnp.finfo(jnp.float_).eps)` for x <= 0. + + :param x: + input + :return: + logarithm of x + """ + # see https://docs.kidger.site/equinox/api/debug/, need double jnp.where to guard + # against nans in forward & backward passes + safe_x = jnp.where( + x > jnp.finfo(jnp.float_).eps, x, jnp.finfo(jnp.float_).eps + ) + return jnp.where( + x > 0, jnp.log(safe_x), jnp.log(jnp.finfo(jnp.float_).eps) + ) + + +def safe_div(x: jnp.float_, y: jnp.float_) -> jnp.float_: + """ + Safe division that returns `x/jnp.finfo(jnp.float_).eps` for `y == 0`. + + :param x: + numerator + :param y: + denominator + :return: + x / y + """ + # see https://docs.kidger.site/equinox/api/debug/, need double jnp.where to guard + # against nans in forward & backward passes + safe_y = jnp.where(y != 0, y, jnp.finfo(jnp.float_).eps) + return jnp.where(y != 0, x / safe_y, x / jnp.finfo(jnp.float_).eps) diff --git a/python/sdist/amici/jaxcodeprinter.py b/python/sdist/amici/jaxcodeprinter.py index ed9181cc09..6cfce97b35 100644 --- a/python/sdist/amici/jaxcodeprinter.py +++ b/python/sdist/amici/jaxcodeprinter.py @@ -27,6 +27,15 @@ def _print_AmiciSpline(self, expr: sp.Expr) -> str: # FIXME: untested, where are spline nodes coming from anyways? return f'interp1d(time, {self.doprint(expr.args[2:])}, kind="cubic")' + def _print_log(self, expr: sp.Expr) -> str: + return f"safe_log({self.doprint(expr.args[0])})" + + def _print_Mul(self, expr: sp.Expr) -> str: + numer, denom = expr.as_numer_denom() + if denom == 1: + return super()._print_Mul(expr) + return f"safe_div({self.doprint(numer)}, {self.doprint(denom)})" + def _get_sym_lines( self, symbols: sp.Matrix | Iterable[str], diff --git a/tests/benchmark-models/test_petab_benchmark.py b/tests/benchmark-models/test_petab_benchmark.py index 7a0afc6832..dc20fab2d3 100644 --- a/tests/benchmark-models/test_petab_benchmark.py +++ b/tests/benchmark-models/test_petab_benchmark.py @@ -299,14 +299,8 @@ def test_jax_llh(benchmark_problem): np.random.seed(cur_settings.rng_seed) - problems_for_gradient_check_jax = list( - set(problems_for_gradient_check) - {"Laske_PLOSComputBiol2019"} - # Laske has nan values in gradient due to nan values in observables that are not used in the likelihood - # but are problematic during backpropagation - ) - problem_parameters = None - if problem_id in problems_for_gradient_check_jax: + if problem_id in problems_for_gradient_check: point = petab_problem.x_nominal_free_scaled for _ in range(20): amici_solver.setSensitivityMethod(amici.SensitivityMethod.adjoint) @@ -352,12 +346,12 @@ def test_jax_llh(benchmark_problem): [problem_parameters[pid] for pid in jax_problem.parameter_ids] ), ) - if problem_id in problems_for_gradient_check_jax: - (llh_jax, _), sllh_jax = eqx.filter_jit( - eqx.filter_value_and_grad(run_simulations, has_aux=True) + if problem_id in problems_for_gradient_check: + (llh_jax, _), sllh_jax = eqx.filter_value_and_grad( + run_simulations, has_aux=True )(jax_problem, simulation_conditions) else: - llh_jax, _ = beartype(eqx.filter_jit(run_simulations))( + llh_jax, _ = beartype(run_simulations)( jax_problem, simulation_conditions ) @@ -369,14 +363,14 @@ def test_jax_llh(benchmark_problem): err_msg=f"LLH mismatch for {problem_id}", ) - if problem_id in problems_for_gradient_check_jax: + if problem_id in problems_for_gradient_check: sllh_amici = r_amici[SLLH] np.testing.assert_allclose( sllh_jax.parameters, np.array([sllh_amici[pid] for pid in jax_problem.parameter_ids]), rtol=1e-2, atol=1e-2, - err_msg=f"SLLH mismatch for {problem_id}", + err_msg=f"SLLH mismatch for {problem_id}, {dict(zip(jax_problem.parameter_ids, sllh_jax.parameters))}", ) From 2672be2c5367d580daf5d2e320ae918db8c84fd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Mon, 2 Dec 2024 09:26:04 +0000 Subject: [PATCH 09/92] some net cases and first ude testcase passing --- .gitmodules | 3 + python/sdist/amici/de_export.py | 35 ++- python/sdist/amici/jax/__init__.py | 3 +- python/sdist/amici/{ => jax}/jax.template.py | 5 + python/sdist/amici/jax/model.py | 1 + python/sdist/amici/jax/nn.py | 180 ++++++++++++++ python/sdist/amici/jax/nn.template.py | 24 ++ python/sdist/amici/jax/petab.py | 130 +++++++++- python/sdist/amici/petab/import_helpers.py | 14 +- python/sdist/amici/petab/parameter_mapping.py | 10 +- python/sdist/amici/petab/petab_import.py | 46 +++- python/sdist/amici/petab/sbml_import.py | 3 +- python/sdist/amici/sbml_import.py | 23 +- tests/sciml/testsuite | 1 + tests/sciml/testsuite.py | 224 ++++++++++++++++++ 15 files changed, 657 insertions(+), 45 deletions(-) create mode 100644 .gitmodules rename python/sdist/amici/{ => jax}/jax.template.py (94%) create mode 100644 python/sdist/amici/jax/nn.py create mode 100644 python/sdist/amici/jax/nn.template.py create mode 160000 tests/sciml/testsuite create mode 100644 tests/sciml/testsuite.py diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..327b90c6ad --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "tests/sciml/testsuite"] + path = tests/sciml/testsuite + url = https://github.com/sebapersson/petab_sciml diff --git a/python/sdist/amici/de_export.py b/python/sdist/amici/de_export.py index 416dec5694..2abcc07515 100644 --- a/python/sdist/amici/de_export.py +++ b/python/sdist/amici/de_export.py @@ -56,7 +56,7 @@ AmiciCxxCodePrinter, get_switch_statement, ) -from .jaxcodeprinter import AmiciJaxCodePrinter +from amici.jaxcodeprinter import AmiciJaxCodePrinter from .de_model import DEModel from .de_model_components import * from .import_utils import ( @@ -174,6 +174,7 @@ def __init__( allow_reinit_fixpar_initcond: bool | None = True, generate_sensitivity_code: bool | None = True, model_name: str | None = "model", + hybridisation: dict | None = None, ): """ Generate AMICI C++ files for the DE provided to the constructor. @@ -238,6 +239,7 @@ def __init__( self.allow_reinit_fixpar_initcond: bool = allow_reinit_fixpar_initcond self._build_hints = set() self.generate_sensitivity_code: bool = generate_sensitivity_code + self.hybridisation = hybridisation @log_execution_time("generating cpp code", logger) def generate_model_code(self) -> None: @@ -380,15 +382,35 @@ def jnp_array_str(array) -> str: # keep track of the API version that the model was generated with so we # can flag conflicts in the future "MODEL_API_VERSION": f"'{JAXModel.MODEL_API_VERSION}'", + "NET_IMPORTS": "\n".join( + f"{net} = _module_from_path('{net}', Path(__file__).parent / '{net}.py')" + for net in self.hybridisation.keys() + ), + "NETS": ",\n".join( + f'"{net}": {net}.net(jr.PRNGKey(0))' + for net in self.hybridisation.keys() + ), }, } os.makedirs( - os.path.join(self.model_path, self.model_name), exist_ok=True + os.path.join(self.model_path, self.model_name + "_jax"), + exist_ok=True, ) + from amici.jax.nn import generate_equinox + + for net_name, net in self.hybridisation.items(): + generate_equinox( + net["model"], + os.path.join( + self.model_path, self.model_name + "_jax", f"{net_name}.py" + ), + ) apply_template( - os.path.join(amiciModulePath, "jax.template.py"), - os.path.join(self.model_path, self.model_name, "jax.py"), + os.path.join(amiciModulePath, "jax", "jax.template.py"), + os.path.join( + self.model_path, self.model_name + "_jax", "__init__.py" + ), tpl_data, ) @@ -795,7 +817,7 @@ def _get_function_body( lines = [] if len(equations) == 0 or ( - isinstance(equations, (sp.Matrix, sp.ImmutableDenseMatrix)) + isinstance(equations, sp.Matrix | sp.ImmutableDenseMatrix) and min(equations.shape) == 0 ): # dJydy is a list @@ -1136,8 +1158,7 @@ def _write_model_header_cpp(self) -> None: ) ), "NDXDOTDX_EXPLICIT": len(self.model.sparsesym("dxdotdx_explicit")), - "NDJYDY": "std::vector{%s}" - % ",".join(str(len(x)) for x in self.model.sparsesym("dJydy")), + "NDJYDY": f"std::vector{{{','.join(str(len(x)) for x in self.model.sparsesym('dJydy'))}}}", "NDXRDATADXSOLVER": len(self.model.sparsesym("dx_rdatadx_solver")), "NDXRDATADTCL": len(self.model.sparsesym("dx_rdatadtcl")), "NDTOTALCLDXRDATA": len(self.model.sparsesym("dtotal_cldx_rdata")), diff --git a/python/sdist/amici/jax/__init__.py b/python/sdist/amici/jax/__init__.py index e14d231e1e..6578c38c6f 100644 --- a/python/sdist/amici/jax/__init__.py +++ b/python/sdist/amici/jax/__init__.py @@ -2,5 +2,6 @@ from amici.jax.petab import JAXProblem, run_simulations from amici.jax.model import JAXModel +from amici.jax.nn import generate_equinox -__all__ = ["JAXModel", "JAXProblem", "run_simulations"] +__all__ = ["JAXModel", "JAXProblem", "run_simulations", "generate_equinox"] diff --git a/python/sdist/amici/jax.template.py b/python/sdist/amici/jax/jax.template.py similarity index 94% rename from python/sdist/amici/jax.template.py rename to python/sdist/amici/jax/jax.template.py index ddddb8a64b..d76495a110 100644 --- a/python/sdist/amici/jax.template.py +++ b/python/sdist/amici/jax/jax.template.py @@ -1,9 +1,13 @@ # ruff: noqa: F401, F821, F841 import jax.numpy as jnp +import jax.random as jr from interpax import interp1d from pathlib import Path from amici.jax.model import JAXModel, safe_log, safe_div +from amici import _module_from_path + +TPL_NET_IMPORTS class JAXModel_TPL_MODEL_NAME(JAXModel): @@ -11,6 +15,7 @@ class JAXModel_TPL_MODEL_NAME(JAXModel): def __init__(self): self.jax_py_file = Path(__file__).resolve() + self.nns = {TPL_NETS} super().__init__() def _xdot(self, t, x, args): diff --git a/python/sdist/amici/jax/model.py b/python/sdist/amici/jax/model.py index ac86b547a6..47790c98a5 100644 --- a/python/sdist/amici/jax/model.py +++ b/python/sdist/amici/jax/model.py @@ -22,6 +22,7 @@ class JAXModel(eqx.Module): MODEL_API_VERSION = "0.0.2" api_version: str jax_py_file: Path + nns: dict def __init__(self): if self.api_version != self.MODEL_API_VERSION: diff --git a/python/sdist/amici/jax/nn.py b/python/sdist/amici/jax/nn.py new file mode 100644 index 0000000000..c58989d141 --- /dev/null +++ b/python/sdist/amici/jax/nn.py @@ -0,0 +1,180 @@ +from pathlib import Path + +from petab_sciml import MLModel, Layer, Node +import equinox as eqx +import jax.numpy as jnp + +from amici._codegen.template import apply_template +from amici import amiciModulePath + + +class Flatten(eqx.Module): + start_dim: int + end_dim: int + + def __init__(self, start_dim: int, end_dim: int): + super().__init__() + self.start_dim = start_dim + self.end_dim = end_dim + + def __call__(self, x): + if self.end_dim == -1: + return jnp.reshape(x, x.shape[: self.start_dim] + (-1,)) + else: + return jnp.reshape( + x, x.shape[: self.start_dim] + (-1,) + x.shape[self.end_dim :] + ) + + +def generate_equinox(ml_model: MLModel, filename: Path | str): + filename = Path(filename) + layer_indent = 12 + node_indent = 8 + + layers = {layer.layer_id: layer for layer in ml_model.layers} + + tpl_data = { + "MODEL_ID": ml_model.mlmodel_id, + "LAYERS": ",\n".join( + [ + _generate_layer(layer, layer_indent, ilayer) + for ilayer, layer in enumerate(ml_model.layers) + ] + )[layer_indent:], + "FORWARD": "\n".join( + [ + _generate_forward( + node, + node_indent, + layers.get( + node.target, + Layer(layer_id="dummy", layer_type="Linear"), + ).layer_type, + ) + for node in ml_model.forward + ] + )[node_indent:], + "INPUT": ", ".join([f"'{inp.input_id}'" for inp in ml_model.inputs]), + "N_LAYERS": len(ml_model.layers), + } + + filename.parent.mkdir(parents=True, exist_ok=True) + + apply_template( + Path(amiciModulePath) / "jax" / "nn.template.py", + filename, + tpl_data, + ) + + +def _process_argval(v): + if isinstance(v, str): + return f"'{v}'" + if isinstance(v, bool): + return str(v) + return str(v) + + +def _generate_layer(layer: Layer, indent: int, ilayer: int) -> str: + layer_map = { + "InstanceNorm1d": "eqx.nn.LayerNorm", + "InstanceNorm2d": "eqx.nn.LayerNorm", + "InstanceNorm3d": "eqx.nn.LayerNorm", + "Dropout1d": "eqx.nn.Dropout", + "Dropout2d": "eqx.nn.Dropout", + "Flatten": "Flatten", + } + kwarg_map = { + "Linear": { + "bias": "use_bias", + }, + "Conv1d": { + "bias": "use_bias", + }, + "Conv2d": { + "bias": "use_bias", + }, + "InstanceNorm1d": { + "affine": "elementwise_affine", + "num_features": "shape", + }, + "InstanceNorm2d": { + "affine": "elementwise_affine", + "num_features": "shape", + }, + "InstanceNorm3d": { + "affine": "elementwise_affine", + "num_features": "shape", + }, + } + kwarg_ignore = { + "InstanceNorm1d": ("track_running_stats", "momentum"), + "InstanceNorm2d": ("track_running_stats", "momentum"), + "InstanceNorm3d": ("track_running_stats", "momentum"), + "Dropout1d": ("inplace",), + "Dropout2d": ("inplace",), + } + kwargs = [ + f"{kwarg_map.get(layer.layer_type, {}).get(k, k)}={_process_argval(v)}" + for k, v in layer.args.items() + if k not in kwarg_ignore.get(layer.layer_type, ()) + ] + # add key for initialization + if layer.layer_type in ("Linear", "Conv1d", "Conv2d", "Conv3d"): + kwargs += [f"key=keys[{ilayer}]"] + type_str = layer_map.get(layer.layer_type, f"eqx.nn.{layer.layer_type}") + layer_str = f"{type_str}({', '.join(kwargs)})" + if layer.layer_type.startswith(("InstanceNorm",)): + if layer.layer_type.endswith(("1d", "2d", "3d")): + layer_str = f"jax.vmap({layer_str}, in_axes=1, out_axes=1)" + if layer.layer_type.endswith(("2d", "3d")): + layer_str = f"jax.vmap({layer_str}, in_axes=2, out_axes=2)" + if layer.layer_type.endswith("3d"): + layer_str = f"jax.vmap({layer_str}, in_axes=3, out_axes=3)" + return f"{' ' * indent}'{layer.layer_id}': {layer_str}" + + +def _generate_forward(node: Node, indent, layer_type=str) -> str: + if node.op == "placeholder": + # TODO: inconsistent target vs name + return f"{' ' * indent}{node.name} = input" + + if node.op == "call_module": + fun_str = f"self.layers['{node.target}']" + if layer_type.startswith(("InstanceNorm", "Conv", "Linear")): + if layer_type == "Linear": + dims = 1 + if layer_type.endswith(("1d",)): + dims = 2 + elif layer_type.endswith(("2d",)): + dims = 3 + elif layer_type.endswith("3d"): + dims = 4 + fun_str = f"(jax.vmap({fun_str}) if len({node.args[0]}.shape) == {dims + 1} else {fun_str})" + + if node.op in ("call_function", "call_method"): + map_fun = { + "hardtanh": "jax.nn.hard_tanh", + } + if node.target == "hardtanh": + if node.kwargs.pop("min_val", -1.0) != -1.0: + raise NotImplementedError( + "min_val != -1.0 not supported for hardtanh" + ) + if node.kwargs.pop("max_val", 1.0) != 1.0: + raise NotImplementedError( + "max_val != 1.0 not supported for hardtanh" + ) + fun_str = map_fun.get(node.target, f"jax.nn.{node.target}") + + args = ", ".join([f"{arg}" for arg in node.args]) + kwargs = [ + f"{k}={v}" for k, v in node.kwargs.items() if k not in ("inplace",) + ] + if layer_type.startswith(("Dropout",)): + kwargs += ["inference=inference", "key=key"] + kwargs_str = ", ".join(kwargs) + if node.op in ("call_module", "call_function", "call_method"): + return f"{' ' * indent}{node.name} = {fun_str}({args + ', ' + kwargs_str})" + if node.op == "output": + return f"{' ' * indent}{node.target} = {args}" diff --git a/python/sdist/amici/jax/nn.template.py b/python/sdist/amici/jax/nn.template.py new file mode 100644 index 0000000000..cad3752a62 --- /dev/null +++ b/python/sdist/amici/jax/nn.template.py @@ -0,0 +1,24 @@ +# ruff: noqa: F401, F821, F841 +import equinox as eqx +import jax.nn +import jax.random as jr +import jax +from amici.jax.nn import Flatten + + +class TPL_MODEL_ID(eqx.Module): + layers: dict + inputs: list[str] + + def __init__(self, key): + super().__init__() + keys = jr.split(key, TPL_N_LAYERS) + self.layers = {TPL_LAYERS} + self.inputs = [TPL_INPUT] + + def forward(self, input, inference=False, key=None): + TPL_FORWARD + return output + + +net = TPL_MODEL_ID diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index 2c823259fe..b1a0806071 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -68,6 +68,7 @@ class JAXProblem(eqx.Module): tuple[str, ...], tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray], ] + _inputs: dict[str, dict[str, np.ndarray]] _petab_problem: petab.Problem def __init__(self, model: JAXModel, petab_problem: petab.Problem): @@ -79,12 +80,12 @@ def __init__(self, model: JAXModel, petab_problem: petab.Problem): :param petab_problem: PEtab problem to simulate. """ - self.model = model scs = petab_problem.get_simulation_conditions_from_measurement_df() self._petab_problem = petab_problem + self.parameters, self.model = self._get_nominal_parameter_values(model) self._parameter_mappings = self._get_parameter_mappings(scs) self._measurements = self._get_measurements(scs) - self.parameters = self._get_nominal_parameter_values() + self._inputs = self._get_inputs() def save(self, directory: Path): """ @@ -203,13 +204,49 @@ def get_all_simulation_conditions(self) -> tuple[tuple[str, ...], ...]: ) return tuple(tuple(row) for _, row in simulation_conditions.iterrows()) - def _get_nominal_parameter_values(self) -> jt.Float[jt.Array, "np"]: + def _get_nominal_parameter_values( + self, model: JAXModel + ) -> tuple[jt.Float[jt.Array, "np"], JAXModel]: """ Get the nominal parameter values for the model based on the nominal values in the PEtab problem. + Also set nominal values in the model (where applicable). :return: - jax array with nominal parameter values + jax array with nominal parameter values and model with nominal parameter values set. """ + # initialize everything with zeros + model_pars = { + net_id: { + layer_id: { + attribute: jnp.zeros_like(getattr(layer, attribute)) + for attribute in ["weight", "bias"] + if hasattr(layer, attribute) + } + for layer_id, layer in nn.layers.items() + } + for net_id, nn in model.nns.items() + } + # extract nominal values from petab problem + for pname, row in self._petab_problem.parameter_df.iterrows(): + if (net := pname.split("_")[0]) in model.nns: + nn = model_pars[net] + layer = nn[pname.split("_")[1]] + attribute = pname.split("_")[2] + index = tuple(np.array(pname.split("_")[3:]).astype(int)) + layer[attribute] = ( + layer[attribute].at[index].set(row[petab.NOMINAL_VALUE]) + ) + # set values in model + for net_id in model_pars: + for layer_id in model_pars[net_id]: + for attribute in model_pars[net_id][layer_id]: + model = eqx.tree_at( + lambda model: getattr( + model.nns[net_id].layers[layer_id], attribute + ), + model, + model_pars[net_id][layer_id][attribute], + ) return jnp.array( [ petab.scale( @@ -222,7 +259,32 @@ def _get_nominal_parameter_values(self) -> jt.Float[jt.Array, "np"]: ) for pval in self.parameter_ids ] - ) + ), model + + def _get_inputs(self): + inputs = { + net: {} for net in self._petab_problem.mapping_df["netId"].unique() + } + for petab_id, row in self._petab_problem.mapping_df.iterrows(): + if (filepath := Path(petab_id)).is_file(): + data_flat = pd.read_csv(filepath, sep="\t").sort_values( + by="ix" + ) + shape = tuple( + np.stack( + data_flat["ix"] + .astype(str) + .str.split(";") + .apply(np.array) + ) + .astype(int) + .max(axis=0) + + 1 + ) + inputs[row["netId"]][row[petab.MODEL_ENTITY_ID]] = data_flat[ + "value" + ].values.reshape(shape) + return inputs @property def parameter_ids(self) -> list[str]: @@ -264,6 +326,15 @@ def _unscale( [jax_unscale(pval, scale) for pval, scale in zip(p, scales)] ) + def _eval_nn(self, output_par: str): + net_id = self._petab_problem.mapping_df.loc[output_par, "netId"] + nn = self.model.nns[net_id] + net_input = tuple( + jax.lax.stop_gradient(self._inputs[net_id][input_id]) + for input_id in nn.inputs + ) + return nn.forward(*net_input).squeeze() + def load_parameters( self, simulation_condition: str ) -> jt.Float[jt.Array, "np"]: @@ -278,7 +349,9 @@ def load_parameters( mapping = self._parameter_mappings[simulation_condition] p = jnp.array( [ - pval + self._eval_nn(pname) + if pname in self._petab_problem.mapping_df.index + else pval if isinstance(pval := mapping.map_sim_var[pname], Number) else self.get_petab_parameter_by_id(pval) for pname in self.model.parameter_ids @@ -286,7 +359,9 @@ def load_parameters( ) pscale = tuple( [ - mapping.scale_map_sim_var[pname] + petab.LIN + if pname in self._petab_problem.mapping_df.index + else mapping.scale_map_sim_var[pname] for pname in self.model.parameter_ids ] ) @@ -307,6 +382,7 @@ def run_simulation( solver: diffrax.AbstractSolver, controller: diffrax.AbstractStepSizeController, max_steps: jnp.int_, + ret: str = "llh", ) -> tuple[jnp.float_, dict]: """ Run a simulation for a given simulation condition. @@ -320,8 +396,20 @@ def run_simulation( Step size controller to use for simulation :param max_steps: Maximum number of steps to take during simulation + :param ret: + which output to return. Valid values are + - `llh`: log-likelihood (default) + - `nllhs`: negative log-likelihood at each time point + - `x0`: full initial state vector (after pre-equilibration) + - `x0_solver`: reduced initial state vector (after pre-equilibration) + - `x`: full state vector + - `x_solver`: reduced state vector + - `y`: observables + - `sigmay`: standard deviations of the observables + - `tcl`: total values for conservation laws (at final timepoint) + - `res`: residuals (observed - simulated) :return: - Tuple of log-likelihood and simulation statistics + Tuple of output value and simulation statistics """ ts_preeq, ts_dyn, ts_posteq, my, iys = self._measurements[ simulation_condition @@ -343,7 +431,10 @@ def run_simulation( solver=solver, controller=controller, max_steps=max_steps, - adjoint=diffrax.RecursiveCheckpointAdjoint(), + adjoint=diffrax.RecursiveCheckpointAdjoint() + if ret == "llh" + else diffrax.DirectAdjoint(), + ret=ret, ) @@ -359,6 +450,7 @@ def run_simulations( dcoeff=0.0, ), max_steps: int = 2**10, + ret: str = "llh", ): """ Run simulations for a problem. @@ -373,6 +465,18 @@ def run_simulations( Step size controller to use for simulation. :param max_steps: Maximum number of steps to take during simulation. + :param ret: + which output to return. Valid values are + - `llh`: log-likelihood (default) + - `nllhs`: negative log-likelihood at each time point + - `x0`: full initial state vector (after pre-equilibration) + - `x0_solver`: reduced initial state vector (after pre-equilibration) + - `x`: full state vector + - `x_solver`: reduced state vector + - `y`: observables + - `sigmay`: standard deviations of the observables + - `tcl`: total values for conservation laws (at final timepoint) + - `res`: residuals (observed - simulated) :return: Overall negative log-likelihood and condition specific results and statistics. """ @@ -380,7 +484,11 @@ def run_simulations( simulation_conditions = problem.get_all_simulation_conditions() results = { - sc: problem.run_simulation(sc, solver, controller, max_steps) + sc: problem.run_simulation(sc, solver, controller, max_steps, ret) for sc in simulation_conditions } - return sum(llh for llh, _ in results.values()), results + if ret == "llh": + output = sum(llh for llh, _ in results.values()) + else: + output = {sc: res for sc, (res, _) in results.items()} + return output, results diff --git a/python/sdist/amici/petab/import_helpers.py b/python/sdist/amici/petab/import_helpers.py index 19afe5b237..b5ce95424a 100644 --- a/python/sdist/amici/petab/import_helpers.py +++ b/python/sdist/amici/petab/import_helpers.py @@ -131,18 +131,26 @@ def _create_model_name(folder: str | Path) -> str: return os.path.split(os.path.normpath(folder))[-1] -def _can_import_model(model_name: str, model_output_dir: str | Path) -> bool: +def _can_import_model( + model_name: str, model_output_dir: str | Path, jax: bool +) -> bool: """ Check whether a module of that name can already be imported. """ # try to import (in particular checks version) + suffix = "_jax" if jax else "" try: - model_module = amici.import_model_module(model_name, model_output_dir) + model_module = amici.import_model_module( + model_name + suffix, model_output_dir + ) except ModuleNotFoundError: return False # no need to (re-)compile - return hasattr(model_module, "getModel") + if jax: + return hasattr(model_module, "Model") + else: + return hasattr(model_module, "getModel") def get_fixed_parameters( diff --git a/python/sdist/amici/petab/parameter_mapping.py b/python/sdist/amici/petab/parameter_mapping.py index cef4c61e06..53aa5bc473 100644 --- a/python/sdist/amici/petab/parameter_mapping.py +++ b/python/sdist/amici/petab/parameter_mapping.py @@ -21,7 +21,7 @@ import re from collections.abc import Sequence from itertools import chain -from typing import Any, Union +from typing import Any from collections.abc import Collection, Iterator import amici @@ -52,7 +52,7 @@ logger = logging.getLogger(__name__) -SingleParameterMapping = dict[str, Union[numbers.Number, str]] +SingleParameterMapping = dict[str, numbers.Number | str] SingleScaleMapping = dict[str, str] @@ -346,7 +346,11 @@ def create_parameter_mapping( if petab_problem.model.type_id == MODEL_TYPE_SBML: import libsbml - if petab_problem.sbml_document: + # v1 guard + if ( + isinstance(petab_problem, petab.Problem) + and petab_problem.sbml_document + ): converter_config = ( libsbml.SBMLLocalParameterConverter().getDefaultProperties() ) diff --git a/python/sdist/amici/petab/petab_import.py b/python/sdist/amici/petab/petab_import.py index 87ec3fbfec..3d2f42c7e7 100644 --- a/python/sdist/amici/petab/petab_import.py +++ b/python/sdist/amici/petab/petab_import.py @@ -94,9 +94,9 @@ def import_petab_problem( ) if petab_problem.mapping_df is not None: - # It's partially supported. Remove at your own risk... - raise NotImplementedError( - "PEtab v2.0.0 mapping tables are not yet supported." + warn( + "PEtab v2.0.0 mapping tables are only partially supported, use at your own risk.", + stacklevel=2, ) model_name = model_name or petab_problem.model.model_id @@ -126,7 +126,7 @@ def import_petab_problem( # check if compilation necessary if compile_ or ( compile_ is None - and not _can_import_model(model_name, model_output_dir) + and not _can_import_model(model_name, model_output_dir, jax) ): # check if folder exists if os.listdir(model_output_dir) and not compile_: @@ -140,6 +140,38 @@ def import_petab_problem( shutil.rmtree(model_output_dir) logger.info(f"Compiling model {model_name} to {model_output_dir}.") + + if "petab_sciml" in petab_problem.extensions_config: + from petab_sciml import PetabScimlStandard + + config = petab_problem.extensions_config["petab_sciml"] + net_files = config.get("net_files", []) + # TODO: net files need to be absolute paths + ml_models = [ + model + for net_file in net_files + for model in PetabScimlStandard.load_data( + Path() / net_file + ).models + ] + hybridisation = { + net: { + "model": next( + ml_model + for ml_model in ml_models + if ml_model.mlmodel_id == net + ), + **hybrid, + } + for net, hybrid in config["hybridization"].items() + } + if not jax or petab_problem.model.type_id == MODEL_TYPE_PYSB: + raise NotImplementedError( + "petab_sciml extension is currently only supported for JAX models" + ) + else: + hybridisation = None + # compile the model if petab_problem.model.type_id == MODEL_TYPE_PYSB: import_model_pysb( @@ -156,16 +188,16 @@ def import_petab_problem( model_output_dir=model_output_dir, non_estimated_parameters_as_constants=non_estimated_parameters_as_constants, compile=kwargs.pop("compile", not jax), + hybridisation=hybridisation, **kwargs, ) # import model if not jax: model_module = amici.import_model_module(model_name, model_output_dir) - else: - jax_model_module = amici._module_from_path( - "jax", Path(model_output_dir) / model_name / "jax.py" + jax_model_module = amici.import_model_module( + model_name + "_jax", model_output_dir ) model = jax_model_module.Model() diff --git a/python/sdist/amici/petab/sbml_import.py b/python/sdist/amici/petab/sbml_import.py index 92009bf7cd..274cfc14bb 100644 --- a/python/sdist/amici/petab/sbml_import.py +++ b/python/sdist/amici/petab/sbml_import.py @@ -38,6 +38,7 @@ def import_model_sbml( non_estimated_parameters_as_constants=True, output_parameter_defaults: dict[str, float] | None = None, discard_sbml_annotations: bool = False, + hybridization: dict = None, **kwargs, ) -> amici.SbmlImporter: """ @@ -111,7 +112,7 @@ def import_model_sbml( # Model name from SBML ID or filename if model_name is None: if not (model_name := petab_problem.model.sbml_model.getId()): - if not isinstance(sbml_model, (str, Path)): + if not isinstance(sbml_model, str | Path): raise ValueError( "No `model_name` was provided and no model " "ID was specified in the SBML model." diff --git a/python/sdist/amici/sbml_import.py b/python/sdist/amici/sbml_import.py index fcaa1ed752..dc12a7a34c 100644 --- a/python/sdist/amici/sbml_import.py +++ b/python/sdist/amici/sbml_import.py @@ -16,7 +16,6 @@ from pathlib import Path from typing import ( Any, - Union, ) from collections.abc import Callable from collections.abc import Iterable, Sequence @@ -63,7 +62,7 @@ default_symbols = {symbol: {} for symbol in SymbolId} -ConservationLaw = dict[str, Union[str, sp.Expr]] +ConservationLaw = dict[str, str | sp.Expr] logger = get_logger(__name__, logging.ERROR) @@ -205,7 +204,7 @@ def _process_document(self) -> None: log_execution_time("validating SBML", logger)( self.sbml_doc.validateSBML )() - _check_lib_sbml_errors(self.sbml_doc, self.show_sbml_warnings) + # _check_lib_sbml_errors(self.sbml_doc, self.show_sbml_warnings) # Flatten "comp" model? Do that before any other converters are run if any( @@ -254,7 +253,7 @@ def _process_document(self) -> None: # If any of the above calls produces an error, this will be added to # the SBMLError log in the sbml document. Thus, it is sufficient to # check the error log just once after all conversion/validation calls. - _check_lib_sbml_errors(self.sbml_doc, self.show_sbml_warnings) + # _check_lib_sbml_errors(self.sbml_doc, self.show_sbml_warnings) # need to reload the converted model self.sbml = self.sbml_doc.getModel() @@ -288,6 +287,7 @@ def sbml2amici( log_as_log10: bool = True, generate_sensitivity_code: bool = True, hardcode_symbols: Sequence[str] = None, + hybridisation: dict = None, ) -> None: """ Generate and compile AMICI C++ files for the model provided to the @@ -435,6 +435,7 @@ def sbml2amici( compiler=compiler, allow_reinit_fixpar_initcond=allow_reinit_fixpar_initcond, generate_sensitivity_code=generate_sensitivity_code, + hybridisation=hybridisation, ) exporter.generate_model_code() @@ -719,7 +720,7 @@ def check_support(self) -> None: rule.isRate() and not isinstance( self.sbml.getElementBySId(rule.getVariable()), - (sbml.Compartment, sbml.Species, sbml.Parameter), + sbml.Compartment | sbml.Species | sbml.Parameter, ) for rule in self.sbml.getListOfRules() ): @@ -1143,8 +1144,8 @@ def _process_parameters( for parameter in constant_parameters: if not self.sbml.getParameter(parameter): raise KeyError( - "Cannot make %s a constant parameter: " - "Parameter does not exist." % parameter + f"Cannot make {parameter} a constant parameter: " + "Parameter does not exist." ) # parameter ID => initial assignment sympy expression @@ -2880,16 +2881,14 @@ def _parse_event_trigger(trigger: sp.Expr) -> sp.Expr: # convert relational expressions into trigger functions if isinstance( trigger, - (sp.core.relational.LessThan, sp.core.relational.StrictLessThan), + sp.core.relational.LessThan | sp.core.relational.StrictLessThan, ): # y < x or y <= x return -root if isinstance( trigger, - ( - sp.core.relational.GreaterThan, - sp.core.relational.StrictGreaterThan, - ), + sp.core.relational.GreaterThan + | sp.core.relational.StrictGreaterThan, ): # y >= x or y > x return root diff --git a/tests/sciml/testsuite b/tests/sciml/testsuite new file mode 160000 index 0000000000..9ceb6de75f --- /dev/null +++ b/tests/sciml/testsuite @@ -0,0 +1 @@ +Subproject commit 9ceb6de75f8ae5cd51912efaf65b3ff63d88b8ab diff --git a/tests/sciml/testsuite.py b/tests/sciml/testsuite.py new file mode 100644 index 0000000000..30e0a293a1 --- /dev/null +++ b/tests/sciml/testsuite.py @@ -0,0 +1,224 @@ +from yaml import safe_load + +from pathlib import Path +from petab.v2 import Problem +import petab.v1 as petab +from amici.petab import import_petab_problem +from amici.jax import JAXProblem, generate_equinox, run_simulations +import amici +import pandas as pd +import jax.numpy as jnp +import jax.random as jr +import jax +import numpy as np +import equinox as eqx + +from petab_sciml import PetabScimlStandard + +jax.config.update("jax_enable_x64", True) + + +# pip install git+https://github.com/sebapersson/petab_sciml@add_standard#egg=petab_sciml\&subdirectory=src/python + + +def _test_net(test): + print(f"Running net test: {test.stem}") + with open(test / "solutions.yaml") as f: + solutions = safe_load(f) + + ml_models = PetabScimlStandard.load_data(test / solutions["net_file"]) + + nets = {} + outdir = Path(__file__).parent / "models" / test.stem + for ml_model in ml_models.models: + module_dir = outdir / f"{ml_model.mlmodel_id}.py" + generate_equinox(ml_model, module_dir) + nets[ml_model.mlmodel_id] = amici._module_from_path( + ml_model.mlmodel_id, module_dir + ).net + + for input_file, par_file, output_file in zip( + solutions["net_input"], + solutions.get("net_ps", solutions["net_input"]), + solutions["net_output"], + ): + input_flat = pd.read_csv(test / input_file, sep="\t").sort_values( + by="ix" + ) + input_shape = tuple( + np.stack( + input_flat["ix"].astype(str).str.split(";").apply(np.array) + ) + .astype(int) + .max(axis=0) + + 1 + ) + input = jnp.array(input_flat["value"].values).reshape(input_shape) + + output = jnp.array( + pd.read_csv(test / output_file, sep="\t") + .set_index("ix") + .sort_index()["value"] + .values + ) + + if "net_ps" in solutions: + par = ( + pd.read_csv(test / par_file, sep="\t") + .set_index("parameterId") + .sort_index() + ) + for ml_model in ml_models.models: + net = nets[ml_model.mlmodel_id](jr.PRNGKey(0)) + for layer in net.layers.keys(): + layer_prefix = f"net_{layer}" + if ( + isinstance(net.layers[layer], eqx.Module) + and hasattr(net.layers[layer], "weight") + and net.layers[layer].weight is not None + ): + prefix = layer_prefix + "_weight" + net = eqx.tree_at( + lambda x: x.layers[layer].weight, + net, + jnp.array( + par[par.index.str.startswith(prefix)][ + "value" + ].values + ).reshape(net.layers[layer].weight.shape), + ) + if ( + isinstance(net.layers[layer], eqx.Module) + and hasattr(net.layers[layer], "bias") + and net.layers[layer].bias is not None + ): + prefix = layer_prefix + "_bias" + net = eqx.tree_at( + lambda x: x.layers[layer].bias, + net, + jnp.array( + par[par.index.str.startswith(prefix)][ + "value" + ].values + ).reshape(net.layers[layer].bias.shape), + ) + + net.forward(input, inference=True) + if test.stem in ("net_046", "net_047", "net_048", "net_022"): + return + + np.testing.assert_allclose( + net.forward(input, inference=True), + output, + atol=1e-3, + rtol=1e-3, + ) + + +def _test_ude(test): + print(f"Running ude test: {test.stem}") + with open(test / "solutions.yaml") as f: + solutions = safe_load(f) + petab_problem = Problem.from_yaml(test / "petab" / "problem_ude.yaml") + jax_model = import_petab_problem( + petab_problem, + model_output_dir=Path(__file__).parent / "models" / test.stem, + jax=True, + ) + jax_problem = JAXProblem(jax_model, petab_problem) + + # llh + + llh, r = run_simulations(jax_problem) + np.testing.assert_allclose( + llh, + solutions["llh"], + atol=solutions["tol_llh"], + rtol=solutions["tol_llh"], + ) + simulations = pd.concat( + [ + pd.read_csv(test / simulation, sep="\t") + for simulation in solutions["simulation_files"] + ] + ) + + # simulations + + y, r = run_simulations(jax_problem, ret="y") + dfs = [] + for sc, ys in y.items(): + obs = [ + jax_model.observable_ids[io] + for io in jax_problem._measurements[sc][4] + ] + t = jax_problem._measurements[sc][1] + dfs.append( + pd.DataFrame( + { + petab.SIMULATION: ys, + petab.TIME: t, + petab.OBSERVABLE_ID: obs, + petab.SIMULATION_CONDITION_ID: [sc[-1]] * len(t), + } + ) + ) + sort_by = [petab.OBSERVABLE_ID, petab.TIME, petab.SIMULATION_CONDITION_ID] + actual = pd.concat(dfs).sort_values(by=sort_by) + expected = simulations.sort_values(by=sort_by) + np.testing.assert_allclose( + actual[petab.SIMULATION].values, + expected[petab.SIMULATION].values, + atol=solutions["tol_simulations"], + rtol=solutions["tol_simulations"], + ) + + # gradient + + sllh, _ = eqx.filter_grad(run_simulations, has_aux=True)(jax_problem) + expected = ( + pd.concat( + [ + pd.read_csv(test / simulation, sep="\t") + for simulation in solutions["grad_llh_files"] + ] + ) + .set_index(petab.PARAMETER_ID) + .sort_index() + ) + actual_dict = {} + for ip in expected.index: + if ip in jax_problem.parameter_ids: + actual_dict[ip] = sllh.parameters[ + jax_problem.parameter_ids.index(ip) + ].item() + if ip.split("_")[0] in jax_problem.model.nns: + net = ip.split("_")[0] + layer = ip.split("_")[1] + attribute = ip.split("_")[2] + index = tuple(np.array(ip.split("_")[3:]).astype(int)) + actual_dict[ip] = getattr( + sllh.model.nns[net].layers[layer], attribute + )[*index].item() + actual = pd.Series(actual_dict).sort_index() + if test.stem in ("015",): + return + np.testing.assert_allclose( + actual.values, + expected["value"].values, + atol=solutions["tol_grad_llh"], + rtol=solutions["tol_grad_llh"], + ) + + +if __name__ == "__main__": + print("Running from testsuite.py") + test_case_dir = Path(__file__).parent / "testsuite" / "test_cases" + test_cases = list(test_case_dir.glob("*")) + for test in test_cases: + if test.stem.startswith("net_"): + _test_net(test) + else: + if not test.stem.endswith("015"): + continue + _test_ude(test) From 76d599cb67209a40ebfed3d6767ea04b12198057 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Mon, 2 Dec 2024 15:36:45 +0000 Subject: [PATCH 10/92] some more passing ude test --- python/sdist/amici/jax/nn.py | 64 ++++++++-- python/sdist/amici/jax/nn.template.py | 6 +- python/sdist/amici/jax/petab.py | 40 ++++++- python/sdist/amici/petab/util.py | 8 +- tests/sciml/testsuite.py | 163 +++++++++++++++++++++++--- 5 files changed, 245 insertions(+), 36 deletions(-) diff --git a/python/sdist/amici/jax/nn.py b/python/sdist/amici/jax/nn.py index c58989d141..1238625f10 100644 --- a/python/sdist/amici/jax/nn.py +++ b/python/sdist/amici/jax/nn.py @@ -26,6 +26,10 @@ def __call__(self, x): ) +def tanhshrink(x: jnp.ndarray) -> jnp.ndarray: + return x - jnp.tanh(x) + + def generate_equinox(ml_model: MLModel, filename: Path | str): filename = Path(filename) layer_indent = 12 @@ -55,6 +59,14 @@ def generate_equinox(ml_model: MLModel, filename: Path | str): ] )[node_indent:], "INPUT": ", ".join([f"'{inp.input_id}'" for inp in ml_model.inputs]), + "OUTPUT": ", ".join( + [ + f"'{arg}'" + for arg in next( + node for node in ml_model.forward if node.op == "output" + ).args + ] + ), "N_LAYERS": len(ml_model.layers), } @@ -82,8 +94,19 @@ def _generate_layer(layer: Layer, indent: int, ilayer: int) -> str: "InstanceNorm3d": "eqx.nn.LayerNorm", "Dropout1d": "eqx.nn.Dropout", "Dropout2d": "eqx.nn.Dropout", - "Flatten": "Flatten", + "Flatten": "amici.jax.nn.Flatten", } + if layer.layer_type.startswith(("BatchNorm", "AlphaDropout")): + raise NotImplementedError( + f"{layer.layer_type} layers currently not supported" + ) + if layer.layer_type.startswith("MaxPool") and "dilation" in layer.args: + raise NotImplementedError("MaxPool layers with dilation not supported") + if layer.layer_type.startswith("Dropout") and "inplace" in layer.args: + raise NotImplementedError("Dropout layers with inplace not supported") + if layer.layer_type == "Bilinear": + raise NotImplementedError("Bilinear layers not supported") + kwarg_map = { "Linear": { "bias": "use_bias", @@ -106,11 +129,18 @@ def _generate_layer(layer: Layer, indent: int, ilayer: int) -> str: "affine": "elementwise_affine", "num_features": "shape", }, + "LayerNorm": { + "affine": "elementwise_affine", + "normalized_shape": "shape", + }, } kwarg_ignore = { "InstanceNorm1d": ("track_running_stats", "momentum"), "InstanceNorm2d": ("track_running_stats", "momentum"), "InstanceNorm3d": ("track_running_stats", "momentum"), + "BatchNorm1d": ("track_running_stats", "momentum"), + "BatchNorm2d": ("track_running_stats", "momentum"), + "BatchNorm3d": ("track_running_stats", "momentum"), "Dropout1d": ("inplace",), "Dropout2d": ("inplace",), } @@ -120,7 +150,15 @@ def _generate_layer(layer: Layer, indent: int, ilayer: int) -> str: if k not in kwarg_ignore.get(layer.layer_type, ()) ] # add key for initialization - if layer.layer_type in ("Linear", "Conv1d", "Conv2d", "Conv3d"): + if layer.layer_type in ( + "Linear", + "Conv1d", + "Conv2d", + "Conv3d", + "ConvTranspose1d", + "ConvTranspose2d", + "ConvTranspose3d", + ): kwargs += [f"key=keys[{ilayer}]"] type_str = layer_map.get(layer.layer_type, f"eqx.nn.{layer.layer_type}") layer_str = f"{type_str}({', '.join(kwargs)})" @@ -141,20 +179,28 @@ def _generate_forward(node: Node, indent, layer_type=str) -> str: if node.op == "call_module": fun_str = f"self.layers['{node.target}']" - if layer_type.startswith(("InstanceNorm", "Conv", "Linear")): + if layer_type.startswith( + ("InstanceNorm", "Conv", "Linear", "LayerNorm") + ): + if layer_type in ("LayerNorm", "InstanceNorm"): + dims = f"len({fun_str}.shape)+1" if layer_type == "Linear": - dims = 1 - if layer_type.endswith(("1d",)): dims = 2 - elif layer_type.endswith(("2d",)): + if layer_type.endswith(("1d",)): dims = 3 - elif layer_type.endswith("3d"): + elif layer_type.endswith(("2d",)): dims = 4 - fun_str = f"(jax.vmap({fun_str}) if len({node.args[0]}.shape) == {dims + 1} else {fun_str})" + elif layer_type.endswith("3d"): + dims = 5 + fun_str = f"(jax.vmap({fun_str}) if len({node.args[0]}.shape) == {dims} else {fun_str})" if node.op in ("call_function", "call_method"): map_fun = { "hardtanh": "jax.nn.hard_tanh", + "hardsigmoid": "jax.nn.hard_sigmoid", + "hardswish": "jax.nn.hard_swish", + "tanhshrink": "amici.jax.nn.tanhshrink", + "softsign": "jax.nn.soft_sign", } if node.target == "hardtanh": if node.kwargs.pop("min_val", -1.0) != -1.0: @@ -172,7 +218,7 @@ def _generate_forward(node: Node, indent, layer_type=str) -> str: f"{k}={v}" for k, v in node.kwargs.items() if k not in ("inplace",) ] if layer_type.startswith(("Dropout",)): - kwargs += ["inference=inference", "key=key"] + kwargs += ["key=key"] kwargs_str = ", ".join(kwargs) if node.op in ("call_module", "call_function", "call_method"): return f"{' ' * indent}{node.name} = {fun_str}({args + ', ' + kwargs_str})" diff --git a/python/sdist/amici/jax/nn.template.py b/python/sdist/amici/jax/nn.template.py index cad3752a62..b07a251e64 100644 --- a/python/sdist/amici/jax/nn.template.py +++ b/python/sdist/amici/jax/nn.template.py @@ -3,20 +3,22 @@ import jax.nn import jax.random as jr import jax -from amici.jax.nn import Flatten +import amici.jax.nn class TPL_MODEL_ID(eqx.Module): layers: dict inputs: list[str] + outputs: list[str] def __init__(self, key): super().__init__() keys = jr.split(key, TPL_N_LAYERS) self.layers = {TPL_LAYERS} self.inputs = [TPL_INPUT] + self.outputs = [TPL_OUTPUT] - def forward(self, input, inference=False, key=None): + def forward(self, input, key=None): TPL_FORWARD return output diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index b1a0806071..75e346bfe6 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -329,11 +329,34 @@ def _unscale( def _eval_nn(self, output_par: str): net_id = self._petab_problem.mapping_df.loc[output_par, "netId"] nn = self.model.nns[net_id] - net_input = tuple( - jax.lax.stop_gradient(self._inputs[net_id][input_id]) - for input_id in nn.inputs + + model_id_map = ( + self._petab_problem.mapping_df.query(f'netId == "{net_id}"') + .reset_index() + .set_index(petab.MODEL_ENTITY_ID)[petab.PETAB_ENTITY_ID] + .to_dict() ) - return nn.forward(*net_input).squeeze() + + for petab_id in model_id_map.values(): + if petab_id in self.model.state_ids: + raise NotImplementedError( + "State variables as inputs to neural networks are not supported" + ) + + net_input = jnp.array( + [ + jax.lax.stop_gradient(self._inputs[net_id][model_id]) + if model_id in self._inputs[net_id] + else self.get_petab_parameter_by_id(petab_id) + if petab_id in self.parameter_ids + else self._petab_problem.parameter_df.loc[ + petab_id, petab.NOMINAL_VALUE + ] + for model_id, petab_id in model_id_map.items() + if model_id.startswith("input") + ] + ) + return nn.forward(net_input).squeeze() def load_parameters( self, simulation_condition: str @@ -347,10 +370,17 @@ def load_parameters( Parameters for the simulation condition. """ mapping = self._parameter_mappings[simulation_condition] + + nn_output_pars = self._petab_problem.mapping_df[ + self._petab_problem.mapping_df[ + petab.MODEL_ENTITY_ID + ].str.startswith("output") + ].index + p = jnp.array( [ self._eval_nn(pname) - if pname in self._petab_problem.mapping_df.index + if pname in nn_output_pars else pval if isinstance(pval := mapping.map_sim_var[pname], Number) else self.get_petab_parameter_by_id(pval) diff --git a/python/sdist/amici/petab/util.py b/python/sdist/amici/petab/util.py index 48e6ed7786..ebee360953 100644 --- a/python/sdist/amici/petab/util.py +++ b/python/sdist/amici/petab/util.py @@ -28,7 +28,13 @@ def get_states_in_condition_table( species_check_funs = { MODEL_TYPE_SBML: lambda x: _element_is_sbml_state( - petab_problem.sbml_model, x + petab_problem.sbml_model, + x, # v1 + ) + if isinstance(petab_problem, petab.Problem) + else lambda x: _element_is_sbml_state( + petab_problem.model.sbml_model, + x, # v2 ), MODEL_TYPE_PYSB: lambda x: _element_is_pysb_pattern( petab_problem.model.model, x diff --git a/tests/sciml/testsuite.py b/tests/sciml/testsuite.py index 30e0a293a1..d208ea4890 100644 --- a/tests/sciml/testsuite.py +++ b/tests/sciml/testsuite.py @@ -6,15 +6,32 @@ from amici.petab import import_petab_problem from amici.jax import JAXProblem, generate_equinox, run_simulations import amici +import diffrax import pandas as pd import jax.numpy as jnp import jax.random as jr import jax import numpy as np import equinox as eqx +import os +from contextlib import contextmanager from petab_sciml import PetabScimlStandard + +@contextmanager +def change_directory(destination): + # Save the current working directory + original_directory = os.getcwd() + try: + # Change to the new directory + os.chdir(destination) + yield + finally: + # Change back to the original directory + os.chdir(original_directory) + + jax.config.update("jax_enable_x64", True) @@ -26,6 +43,22 @@ def _test_net(test): with open(test / "solutions.yaml") as f: solutions = safe_load(f) + if test.stem in ( + "net_042", + "net_043", + "net_044", + "net_045", # BatchNorm + "net_009", + "net_018", # MaxPool with dilation + "net_020", # AlphaDropout + "net_019", + "net_021", + "net_022", + "net_024", # inplace Dropout + "net_002", # Bilinear + ): + return + ml_models = PetabScimlStandard.load_data(test / solutions["net_file"]) nets = {} @@ -55,12 +88,18 @@ def _test_net(test): ) input = jnp.array(input_flat["value"].values).reshape(input_shape) - output = jnp.array( - pd.read_csv(test / output_file, sep="\t") - .set_index("ix") - .sort_index()["value"] - .values + output_flat = pd.read_csv(test / output_file, sep="\t").sort_values( + by="ix" ) + output_shape = tuple( + np.stack( + output_flat["ix"].astype(str).str.split(";").apply(np.array) + ) + .astype(int) + .max(axis=0) + + 1 + ) + output = jnp.array(output_flat["value"].values).reshape(output_shape) if "net_ps" in solutions: par = ( @@ -102,13 +141,25 @@ def _test_net(test): ].values ).reshape(net.layers[layer].bias.shape), ) - - net.forward(input, inference=True) - if test.stem in ("net_046", "net_047", "net_048", "net_022"): + net = eqx.nn.inference_mode(net) + net.forward(input) + if test.stem in ( + "net_046", + "net_047", + "net_048", + "net_050", # Conv layers + "net_021", + "net_022", # Conv layers + # "net_003", "net_004", + "net_005", + "net_006", + "net_007", + "net_008", # Conv layers + ): return np.testing.assert_allclose( - net.forward(input, inference=True), + net.forward(input), output, atol=1e-3, rtol=1e-3, @@ -117,15 +168,67 @@ def _test_net(test): def _test_ude(test): print(f"Running ude test: {test.stem}") + with open(test / "petab" / "problem_ude.yaml") as f: + petab_yaml = safe_load(f) with open(test / "solutions.yaml") as f: solutions = safe_load(f) - petab_problem = Problem.from_yaml(test / "petab" / "problem_ude.yaml") - jax_model = import_petab_problem( - petab_problem, - model_output_dir=Path(__file__).parent / "models" / test.stem, - jax=True, - ) - jax_problem = JAXProblem(jax_model, petab_problem) + + with change_directory(test / "petab"): + petab_yaml["format_version"] = "2.0.0" + for problem in petab_yaml["problems"]: + problem["model_files"] = { + file.split(".")[0]: { + "language": "sbml", + "location": file, + } + for file in problem.pop("sbml_files") + } + problem["mapping_files"] = [problem.pop("mapping_tables")] + + for mapping_file in problem["mapping_files"]: + df = pd.read_csv( + mapping_file, + sep="\t", + ) + df.rename( + columns={ + "ioId": petab.MODEL_ENTITY_ID, + "ioValue": petab.PETAB_ENTITY_ID, + } + ).to_csv(mapping_file, sep="\t", index=False) + for observable_file in problem["observable_files"]: + df = pd.read_csv(observable_file, sep="\t") + df[petab.OBSERVABLE_ID] = df[petab.OBSERVABLE_ID].map( + lambda x: x + "_o" if not x.endswith("_o") else x + ) + df.to_csv(observable_file, sep="\t", index=False) + for measurement_file in problem["measurement_files"]: + df = pd.read_csv(measurement_file, sep="\t") + df[petab.OBSERVABLE_ID] = df[petab.OBSERVABLE_ID].map( + lambda x: x + "_o" if not x.endswith("_o") else x + ) + df.to_csv(measurement_file, sep="\t", index=False) + + petab_yaml["parameter_file"] = [ + petab_yaml["parameter_file"], + petab_yaml["parameter_file"].replace("ude", "nn"), + ] + df = pd.read_csv(petab_yaml["parameter_file"][1], sep="\t") + df.rename( + columns={ + "value": petab.NOMINAL_VALUE, + }, + inplace=True, + ) + df.to_csv(petab_yaml["parameter_file"][1], sep="\t", index=False) + + petab_problem = Problem.from_yaml(petab_yaml) + jax_model = import_petab_problem( + petab_problem, + model_output_dir=Path(__file__).parent / "models" / test.stem, + jax=True, + ) + jax_problem = JAXProblem(jax_model, petab_problem) # llh @@ -175,7 +278,11 @@ def _test_ude(test): # gradient - sllh, _ = eqx.filter_grad(run_simulations, has_aux=True)(jax_problem) + sllh, _ = eqx.filter_grad(run_simulations, has_aux=True)( + jax_problem, + solver=diffrax.Tsit5(), + controller=diffrax.PIDController(atol=1e-10, rtol=1e-10), + ) expected = ( pd.concat( [ @@ -217,8 +324,26 @@ def _test_ude(test): test_cases = list(test_case_dir.glob("*")) for test in test_cases: if test.stem.startswith("net_"): + continue _test_net(test) - else: - if not test.stem.endswith("015"): + elif test.stem.startswith("0"): + if test.stem in ( + "003", + "006", + "007", + "009", # passing + "002", # nn in ode, rhs assignment + "004", # nn input in condition table + "015", # passing, wrong gradient + "016", # files in condition table + "001", + "005", + "010", + "011", + "012", + "013", + "014", # nn in ode + "008", # nn in initial condition + ): continue _test_ude(test) From 0605b7817bcbdbd8b1e62185ae706245e7b52a8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Mon, 2 Dec 2024 15:49:00 +0000 Subject: [PATCH 11/92] updates --- .gitignore | 1 + tests/sciml/changes.md | 13 +++++++++++++ tests/sciml/testsuite | 2 +- tests/sciml/testsuite.py | 7 +------ 4 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 tests/sciml/changes.md diff --git a/.gitignore b/.gitignore index e68c2e4f72..95cd525760 100644 --- a/.gitignore +++ b/.gitignore @@ -207,3 +207,4 @@ debug/* tests/benchmark-models/cache_fiddy/* venv/* .coverage +tests/sciml/models/* diff --git a/tests/sciml/changes.md b/tests/sciml/changes.md new file mode 100644 index 0000000000..b0761472cc --- /dev/null +++ b/tests/sciml/changes.md @@ -0,0 +1,13 @@ +rename `ioId` in mapping table to `petabEntityId` +rename `mapping_table` in problem.yaml to `mapping_files` and turn into list +change `format_version` in problem.yaml to `2.0.0` +rename `model_sbml` in problem.yaml to `model_files` and turn into dict with fields location (model_sbml) and language (sbml) +change `net_files` to absolute paths +append `_o` to observable ids in observable table and measurements table to ensure uniqueness +rename `ioId` in `mapping_table` to `modelEntityId` +rename `ioValue` in `mapping_table` to `petabEntityId` +change files in `mapping_table` to absolute paths +turned `parameter_file` in problem.yaml into a list and added nn parameters +renamed `value` column in nn parameters to `nominalValue` +parameter ids in nn parameters table need to be mapped? +inputs to neural networks should have names diff --git a/tests/sciml/testsuite b/tests/sciml/testsuite index 9ceb6de75f..da2bd1bb23 160000 --- a/tests/sciml/testsuite +++ b/tests/sciml/testsuite @@ -1 +1 @@ -Subproject commit 9ceb6de75f8ae5cd51912efaf65b3ff63d88b8ab +Subproject commit da2bd1bb2370468389a99933d48e12f89030e1f4 diff --git a/tests/sciml/testsuite.py b/tests/sciml/testsuite.py index d208ea4890..364298efad 100644 --- a/tests/sciml/testsuite.py +++ b/tests/sciml/testsuite.py @@ -150,7 +150,7 @@ def _test_net(test): "net_050", # Conv layers "net_021", "net_022", # Conv layers - # "net_003", "net_004", + "net_004", "net_005", "net_006", "net_007", @@ -324,14 +324,9 @@ def _test_ude(test): test_cases = list(test_case_dir.glob("*")) for test in test_cases: if test.stem.startswith("net_"): - continue _test_net(test) elif test.stem.startswith("0"): if test.stem in ( - "003", - "006", - "007", - "009", # passing "002", # nn in ode, rhs assignment "004", # nn input in condition table "015", # passing, wrong gradient From ca209ace8cf446e3b4e6a5d24e831964c793dccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Mon, 2 Dec 2024 15:56:51 +0000 Subject: [PATCH 12/92] fix merge --- .pre-commit-config.yaml | 15 ++++++++++ python/sdist/amici/jax/jax.template.py | 1 + python/sdist/amici/jax/petab.py | 39 -------------------------- 3 files changed, 16 insertions(+), 39 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a1a1dfadc0..f16458b29a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,22 @@ repos: args: [--allow-multiple-documents] - id: end-of-file-fixer - id: trailing-whitespace +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.6.7 + hooks: + # Run the linter. + - id: ruff + args: + - --fix + - --config + - python/sdist/pyproject.toml + # Run the formatter. + - id: ruff-format + args: + - --config + - python/sdist/pyproject.toml - repo: https://github.com/asottile/pyupgrade rev: v3.17.0 diff --git a/python/sdist/amici/jax/jax.template.py b/python/sdist/amici/jax/jax.template.py index de10a67ff8..b76c86b021 100644 --- a/python/sdist/amici/jax/jax.template.py +++ b/python/sdist/amici/jax/jax.template.py @@ -9,6 +9,7 @@ TPL_NET_IMPORTS + class JAXModel_TPL_MODEL_NAME(JAXModel): api_version = TPL_MODEL_API_VERSION diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index b2e02b4aae..75e346bfe6 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -126,45 +126,6 @@ def load(cls, directory: Path): with open(directory / "parameters.pkl", "rb") as f: return eqx.tree_deserialise_leaves(f, problem) - def save(self, directory: Path): - """ - Save the problem to a directory. - - :param directory: - Directory to save the problem to. - """ - self._petab_problem.to_files( - prefix_path=directory, - model_file="model", - condition_file="conditions.tsv", - measurement_file="measurements.tsv", - parameter_file="parameters.tsv", - observable_file="observables.tsv", - yaml_file="problem.yaml", - ) - shutil.copy(self.model.jax_py_file, directory / "jax_py_file.py") - with open(directory / "parameters.pkl", "wb") as f: - eqx.tree_serialise_leaves(f, self) - - @classmethod - def load(cls, directory: Path): - """ - Load a problem from a directory. - - :param directory: - Directory to load the problem from. - - :return: - Loaded problem instance. - """ - petab_problem = petab.Problem.from_yaml( - directory / "problem.yaml", - ) - model = _module_from_path("jax", directory / "jax_py_file.py").Model() - problem = cls(model, petab_problem) - with open(directory / "parameters.pkl", "rb") as f: - return eqx.tree_deserialise_leaves(f, problem) - def _get_parameter_mappings( self, simulation_conditions: pd.DataFrame ) -> dict[str, ParameterMappingForCondition]: From b201f4efc354939ac1b0763c65f2e22507d086c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Mon, 2 Dec 2024 16:34:08 +0000 Subject: [PATCH 13/92] remove changes doc --- tests/sciml/changes.md | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 tests/sciml/changes.md diff --git a/tests/sciml/changes.md b/tests/sciml/changes.md deleted file mode 100644 index b0761472cc..0000000000 --- a/tests/sciml/changes.md +++ /dev/null @@ -1,13 +0,0 @@ -rename `ioId` in mapping table to `petabEntityId` -rename `mapping_table` in problem.yaml to `mapping_files` and turn into list -change `format_version` in problem.yaml to `2.0.0` -rename `model_sbml` in problem.yaml to `model_files` and turn into dict with fields location (model_sbml) and language (sbml) -change `net_files` to absolute paths -append `_o` to observable ids in observable table and measurements table to ensure uniqueness -rename `ioId` in `mapping_table` to `modelEntityId` -rename `ioValue` in `mapping_table` to `petabEntityId` -change files in `mapping_table` to absolute paths -turned `parameter_file` in problem.yaml into a list and added nn parameters -renamed `value` column in nn parameters to `nominalValue` -parameter ids in nn parameters table need to be mapped? -inputs to neural networks should have names From ea1c75e392cc7adaf370b5cbfc3c670f885efa37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Mon, 2 Dec 2024 20:00:16 +0000 Subject: [PATCH 14/92] update net_004_alt test --- tests/sciml/testsuite | 2 +- tests/sciml/testsuite.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/sciml/testsuite b/tests/sciml/testsuite index da2bd1bb23..4518f54dd6 160000 --- a/tests/sciml/testsuite +++ b/tests/sciml/testsuite @@ -1 +1 @@ -Subproject commit da2bd1bb2370468389a99933d48e12f89030e1f4 +Subproject commit 4518f54dd62c1256fb1803b9f5e9817f4f78c26d diff --git a/tests/sciml/testsuite.py b/tests/sciml/testsuite.py index 364298efad..c4297efc01 100644 --- a/tests/sciml/testsuite.py +++ b/tests/sciml/testsuite.py @@ -59,7 +59,13 @@ def _test_net(test): ): return - ml_models = PetabScimlStandard.load_data(test / solutions["net_file"]) + if test.stem.endswith("_alt"): + net_file = ( + test.parent / test.stem.replace("_alt", "") / solutions["net_file"] + ) + else: + net_file = test / solutions["net_file"] + ml_models = PetabScimlStandard.load_data(net_file) nets = {} outdir = Path(__file__).parent / "models" / test.stem @@ -151,6 +157,7 @@ def _test_net(test): "net_021", "net_022", # Conv layers "net_004", + "net_004_alt", "net_005", "net_006", "net_007", @@ -277,7 +284,6 @@ def _test_ude(test): ) # gradient - sllh, _ = eqx.filter_grad(run_simulations, has_aux=True)( jax_problem, solver=diffrax.Tsit5(), From b9add9d947853733e9b3a981abe55d73e19e9844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Tue, 3 Dec 2024 11:29:22 +0000 Subject: [PATCH 15/92] refactor to pytests --- tests/sciml/{testsuite.py => test_sciml.py} | 145 +++++++++----------- 1 file changed, 62 insertions(+), 83 deletions(-) rename tests/sciml/{testsuite.py => test_sciml.py} (76%) diff --git a/tests/sciml/testsuite.py b/tests/sciml/test_sciml.py similarity index 76% rename from tests/sciml/testsuite.py rename to tests/sciml/test_sciml.py index c4297efc01..75205b0093 100644 --- a/tests/sciml/testsuite.py +++ b/tests/sciml/test_sciml.py @@ -1,7 +1,7 @@ from yaml import safe_load +import pytest from pathlib import Path -from petab.v2 import Problem import petab.v1 as petab from amici.petab import import_petab_problem from amici.jax import JAXProblem, generate_equinox, run_simulations @@ -37,40 +37,43 @@ def change_directory(destination): # pip install git+https://github.com/sebapersson/petab_sciml@add_standard#egg=petab_sciml\&subdirectory=src/python +cases_dir = Path(__file__).parent / "testsuite" / "test_cases" -def _test_net(test): - print(f"Running net test: {test.stem}") - with open(test / "solutions.yaml") as f: - solutions = safe_load(f) - if test.stem in ( - "net_042", - "net_043", - "net_044", - "net_045", # BatchNorm - "net_009", - "net_018", # MaxPool with dilation - "net_020", # AlphaDropout - "net_019", - "net_021", - "net_022", - "net_024", # inplace Dropout - "net_002", # Bilinear - ): - return +@pytest.mark.parametrize( + "test", [d.stem for d in cases_dir.glob("net_[0-9]*")] +) +def test_net(test): + test_dir = cases_dir / test + with open(test_dir / "solutions.yaml") as f: + solutions = safe_load(f) - if test.stem.endswith("_alt"): - net_file = ( - test.parent / test.stem.replace("_alt", "") / solutions["net_file"] - ) + if test.endswith("_alt"): + net_file = cases_dir / test.replace("_alt", "") / solutions["net_file"] else: - net_file = test / solutions["net_file"] + net_file = test_dir / solutions["net_file"] ml_models = PetabScimlStandard.load_data(net_file) nets = {} - outdir = Path(__file__).parent / "models" / test.stem + outdir = Path(__file__).parent / "models" / test for ml_model in ml_models.models: module_dir = outdir / f"{ml_model.mlmodel_id}.py" + if test in ( + "net_022", + "net_002", + "net_045", + "net_042", + "net_018", + "net_020", + "net_043", + "net_044", + "net_021", + "net_019", + "net_002", + ): + with pytest.raises(NotImplementedError): + generate_equinox(ml_model, module_dir) + return generate_equinox(ml_model, module_dir) nets[ml_model.mlmodel_id] = amici._module_from_path( ml_model.mlmodel_id, module_dir @@ -81,7 +84,7 @@ def _test_net(test): solutions.get("net_ps", solutions["net_input"]), solutions["net_output"], ): - input_flat = pd.read_csv(test / input_file, sep="\t").sort_values( + input_flat = pd.read_csv(test_dir / input_file, sep="\t").sort_values( by="ix" ) input_shape = tuple( @@ -94,9 +97,9 @@ def _test_net(test): ) input = jnp.array(input_flat["value"].values).reshape(input_shape) - output_flat = pd.read_csv(test / output_file, sep="\t").sort_values( - by="ix" - ) + output_flat = pd.read_csv( + test_dir / output_file, sep="\t" + ).sort_values(by="ix") output_shape = tuple( np.stack( output_flat["ix"].astype(str).str.split(";").apply(np.array) @@ -109,7 +112,7 @@ def _test_net(test): if "net_ps" in solutions: par = ( - pd.read_csv(test / par_file, sep="\t") + pd.read_csv(test_dir / par_file, sep="\t") .set_index("parameterId") .sort_index() ) @@ -148,22 +151,6 @@ def _test_net(test): ).reshape(net.layers[layer].bias.shape), ) net = eqx.nn.inference_mode(net) - net.forward(input) - if test.stem in ( - "net_046", - "net_047", - "net_048", - "net_050", # Conv layers - "net_021", - "net_022", # Conv layers - "net_004", - "net_004_alt", - "net_005", - "net_006", - "net_007", - "net_008", # Conv layers - ): - return np.testing.assert_allclose( net.forward(input), @@ -173,14 +160,15 @@ def _test_net(test): ) -def _test_ude(test): - print(f"Running ude test: {test.stem}") - with open(test / "petab" / "problem_ude.yaml") as f: +@pytest.mark.parametrize("test", [d.stem for d in cases_dir.glob("[0-9]*")]) +def test_ude(test): + test_dir = cases_dir / test + with open(test_dir / "petab" / "problem_ude.yaml") as f: petab_yaml = safe_load(f) - with open(test / "solutions.yaml") as f: + with open(test_dir / "solutions.yaml") as f: solutions = safe_load(f) - with change_directory(test / "petab"): + with change_directory(test_dir / "petab"): petab_yaml["format_version"] = "2.0.0" for problem in petab_yaml["problems"]: problem["model_files"] = { @@ -229,16 +217,35 @@ def _test_ude(test): ) df.to_csv(petab_yaml["parameter_file"][1], sep="\t", index=False) + from petab.v2 import Problem + petab_problem = Problem.from_yaml(petab_yaml) jax_model = import_petab_problem( petab_problem, - model_output_dir=Path(__file__).parent / "models" / test.stem, + model_output_dir=Path(__file__).parent / "models" / test, + compile_=True, jax=True, ) jax_problem = JAXProblem(jax_model, petab_problem) # llh + if test in ( + "012", + "013", + "014", + "001", + "011", + "016", + "010", + "010", + "003", + "004", + "005", + ): + with pytest.raises(NotImplementedError): + run_simulations(jax_problem) + return llh, r = run_simulations(jax_problem) np.testing.assert_allclose( llh, @@ -248,7 +255,7 @@ def _test_ude(test): ) simulations = pd.concat( [ - pd.read_csv(test / simulation, sep="\t") + pd.read_csv(test_dir / simulation, sep="\t") for simulation in solutions["simulation_files"] ] ) @@ -292,7 +299,7 @@ def _test_ude(test): expected = ( pd.concat( [ - pd.read_csv(test / simulation, sep="\t") + pd.read_csv(test_dir / simulation, sep="\t") for simulation in solutions["grad_llh_files"] ] ) @@ -314,37 +321,9 @@ def _test_ude(test): sllh.model.nns[net].layers[layer], attribute )[*index].item() actual = pd.Series(actual_dict).sort_index() - if test.stem in ("015",): - return np.testing.assert_allclose( actual.values, expected["value"].values, atol=solutions["tol_grad_llh"], rtol=solutions["tol_grad_llh"], ) - - -if __name__ == "__main__": - print("Running from testsuite.py") - test_case_dir = Path(__file__).parent / "testsuite" / "test_cases" - test_cases = list(test_case_dir.glob("*")) - for test in test_cases: - if test.stem.startswith("net_"): - _test_net(test) - elif test.stem.startswith("0"): - if test.stem in ( - "002", # nn in ode, rhs assignment - "004", # nn input in condition table - "015", # passing, wrong gradient - "016", # files in condition table - "001", - "005", - "010", - "011", - "012", - "013", - "014", # nn in ode - "008", # nn in initial condition - ): - continue - _test_ude(test) From b8632f167a15256bd3eb16b7c1cd22015f405ced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Tue, 3 Dec 2024 12:24:19 +0000 Subject: [PATCH 16/92] fixup merge --- python/sdist/amici/jax/jax.template.py | 3 +-- python/sdist/amici/jax/ode_export.py | 22 ++++++++++++++++++++++ python/sdist/amici/sbml_import.py | 2 ++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/python/sdist/amici/jax/jax.template.py b/python/sdist/amici/jax/jax.template.py index 4eca618143..f9de581b1e 100644 --- a/python/sdist/amici/jax/jax.template.py +++ b/python/sdist/amici/jax/jax.template.py @@ -20,7 +20,7 @@ def __init__(self): super().__init__() def _xdot(self, t, x, args): - pk, tcl = args + p, tcl = args TPL_X_SYMS = x TPL_P_SYMS = p @@ -31,7 +31,6 @@ def _xdot(self, t, x, args): return TPL_XDOT_RET - def _w(self, t, x, p, tcl): TPL_X_SYMS = x TPL_P_SYMS = p diff --git a/python/sdist/amici/jax/ode_export.py b/python/sdist/amici/jax/ode_export.py index 7ea4a29d8a..385bc65e07 100644 --- a/python/sdist/amici/jax/ode_export.py +++ b/python/sdist/amici/jax/ode_export.py @@ -24,6 +24,7 @@ from amici._codegen.template import apply_template from amici.jax.jaxcodeprinter import AmiciJaxCodePrinter from amici.jax.model import JAXModel +from amici.jax.nn import generate_equinox from amici.de_model import DEModel from amici.de_export import is_valid_identifier from amici.import_utils import ( @@ -129,6 +130,7 @@ def __init__( outdir: Path | str | None = None, verbose: bool | int | None = False, model_name: str | None = "model", + hybridisation: dict[str, str] = {}, ): """ Generate AMICI jax files for the ODE provided to the constructor. @@ -157,6 +159,8 @@ def __init__( self.model: DEModel = ode_model + self.hybridisation = hybridisation + self._code_printer = AmiciJaxCodePrinter() @log_execution_time("generating jax code", logger) @@ -169,6 +173,7 @@ def generate_model_code(self) -> None: ): self._prepare_model_folder() self._generate_jax_code() + self._generate_nn_code() def _prepare_model_folder(self) -> None: """ @@ -233,6 +238,14 @@ def _generate_jax_code(self) -> None: # can flag conflicts in the future "MODEL_API_VERSION": f"'{JAXModel.MODEL_API_VERSION}'", }, + "NET_IMPORTS": "\n".join( + f"{net} = _module_from_path('{net}', Path(__file__).parent / '{net}.py')" + for net in self.hybridisation.keys() + ), + "NETS": ",\n".join( + f'"{net}": {net}.net(jr.PRNGKey(0))' + for net in self.hybridisation.keys() + ), } outdir = self.model_path / (self.model_name + "_jax") outdir.mkdir(parents=True, exist_ok=True) @@ -243,6 +256,15 @@ def _generate_jax_code(self) -> None: tpl_data, ) + def _generate_nn_code(self) -> None: + for net_name, net in self.hybridisation.items(): + generate_equinox( + net["model"], + os.path.join( + self.model_path, self.model_name + "_jax", f"{net_name}.py" + ), + ) + def set_paths(self, output_dir: str | Path | None = None) -> None: """ Set output paths for the model and create if necessary diff --git a/python/sdist/amici/sbml_import.py b/python/sdist/amici/sbml_import.py index cb5c80ea88..9e66a5d924 100644 --- a/python/sdist/amici/sbml_import.py +++ b/python/sdist/amici/sbml_import.py @@ -460,6 +460,7 @@ def sbml2jax( simplify: Callable | None = _default_simplify, cache_simplify: bool = False, log_as_log10: bool = True, + hybridisation: dict = None, ) -> None: """ Generate and compile AMICI jax files for the model provided to the @@ -549,6 +550,7 @@ def sbml2jax( model_name=model_name, outdir=output_dir, verbose=verbose, + hybridisation=hybridisation, ) exporter.generate_model_code() From 031b524160ec0d1f312e6585ba484c2922815dee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Tue, 3 Dec 2024 15:23:14 +0000 Subject: [PATCH 17/92] fix net test-cases --- python/sdist/amici/jax/nn.py | 38 ++-------- tests/sciml/test_sciml.py | 130 ++++++++++++++++++++++------------- 2 files changed, 87 insertions(+), 81 deletions(-) diff --git a/python/sdist/amici/jax/nn.py b/python/sdist/amici/jax/nn.py index 1238625f10..343a749ea6 100644 --- a/python/sdist/amici/jax/nn.py +++ b/python/sdist/amici/jax/nn.py @@ -89,14 +89,13 @@ def _process_argval(v): def _generate_layer(layer: Layer, indent: int, ilayer: int) -> str: layer_map = { - "InstanceNorm1d": "eqx.nn.LayerNorm", - "InstanceNorm2d": "eqx.nn.LayerNorm", - "InstanceNorm3d": "eqx.nn.LayerNorm", "Dropout1d": "eqx.nn.Dropout", "Dropout2d": "eqx.nn.Dropout", "Flatten": "amici.jax.nn.Flatten", } - if layer.layer_type.startswith(("BatchNorm", "AlphaDropout")): + if layer.layer_type.startswith( + ("BatchNorm", "AlphaDropout", "InstanceNorm") + ): raise NotImplementedError( f"{layer.layer_type} layers currently not supported" ) @@ -117,30 +116,12 @@ def _generate_layer(layer: Layer, indent: int, ilayer: int) -> str: "Conv2d": { "bias": "use_bias", }, - "InstanceNorm1d": { - "affine": "elementwise_affine", - "num_features": "shape", - }, - "InstanceNorm2d": { - "affine": "elementwise_affine", - "num_features": "shape", - }, - "InstanceNorm3d": { - "affine": "elementwise_affine", - "num_features": "shape", - }, "LayerNorm": { "affine": "elementwise_affine", "normalized_shape": "shape", }, } kwarg_ignore = { - "InstanceNorm1d": ("track_running_stats", "momentum"), - "InstanceNorm2d": ("track_running_stats", "momentum"), - "InstanceNorm3d": ("track_running_stats", "momentum"), - "BatchNorm1d": ("track_running_stats", "momentum"), - "BatchNorm2d": ("track_running_stats", "momentum"), - "BatchNorm3d": ("track_running_stats", "momentum"), "Dropout1d": ("inplace",), "Dropout2d": ("inplace",), } @@ -162,13 +143,6 @@ def _generate_layer(layer: Layer, indent: int, ilayer: int) -> str: kwargs += [f"key=keys[{ilayer}]"] type_str = layer_map.get(layer.layer_type, f"eqx.nn.{layer.layer_type}") layer_str = f"{type_str}({', '.join(kwargs)})" - if layer.layer_type.startswith(("InstanceNorm",)): - if layer.layer_type.endswith(("1d", "2d", "3d")): - layer_str = f"jax.vmap({layer_str}, in_axes=1, out_axes=1)" - if layer.layer_type.endswith(("2d", "3d")): - layer_str = f"jax.vmap({layer_str}, in_axes=2, out_axes=2)" - if layer.layer_type.endswith("3d"): - layer_str = f"jax.vmap({layer_str}, in_axes=3, out_axes=3)" return f"{' ' * indent}'{layer.layer_id}': {layer_str}" @@ -179,10 +153,8 @@ def _generate_forward(node: Node, indent, layer_type=str) -> str: if node.op == "call_module": fun_str = f"self.layers['{node.target}']" - if layer_type.startswith( - ("InstanceNorm", "Conv", "Linear", "LayerNorm") - ): - if layer_type in ("LayerNorm", "InstanceNorm"): + if layer_type.startswith(("Conv", "Linear", "LayerNorm")): + if layer_type in ("LayerNorm",): dims = f"len({fun_str}.shape)+1" if layer_type == "Linear": dims = 2 diff --git a/tests/sciml/test_sciml.py b/tests/sciml/test_sciml.py index 75205b0093..4986899fd1 100644 --- a/tests/sciml/test_sciml.py +++ b/tests/sciml/test_sciml.py @@ -40,8 +40,26 @@ def change_directory(destination): cases_dir = Path(__file__).parent / "testsuite" / "test_cases" +def _reshape_flat_array(array_flat): + array_flat["ix"] = array_flat["ix"].astype(str) + ix_cols = [ + f"ix_{i}" for i in range(len(array_flat["ix"].values[0].split(";"))) + ] + if len(ix_cols) == 1: + array_flat[ix_cols[0]] = array_flat["ix"].apply(int) + else: + array_flat[ix_cols] = pd.DataFrame( + array_flat["ix"].str.split(";").apply(np.array).to_list(), + index=array_flat.index, + ).astype(int) + array_flat.sort_values(by=ix_cols, inplace=True) + array_shape = tuple(array_flat[ix_cols].max().astype(int) + 1) + array = np.array(array_flat["value"].values).reshape(array_shape) + return array + + @pytest.mark.parametrize( - "test", [d.stem for d in cases_dir.glob("net_[0-9]*")] + "test", sorted([d.stem for d in cases_dir.glob("net_[0-9]*")]) ) def test_net(test): test_dir = cases_dir / test @@ -59,17 +77,20 @@ def test_net(test): for ml_model in ml_models.models: module_dir = outdir / f"{ml_model.mlmodel_id}.py" if test in ( - "net_022", "net_002", - "net_045", - "net_042", + "net_009", "net_018", + "net_019", "net_020", + "net_021", + "net_022", + "net_042", "net_043", "net_044", - "net_021", - "net_019", - "net_002", + "net_045", + "net_046", + "net_047", + "net_048", ): with pytest.raises(NotImplementedError): generate_equinox(ml_model, module_dir) @@ -84,38 +105,14 @@ def test_net(test): solutions.get("net_ps", solutions["net_input"]), solutions["net_output"], ): - input_flat = pd.read_csv(test_dir / input_file, sep="\t").sort_values( - by="ix" - ) - input_shape = tuple( - np.stack( - input_flat["ix"].astype(str).str.split(";").apply(np.array) - ) - .astype(int) - .max(axis=0) - + 1 - ) - input = jnp.array(input_flat["value"].values).reshape(input_shape) - - output_flat = pd.read_csv( - test_dir / output_file, sep="\t" - ).sort_values(by="ix") - output_shape = tuple( - np.stack( - output_flat["ix"].astype(str).str.split(";").apply(np.array) - ) - .astype(int) - .max(axis=0) - + 1 - ) - output = jnp.array(output_flat["value"].values).reshape(output_shape) + input_flat = pd.read_csv(test_dir / input_file, sep="\t") + input = _reshape_flat_array(input_flat) + + output_flat = pd.read_csv(test_dir / output_file, sep="\t") + output = _reshape_flat_array(output_flat) if "net_ps" in solutions: - par = ( - pd.read_csv(test_dir / par_file, sep="\t") - .set_index("parameterId") - .sort_index() - ) + par = pd.read_csv(test_dir / par_file, sep="\t") for ml_model in ml_models.models: net = nets[ml_model.mlmodel_id](jr.PRNGKey(0)) for layer in net.layers.keys(): @@ -126,14 +123,26 @@ def test_net(test): and net.layers[layer].weight is not None ): prefix = layer_prefix + "_weight" + df = par[ + par[petab.PARAMETER_ID].str.startswith(prefix) + ] + df["ix"] = ( + df[petab.PARAMETER_ID] + .str.split("_") + .str[3:] + .apply(lambda x: ";".join(x)) + ) + w = _reshape_flat_array(df) + if isinstance(net.layers[layer], eqx.nn.ConvTranspose): + # see FAQ in https://docs.kidger.site/equinox/api/nn/conv/#equinox.nn.ConvTranspose + w = np.flip( + w, axis=tuple(range(2, w.ndim)) + ).swapaxes(0, 1) + assert w.shape == net.layers[layer].weight.shape net = eqx.tree_at( lambda x: x.layers[layer].weight, net, - jnp.array( - par[par.index.str.startswith(prefix)][ - "value" - ].values - ).reshape(net.layers[layer].weight.shape), + jnp.array(w), ) if ( isinstance(net.layers[layer], eqx.Module) @@ -141,17 +150,40 @@ def test_net(test): and net.layers[layer].bias is not None ): prefix = layer_prefix + "_bias" + df = par[ + par[petab.PARAMETER_ID].str.startswith(prefix) + ] + df["ix"] = ( + df[petab.PARAMETER_ID] + .str.split("_") + .str[3:] + .apply(lambda x: ";".join(x)) + ) + b = _reshape_flat_array(df) + if isinstance( + net.layers[layer], + eqx.nn.Conv | eqx.nn.ConvTranspose, + ): + b = np.expand_dims( + b, + tuple( + range( + 1, + net.layers[layer].num_spatial_dims + 1, + ) + ), + ) + assert b.shape == net.layers[layer].bias.shape net = eqx.tree_at( lambda x: x.layers[layer].bias, net, - jnp.array( - par[par.index.str.startswith(prefix)][ - "value" - ].values - ).reshape(net.layers[layer].bias.shape), + jnp.array(b), ) net = eqx.nn.inference_mode(net) + if test == "net_004_alt": + return # skipping, no support for non-cross-correlation in equinox + np.testing.assert_allclose( net.forward(input), output, @@ -160,7 +192,9 @@ def test_net(test): ) -@pytest.mark.parametrize("test", [d.stem for d in cases_dir.glob("[0-9]*")]) +@pytest.mark.parametrize( + "test", sorted([d.stem for d in cases_dir.glob("[0-9]*")]) +) def test_ude(test): test_dir = cases_dir / test with open(test_dir / "petab" / "problem_ude.yaml") as f: From cfb0b5a5485f87a41e900c063f060507285436a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Tue, 3 Dec 2024 17:54:56 +0000 Subject: [PATCH 18/92] fixes & remove sciml dependency --- python/sdist/amici/jax/nn.py | 11 +++++++---- python/sdist/amici/jax/ode_export.py | 4 ++-- tests/sciml/test_sciml.py | 5 +++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/python/sdist/amici/jax/nn.py b/python/sdist/amici/jax/nn.py index 343a749ea6..d503df2393 100644 --- a/python/sdist/amici/jax/nn.py +++ b/python/sdist/amici/jax/nn.py @@ -1,6 +1,6 @@ from pathlib import Path -from petab_sciml import MLModel, Layer, Node + import equinox as eqx import jax.numpy as jnp @@ -30,7 +30,10 @@ def tanhshrink(x: jnp.ndarray) -> jnp.ndarray: return x - jnp.tanh(x) -def generate_equinox(ml_model: MLModel, filename: Path | str): +def generate_equinox(ml_model: "MLModel", filename: Path | str): # noqa: F821 + # TODO: move to top level import and replace forward type definitions + from petab_sciml import Layer + filename = Path(filename) layer_indent = 12 node_indent = 8 @@ -87,7 +90,7 @@ def _process_argval(v): return str(v) -def _generate_layer(layer: Layer, indent: int, ilayer: int) -> str: +def _generate_layer(layer: "Layer", indent: int, ilayer: int) -> str: # noqa: F821 layer_map = { "Dropout1d": "eqx.nn.Dropout", "Dropout2d": "eqx.nn.Dropout", @@ -146,7 +149,7 @@ def _generate_layer(layer: Layer, indent: int, ilayer: int) -> str: return f"{' ' * indent}'{layer.layer_id}': {layer_str}" -def _generate_forward(node: Node, indent, layer_type=str) -> str: +def _generate_forward(node: "Node", indent, layer_type=str) -> str: # noqa: F821 if node.op == "placeholder": # TODO: inconsistent target vs name return f"{' ' * indent}{node.name} = input" diff --git a/python/sdist/amici/jax/ode_export.py b/python/sdist/amici/jax/ode_export.py index 385bc65e07..f36f67ab85 100644 --- a/python/sdist/amici/jax/ode_export.py +++ b/python/sdist/amici/jax/ode_export.py @@ -130,7 +130,7 @@ def __init__( outdir: Path | str | None = None, verbose: bool | int | None = False, model_name: str | None = "model", - hybridisation: dict[str, str] = {}, + hybridisation: dict[str, dict] = None, ): """ Generate AMICI jax files for the ODE provided to the constructor. @@ -159,7 +159,7 @@ def __init__( self.model: DEModel = ode_model - self.hybridisation = hybridisation + self.hybridisation = hybridisation if hybridisation is not None else {} self._code_printer = AmiciJaxCodePrinter() diff --git a/tests/sciml/test_sciml.py b/tests/sciml/test_sciml.py index 4986899fd1..e872718f48 100644 --- a/tests/sciml/test_sciml.py +++ b/tests/sciml/test_sciml.py @@ -327,8 +327,9 @@ def test_ude(test): # gradient sllh, _ = eqx.filter_grad(run_simulations, has_aux=True)( jax_problem, - solver=diffrax.Tsit5(), - controller=diffrax.PIDController(atol=1e-10, rtol=1e-10), + solver=diffrax.Kvaerno5(), + controller=diffrax.PIDController(atol=1e-14, rtol=1e-14), + max_steps=2**16, ) expected = ( pd.concat( From 8b8f9a860986a84188bae8fb2bfa9208ddb2ba8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Sun, 8 Dec 2024 10:24:04 +0000 Subject: [PATCH 19/92] fixup, add initial condition support --- .github/workflows/test_petab_sciml.yml | 93 ++++ documentation/ExampleJaxPEtab.ipynb | 672 ++++++++++++++++++++++++- python/sdist/amici/jax/ode_export.py | 4 +- python/sdist/amici/jax/petab.py | 50 +- tests/sciml/test_sciml.py | 41 +- 5 files changed, 812 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/test_petab_sciml.yml mode change 120000 => 100644 documentation/ExampleJaxPEtab.ipynb diff --git a/.github/workflows/test_petab_sciml.yml b/.github/workflows/test_petab_sciml.yml new file mode 100644 index 0000000000..2b0976c824 --- /dev/null +++ b/.github/workflows/test_petab_sciml.yml @@ -0,0 +1,93 @@ +name: PEtab +on: + push: + branches: + - develop + - master + pull_request: + branches: + - master + - develop + merge_group: + workflow_dispatch: + +jobs: + build: + name: PEtab SciML Testsuite + + runs-on: ubuntu-latest + + env: + ENABLE_GCOV_COVERAGE: TRUE + + strategy: + matrix: + python-version: ["3.11"] + + steps: + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - uses: actions/checkout@v4 + with: + fetch-depth: 20 + + - name: Install apt dependencies + uses: ./.github/actions/install-apt-dependencies + + # install dependencies + - name: apt + run: | + sudo apt-get update \ + && sudo apt-get install -y python3-venv + + - run: | + echo "${HOME}/.local/bin/" >> $GITHUB_PATH + + # install AMICI + - name: Install python package + run: scripts/installAmiciSource.sh + + - name: Install petab + run: | + source ./venv/bin/activate \ + && pip3 install wheel pytest shyaml pytest-cov + + # retrieve test models + - name: Download and install PEtab SciML test suite + run: | + git clone --depth 1 --branch main \ + https://github.com/sebapersson/petab_sciml.git \ + && export TESTSUITE="$(pwd)/petab_sciml" \ + && source venv/bin/activate \ + && python -m pip install -e $TESTSUITE/../src/python + + - name: Install PEtab benchmark collection + run: | + git clone --depth 1 https://github.com/benchmarking-initiative/Benchmark-Models-PEtab.git \ + && export BENCHMARK_COLLECTION="$(pwd)/Benchmark-Models-PEtab/Benchmark-Models/" \ + && source venv/bin/activate && python -m pip install -e $BENCHMARK_COLLECTION/../src/python + + - name: Install petab + run: | + source ./venv/bin/activate \ + && python3 -m pip uninstall -y petab \ + && python3 -m pip install git+https://github.com/petab-dev/libpetab-python.git@develop \ + + - name: Run PEtab SciML testsuite + run: | + source ./venv/bin/activate \ + + && pytest --cov-report=xml:coverage.xml \ + --cov=./ python/tests/test_*petab*.py python/tests/sciml/ + + - name: Codecov + if: github.event_name == 'pull_request' || github.repository_owner == 'AMICI-dev' + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: coverage.xml + flags: petab + fail_ci_if_error: true diff --git a/documentation/ExampleJaxPEtab.ipynb b/documentation/ExampleJaxPEtab.ipynb deleted file mode 120000 index 821b14f21f..0000000000 --- a/documentation/ExampleJaxPEtab.ipynb +++ /dev/null @@ -1 +0,0 @@ -../python/examples/example_jax_petab/ExampleJaxPEtab.ipynb \ No newline at end of file diff --git a/documentation/ExampleJaxPEtab.ipynb b/documentation/ExampleJaxPEtab.ipynb new file mode 100644 index 0000000000..1310091f4c --- /dev/null +++ b/documentation/ExampleJaxPEtab.ipynb @@ -0,0 +1,671 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "d4d2bc5c", + "metadata": {}, + "source": [ + "# Simulating AMICI models using JAX\n", + "\n", + "## Overview\n", + "\n", + "This guide demonstrates how to use AMICI to export models in a format compatible with the [JAX](https://jax.readthedocs.io/en/latest/) ecosystem, enabling simulations with the [diffrax](https://docs.kidger.site/diffrax/) library. " + ] + }, + { + "cell_type": "markdown", + "id": "fb2fe897", + "metadata": {}, + "source": [ + "## Preparation\n", + "\n", + "To begin, we will import a model using [PEtab](https://petab.readthedocs.io). For this demonstration, we will utilize the [Benchmark Collection](https://github.com/Benchmarking-Initiative/Benchmark-Models-PEtab), which provides a diverse set of models. For more information on importing PEtab models, refer to the corresponding [PEtab notebook](https://amici.readthedocs.io/en/latest/petab.html).\n", + "\n", + "In this tutorial, we will import the Böhm model from the Benchmark Collection. Using [amici.petab_import](https://amici.readthedocs.io/en/latest/generated/amici.petab_import.html#amici.petab_import.import_petab_problem), we will load the PEtab problem. To create a [JAXModel](https://amici.readthedocs.io/en/latest/generated/amici.jax.html#amici.jax.JAXModel) instead of a standard AMICI model, we set the `jax` parameter to `True`.\n" + ] + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "from amici.petab.petab_import import import_petab_problem\n", + "import petab.v1 as petab\n", + "\n", + "# Define the model name and YAML file location\n", + "model_name = \"Boehm_JProteomeRes2014\"\n", + "yaml_url = (\n", + " f\"https://raw.githubusercontent.com/Benchmarking-Initiative/Benchmark-Models-PEtab/\"\n", + " f\"master/Benchmark-Models/{model_name}/{model_name}.yaml\"\n", + ")\n", + "\n", + "# Load the PEtab problem from the YAML file\n", + "petab_problem = petab.Problem.from_yaml(yaml_url)\n", + "\n", + "# Import the PEtab problem as a JAX-compatible AMICI model\n", + "jax_model = import_petab_problem(\n", + " petab_problem,\n", + " verbose=False, # no text output\n", + " jax=True, # return jax model\n", + ")" + ], + "id": "c71c96da0da3144a" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "## Simulation\n", + "\n", + "In principle, we can already use this model for simulation using the [simulate_condition](https://amici.readthedocs.io/en/latest/generated/amici.jax.html#amici.jax.JAXModel.simulate_condition) method. However, this approach can be cumbersome as timepoints, data etc. need to be specified manually. Instead, we process the PEtab problem into a [JAXProblem](https://amici.readthedocs.io/en/latest/generated/amici.jax.html#amici.jax.JAXProblem), which enables efficient simulation using [amici.jax.run_simulations]((https://amici.readthedocs.io/en/latest/generated/amici.jax.html#amici.jax.run_simulations)." + ], + "id": "7e0f1c27bd71ee1f" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "from amici.jax import JAXProblem, run_simulations\n", + "\n", + "# Create a JAXProblem from the JAX model and PEtab problem\n", + "jax_problem = JAXProblem(jax_model, petab_problem)\n", + "\n", + "# Run simulations and compute the log-likelihood\n", + "llh, results = run_simulations(jax_problem)" + ], + "id": "ccecc9a29acc7b73" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "This simulates the model for all conditions using the nominal parameter values. Simple, right? Now, let’s take a look at the simulation results.", + "id": "415962751301c64a" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "# Define the simulation condition\n", + "simulation_condition = (\"model1_data1\",)\n", + "\n", + "# Access the results for the specified condition\n", + "results[simulation_condition]" + ], + "id": "596b86e45e18fe3d" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "Unfortunately, the simulation failed! As seen in the output, the simulation broke down after the initial timepoint, indicated by the `inf` values in the state variables `results[simulation_condition][1].x` and the `nan` likelihood value. A closer inspection of this variable provides additional clues about what might have gone wrong.\n", + "\n", + "The issue stems from using single precision, as indicated by the `float32` dtype of state variables. Single precision is generally a [bad idea](https://docs.kidger.site/diffrax/examples/stiff_ode/) for stiff systems like the Böhm model. Let’s retry the simulation with double precision." + ], + "id": "a1b173e013f9210a" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "import jax\n", + "\n", + "# Enable double precision in JAX\n", + "jax.config.update(\"jax_enable_x64\", True)\n", + "\n", + "# Re-run simulations with double precision\n", + "llh, results = run_simulations(jax_problem)\n", + "\n", + "results" + ], + "id": "f4f5ff705a3f7402" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "Success! The simulation completed successfully, and we can now plot the resulting state trajectories.", + "id": "fe4d3b40ee3efdf2" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "%matplotlib inline\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "\n", + "def plot_simulation(results):\n", + " \"\"\"\n", + " Plot the state trajectories from the simulation results.\n", + "\n", + " Parameters:\n", + " results (dict): Simulation results from run_simulations.\n", + " \"\"\"\n", + " # Extract the simulation results for the specific condition\n", + " sim_results = results[simulation_condition]\n", + "\n", + " # Create a new figure for the state trajectories\n", + " plt.figure(figsize=(8, 6))\n", + " for idx in range(sim_results[\"x\"].shape[1]):\n", + " time_points = np.array(sim_results[\"ts\"])\n", + " state_values = np.array(sim_results[\"x\"][:, idx])\n", + " plt.plot(time_points, state_values, label=jax_model.state_ids[idx])\n", + "\n", + " # Add labels, legend, and grid\n", + " plt.xlabel(\"Time\")\n", + " plt.ylabel(\"State Values\")\n", + " plt.title(simulation_condition)\n", + " plt.legend()\n", + " plt.grid(True)\n", + " plt.show()\n", + "\n", + "\n", + "# Plot the simulation results\n", + "plot_simulation(results)" + ], + "id": "72f1ed397105e14a" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "`run_simulations` enables users to specify the simulation conditions to be executed. For more complex models, this allows for restricting simulations to a subset of conditions. Since the Böhm model includes only a single condition, we demonstrate this functionality by simulating no condition at all.", + "id": "4fa97c33719c2277" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "llh, results = run_simulations(jax_problem, simulation_conditions=tuple())\n", + "results" + ], + "id": "7950774a3e989042" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "## Updating Parameters\n", + "\n", + "As next step, we will update the parameter values used for simulation. However, if we attempt to directly modify the values in `JAXModel.parameters`, we encounter a `FrozenInstanceError`." + ], + "id": "98b8516a75ce4d12" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "from dataclasses import FrozenInstanceError\n", + "import jax\n", + "\n", + "# Generate random noise to update the parameters\n", + "noise = (\n", + " jax.random.normal(\n", + " key=jax.random.PRNGKey(0), shape=jax_problem.parameters.shape\n", + " )\n", + " / 10\n", + ")\n", + "\n", + "# Attempt to update the parameters\n", + "try:\n", + " jax_problem.parameters += noise\n", + "except FrozenInstanceError as e:\n", + " print(\"Error:\", e)" + ], + "id": "3d278a3d21e709d" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "The root cause of this error lies in the fact that, to enable autodiff, direct modifications of attributes are not allowed in [equinox](https://docs.kidger.site/equinox/), which AMICI utilizes under the hood. Consequently, attributes of instances like `JAXModel` or `JAXProblem` cannot be updated directly — this is the price we have to pay for autodiff.\n", + "\n", + "However, `JAXProblem` provides a convenient method called [update_parameters](https://amici.readthedocs.io/en/latest/generated/amici.jax.html#amici.jax.JAXProblem.update_parameters). The caveat is that this method creates a new JAXProblem instance instead of modifying the existing one." + ], + "id": "4cc3d595de4a4085" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "# Update the parameters and create a new JAXProblem instance\n", + "jax_problem = jax_problem.update_parameters(jax_problem.parameters + noise)\n", + "\n", + "# Run simulations with the updated parameters\n", + "llh, results = run_simulations(jax_problem)\n", + "\n", + "# Plot the simulation results\n", + "plot_simulation(results)" + ], + "id": "e47748376059628b" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "## Computing Gradients\n", + "\n", + "Similar to updating attributes, computing gradients in the JAX ecosystem can feel a bit unconventional if you’re not familiar with the JAX ecosysmt. JAX offers [powerful automatic differentiation](https://jax.readthedocs.io/en/latest/automatic-differentiation.html) through the `jax.grad` function. However, to use `jax.grad` with `JAXProblem`, we need to specify which parts of the `JAXProblem` should be treated as static." + ], + "id": "660baf605a4e8339" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "try:\n", + " # Attempt to compute the gradient of the run_simulations function\n", + " jax.grad(run_simulations, has_aux=True)(jax_problem)\n", + "except TypeError as e:\n", + " print(\"Error:\", e)" + ], + "id": "7033d09cc81b7f69" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "Fortunately, `equinox` simplifies this process by offering [filter_grad](https://docs.kidger.site/equinox/api/transformations/#equinox.filter_grad), which enables autodiff functionality that is compatible with `JAXProblem` and, in theory, also with `JAXModel`.", + "id": "dc9bc07cde00a926" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "import equinox as eqx\n", + "\n", + "# Compute the gradient using equinox's filter_grad, preserving auxiliary outputs\n", + "grad, _ = eqx.filter_grad(run_simulations, has_aux=True)(jax_problem)" + ], + "id": "a6704182200e6438" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "Functions transformed by `filter_grad` return gradients that share the same structure as the first argument (unless specified otherwise). This allows us to access the gradient with respect to the parameters attribute directly `via grad.parameters`.", + "id": "851c3ec94cb5d086" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": "grad.parameters", + "id": "c00c1581d7173d7a" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "Attributes for which derivatives cannot be computed (typically anything that is not a [jax.numpy.array](https://jax.readthedocs.io/en/latest/_autosummary/jax.numpy.array.html)) are automatically set to `None`.", + "id": "375b835fecc5a022" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": "grad", + "id": "f7c17f7459d0151f" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "Observant readers may notice that the gradient above appears to include numeric values for derivatives with respect to some measurements. However, `simulation_conditions` internally disables gradient computations using `jax.lax.stop_gradient`, resulting in these values being zeroed out.", + "id": "8eb7cc3db510c826" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": "grad._measurements[simulation_condition]", + "id": "3badd4402cf6b8c6" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "However, we can compute derivatives with respect to data elements using `JAXModel.simulate_condition`. In the example below, we differentiate the observables `y` (specified by passing `y` to the `ret` argument) with respect to the timepoints at which the model outputs are computed after the solving the differential equation. While this might not be particularly practical, it serves as an nice illustration of the power of automatic differentiation.", + "id": "58eb04393a1463d" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "import jax.numpy as jnp\n", + "import diffrax\n", + "from amici.jax import ReturnValue\n", + "\n", + "# Define the simulation condition\n", + "simulation_condition = (\"model1_data1\",)\n", + "\n", + "# Load condition-specific data\n", + "ts_init, ts_dyn, ts_posteq, my, iys, iy_trafos = jax_problem._measurements[\n", + " simulation_condition\n", + "]\n", + "\n", + "# Load parameters for the specified condition\n", + "p = jax_problem.load_parameters(simulation_condition[0])\n", + "\n", + "\n", + "# Define a function to compute the gradient with respect to dynamic timepoints\n", + "@eqx.filter_jacfwd\n", + "def grad_ts_dyn(tt):\n", + " return jax_problem.model.simulate_condition(\n", + " p=p,\n", + " ts_init=ts_init,\n", + " ts_dyn=tt,\n", + " ts_posteq=ts_posteq,\n", + " my=jnp.array(my),\n", + " iys=jnp.array(iys),\n", + " iy_trafos=jnp.array(iy_trafos),\n", + " solver=diffrax.Kvaerno5(),\n", + " controller=diffrax.PIDController(atol=1e-8, rtol=1e-8),\n", + " max_steps=2**10,\n", + " adjoint=diffrax.DirectAdjoint(),\n", + " ret=ReturnValue.y, # Return observables\n", + " )[0]\n", + "\n", + "\n", + "# Compute the gradient with respect to `ts_dyn`\n", + "g = grad_ts_dyn(ts_dyn)\n", + "g" + ], + "id": "1a91aff44b93157" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "## Compilation & Profiling\n", + "\n", + "To maximize performance with JAX, code should be just-in-time (JIT) compiled. This can be achieved using the `jax.jit` or `equinox.filter_jit` decorators. While JIT compilation introduces some overhead during the first function call, it significantly improves performance for subsequent calls. To demonstrate this, we will first clear the JIT cache and then profile the execution." + ], + "id": "9f870da7754e139c" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "from time import time\n", + "\n", + "# Clear JAX caches to ensure a fresh start\n", + "jax.clear_caches()\n", + "\n", + "# Define a JIT-compiled gradient function with auxiliary outputs\n", + "gradfun = eqx.filter_jit(eqx.filter_grad(run_simulations, has_aux=True))" + ], + "id": "58ebdc110ea7457e" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "# Measure the time taken for the first function call (including compilation)\n", + "start = time()\n", + "run_simulations(jax_problem)\n", + "print(f\"Function compilation time: {time() - start:.2f} seconds\")\n", + "\n", + "# Measure the time taken for the gradient computation (including compilation)\n", + "start = time()\n", + "gradfun(jax_problem)\n", + "print(f\"Gradient compilation time: {time() - start:.2f} seconds\")" + ], + "id": "e1242075f7e0faf" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "%%timeit\n", + "run_simulations(\n", + " jax_problem,\n", + " controller=diffrax.PIDController(\n", + " rtol=1e-8, # same as amici default\n", + " atol=1e-16, # same as amici default\n", + " pcoeff=0.4, # recommended value for stiff systems\n", + " icoeff=0.3, # recommended value for stiff systems\n", + " dcoeff=0.0, # recommended value for stiff systems\n", + " ),\n", + ")" + ], + "id": "27181f367ccb1817" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "%%timeit \n", + "gradfun(\n", + " jax_problem,\n", + " controller=diffrax.PIDController(\n", + " rtol=1e-8, # same as amici default\n", + " atol=1e-16, # same as amici default\n", + " pcoeff=0.4, # recommended value for stiff systems\n", + " icoeff=0.3, # recommended value for stiff systems\n", + " dcoeff=0.0, # recommended value for stiff systems\n", + " ),\n", + ")" + ], + "id": "5b8d3a6162a3ae55" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "from amici.petab import simulate_petab\n", + "import amici\n", + "\n", + "# Import the PEtab problem as a standard AMICI model\n", + "amici_model = import_petab_problem(\n", + " petab_problem,\n", + " verbose=False,\n", + " jax=False, # load the amici model this time\n", + ")\n", + "\n", + "# Configure the solver with appropriate tolerances\n", + "solver = amici_model.getSolver()\n", + "solver.setAbsoluteTolerance(1e-8)\n", + "solver.setRelativeTolerance(1e-8)\n", + "\n", + "# Prepare the parameters for the simulation\n", + "problem_parameters = dict(\n", + " zip(jax_problem.parameter_ids, jax_problem.parameters)\n", + ")" + ], + "id": "d733a450635a749b" + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "413ed7c60b2cf4be", + "metadata": { + "ExecuteTime": { + "end_time": "2024-11-19T09:51:55.259985Z", + "start_time": "2024-11-19T09:51:55.257937Z" + } + }, + "outputs": [], + "source": [ + "# Profile simulation only\n", + "solver.setSensitivityOrder(amici.SensitivityOrder.none)" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "768fa60e439ca8b4", + "metadata": { + "ExecuteTime": { + "end_time": "2024-11-19T09:51:57.417608Z", + "start_time": "2024-11-19T09:51:55.273367Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "26.1 ms ± 2.71 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)\n" + ] + } + ], + "source": [ + "%%timeit \n", + "simulate_petab(\n", + " petab_problem,\n", + " amici_model,\n", + " solver=solver,\n", + " problem_parameters=problem_parameters,\n", + " scaled_parameters=True,\n", + " scaled_gradients=True,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "b8382b0b2b68f49e", + "metadata": { + "ExecuteTime": { + "end_time": "2024-11-19T09:51:57.497361Z", + "start_time": "2024-11-19T09:51:57.494502Z" + } + }, + "outputs": [], + "source": [ + "# Profile gradient computation using forward sensitivity analysis\n", + "solver.setSensitivityOrder(amici.SensitivityOrder.first)\n", + "solver.setSensitivityMethod(amici.SensitivityMethod.forward)" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "3bae1fab8c416122", + "metadata": { + "ExecuteTime": { + "end_time": "2024-11-19T09:51:59.897459Z", + "start_time": "2024-11-19T09:51:57.511889Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "29.1 ms ± 1.82 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)\n" + ] + } + ], + "source": [ + "%%timeit \n", + "simulate_petab(\n", + " petab_problem,\n", + " amici_model,\n", + " solver=solver,\n", + " problem_parameters=problem_parameters,\n", + " scaled_parameters=True,\n", + " scaled_gradients=True,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "71e0358227e1dc74", + "metadata": { + "ExecuteTime": { + "end_time": "2024-11-19T09:51:59.972149Z", + "start_time": "2024-11-19T09:51:59.969006Z" + } + }, + "outputs": [], + "source": [ + "# Profile gradient computation using adjoint sensitivity analysis\n", + "solver.setSensitivityOrder(amici.SensitivityOrder.first)\n", + "solver.setSensitivityMethod(amici.SensitivityMethod.adjoint)" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "e3cc7971002b6d06", + "metadata": { + "ExecuteTime": { + "end_time": "2024-11-19T09:52:03.266074Z", + "start_time": "2024-11-19T09:51:59.992465Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "39.3 ms ± 1.6 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)\n" + ] + } + ], + "source": [ + "%%timeit \n", + "simulate_petab(\n", + " petab_problem,\n", + " amici_model,\n", + " solver=solver,\n", + " problem_parameters=problem_parameters,\n", + " scaled_parameters=True,\n", + " scaled_gradients=True,\n", + ")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/python/sdist/amici/jax/ode_export.py b/python/sdist/amici/jax/ode_export.py index 09c6e72a41..08ba8bc0cd 100644 --- a/python/sdist/amici/jax/ode_export.py +++ b/python/sdist/amici/jax/ode_export.py @@ -258,9 +258,7 @@ def _generate_nn_code(self) -> None: for net_name, net in self.hybridisation.items(): generate_equinox( net["model"], - os.path.join( - self.model_path, self.model_name + "_jax", f"{net_name}.py" - ), + self.model_path / f"{net_name}.py", ) def set_paths(self, output_dir: str | Path | None = None) -> None: diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index e85aded8bb..4d1cfd303a 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -107,12 +107,10 @@ def __init__(self, model: JAXModel, petab_problem: petab.Problem): self._petab_problem = petab_problem self.parameters, self.model = self._get_nominal_parameter_values(model) self._parameter_mappings = self._get_parameter_mappings(scs) - self._measurements = self._get_measurements(scs) self._inputs = self._get_inputs() self._measurements, self._petab_measurement_indices = ( self._get_measurements(scs) ) - self.parameters = self._get_nominal_parameter_values() def save(self, directory: Path): """ @@ -358,6 +356,20 @@ def parameter_ids(self) -> list[str]: self._petab_problem.parameter_df[petab.ESTIMATE] == 1 ].index.tolist() + @property + def nn_output_ids(self) -> list[str]: + """ + Parameter ids that are estimated in the PEtab problem. Same ordering as values in :attr:`parameters`. + + :return: + PEtab parameter ids + """ + return self._petab_problem.mapping_df[ + self._petab_problem.mapping_df[ + petab.MODEL_ENTITY_ID + ].str.startswith("output") + ].index.tolist() + def get_petab_parameter_by_id(self, name: str) -> jnp.float_: """ Get the value of a PEtab parameter by name. @@ -418,7 +430,19 @@ def _eval_nn(self, output_par: str): ) return nn.forward(net_input).squeeze() - def load_parameters( + def _map_model_parameter_value( + self, + mapping: ParameterMappingForCondition, + pname: str, + ) -> jt.Float[jt.Scalar, ""] | float: # noqa: F722 + if pname in self.nn_output_ids: + return self._eval_nn(pname) + pval = mapping.map_sim_var[pname] + if isinstance(pval, Number): + return pval + return self.get_petab_parameter_by_id(pval) + + def load_model_parameters( self, simulation_condition: str ) -> jt.Float[jt.Array, "np"]: """ @@ -431,19 +455,9 @@ def load_parameters( """ mapping = self._parameter_mappings[simulation_condition] - nn_output_pars = self._petab_problem.mapping_df[ - self._petab_problem.mapping_df[ - petab.MODEL_ENTITY_ID - ].str.startswith("output") - ].index - p = jnp.array( [ - self._eval_nn(pname) - if pname in nn_output_pars - else pval - if isinstance(pval := mapping.map_sim_var[pname], Number) - else self.get_petab_parameter_by_id(pval) + self._map_model_parameter_value(mapping, pname) for pname in self.model.parameter_ids ] ) @@ -499,6 +513,9 @@ def _state_reinitialisation_value( :return: reinitialisation value for the state """ + if state_id in self.nn_output_ids: + return self._eval_nn(state_id) + if state_id not in self._petab_problem.condition_df: # no reinitialisation, return dummy value return 0.0 @@ -543,6 +560,7 @@ def load_reinitialisation( """ if not any( x_id in self._petab_problem.condition_df + or x_id in self.nn_output_ids for x_id in self.model.state_ids ): return jnp.array([]), jnp.array([]) @@ -602,7 +620,7 @@ def run_simulation( ts_preeq, ts_dyn, ts_posteq, my, iys, iy_trafos = self._measurements[ simulation_condition ] - p = self.load_parameters(simulation_condition[0]) + p = self.load_model_parameters(simulation_condition[0]) mask_reinit, x_reinit = self.load_reinitialisation( simulation_condition[0], p ) @@ -647,7 +665,7 @@ def run_preequilibration( :return: Pre-equilibration state """ - p = self.load_parameters(simulation_condition) + p = self.load_model_parameters(simulation_condition) mask_reinit, x_reinit = self.load_reinitialisation( simulation_condition, p ) diff --git a/tests/sciml/test_sciml.py b/tests/sciml/test_sciml.py index e872718f48..c6a98fd8cc 100644 --- a/tests/sciml/test_sciml.py +++ b/tests/sciml/test_sciml.py @@ -4,7 +4,12 @@ from pathlib import Path import petab.v1 as petab from amici.petab import import_petab_problem -from amici.jax import JAXProblem, generate_equinox, run_simulations +from amici.jax import ( + JAXProblem, + generate_equinox, + run_simulations, + petab_simulate, +) import amici import diffrax import pandas as pd @@ -265,17 +270,16 @@ def test_ude(test): # llh if test in ( + "001", + "004", + "005", + "008", + "010", + "011", "012", "013", "014", - "001", - "011", "016", - "010", - "010", - "003", - "004", - "005", ): with pytest.raises(NotImplementedError): run_simulations(jax_problem) @@ -295,27 +299,8 @@ def test_ude(test): ) # simulations - - y, r = run_simulations(jax_problem, ret="y") - dfs = [] - for sc, ys in y.items(): - obs = [ - jax_model.observable_ids[io] - for io in jax_problem._measurements[sc][4] - ] - t = jax_problem._measurements[sc][1] - dfs.append( - pd.DataFrame( - { - petab.SIMULATION: ys, - petab.TIME: t, - petab.OBSERVABLE_ID: obs, - petab.SIMULATION_CONDITION_ID: [sc[-1]] * len(t), - } - ) - ) sort_by = [petab.OBSERVABLE_ID, petab.TIME, petab.SIMULATION_CONDITION_ID] - actual = pd.concat(dfs).sort_values(by=sort_by) + actual = petab_simulate(jax_problem).sort_values(by=sort_by) expected = simulations.sort_values(by=sort_by) np.testing.assert_allclose( actual[petab.SIMULATION].values, From 982d275087b9c1f6bd52c23367adcd3f8246be1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Sun, 8 Dec 2024 11:46:24 +0000 Subject: [PATCH 20/92] Update petab.py --- python/sdist/amici/jax/petab.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index 4d1cfd303a..0bf0d97696 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -320,6 +320,11 @@ def _get_nominal_parameter_values( ), model def _get_inputs(self): + if ( + self._petab_problem.mapping_df is None + or "netId" not in self._petab_problem.mapping_df.columns + ): + return {} inputs = { net: {} for net in self._petab_problem.mapping_df["netId"].unique() } From 33f86bbf34b68b13293edaa51857ffe92742d464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Sun, 8 Dec 2024 11:53:55 +0000 Subject: [PATCH 21/92] Update test_petab_sciml.yml --- .github/workflows/test_petab_sciml.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test_petab_sciml.yml b/.github/workflows/test_petab_sciml.yml index 2b0976c824..330a9982f9 100644 --- a/.github/workflows/test_petab_sciml.yml +++ b/.github/workflows/test_petab_sciml.yml @@ -60,15 +60,10 @@ jobs: run: | git clone --depth 1 --branch main \ https://github.com/sebapersson/petab_sciml.git \ - && export TESTSUITE="$(pwd)/petab_sciml" \ + && export SCIML_TESTSUITE="$(pwd)/petab_sciml" \ && source venv/bin/activate \ - && python -m pip install -e $TESTSUITE/../src/python + && python -m pip install -e $SCIML_TESTSUITE/src/python - - name: Install PEtab benchmark collection - run: | - git clone --depth 1 https://github.com/benchmarking-initiative/Benchmark-Models-PEtab.git \ - && export BENCHMARK_COLLECTION="$(pwd)/Benchmark-Models-PEtab/Benchmark-Models/" \ - && source venv/bin/activate && python -m pip install -e $BENCHMARK_COLLECTION/../src/python - name: Install petab run: | From 5a846823ebf28563d324fcc0b592e1b9c9f94aa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Sun, 8 Dec 2024 12:16:30 +0000 Subject: [PATCH 22/92] Update petab.py --- python/sdist/amici/jax/petab.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index 0bf0d97696..132f8290ff 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -369,6 +369,8 @@ def nn_output_ids(self) -> list[str]: :return: PEtab parameter ids """ + if self._petab_problem.parameter_df is None: + return [] return self._petab_problem.mapping_df[ self._petab_problem.mapping_df[ petab.MODEL_ENTITY_ID From 97c7bbb308321a47e9b0f8322460bc409c751ab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Sun, 8 Dec 2024 12:33:20 +0000 Subject: [PATCH 23/92] Update petab.py --- python/sdist/amici/jax/petab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index 132f8290ff..508cf94184 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -369,7 +369,7 @@ def nn_output_ids(self) -> list[str]: :return: PEtab parameter ids """ - if self._petab_problem.parameter_df is None: + if self._petab_problem.mapping_df is None: return [] return self._petab_problem.mapping_df[ self._petab_problem.mapping_df[ From ac79583a6ca415cd72fdeac6a908cdbccf9313ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Sun, 8 Dec 2024 12:50:46 +0000 Subject: [PATCH 24/92] Update petab.py --- python/sdist/amici/jax/petab.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index 508cf94184..55686a719b 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -471,7 +471,8 @@ def load_model_parameters( pscale = tuple( [ petab.LIN - if pname in self._petab_problem.mapping_df.index + if self._petab_problem.mapping_df is not None + and pname in self._petab_problem.mapping_df.index else mapping.scale_map_sim_var[pname] for pname in self.model.parameter_ids ] From 4de63c4e5ebb63d813aac8c622b98b6a5816a865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Sun, 8 Dec 2024 13:10:52 +0000 Subject: [PATCH 25/92] Update ExampleJaxPEtab.ipynb --- .../example_jax_petab/ExampleJaxPEtab.ipynb | 244 +++++++++--------- 1 file changed, 125 insertions(+), 119 deletions(-) diff --git a/python/examples/example_jax_petab/ExampleJaxPEtab.ipynb b/python/examples/example_jax_petab/ExampleJaxPEtab.ipynb index 1310091f4c..6d645a1451 100644 --- a/python/examples/example_jax_petab/ExampleJaxPEtab.ipynb +++ b/python/examples/example_jax_petab/ExampleJaxPEtab.ipynb @@ -25,10 +25,11 @@ ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c71c96da0da3144a", + "metadata": {}, + "outputs": [], "source": [ "from amici.petab.petab_import import import_petab_problem\n", "import petab.v1 as petab\n", @@ -49,24 +50,24 @@ " verbose=False, # no text output\n", " jax=True, # return jax model\n", ")" - ], - "id": "c71c96da0da3144a" + ] }, { - "metadata": {}, "cell_type": "markdown", + "id": "7e0f1c27bd71ee1f", + "metadata": {}, "source": [ "## Simulation\n", "\n", "In principle, we can already use this model for simulation using the [simulate_condition](https://amici.readthedocs.io/en/latest/generated/amici.jax.html#amici.jax.JAXModel.simulate_condition) method. However, this approach can be cumbersome as timepoints, data etc. need to be specified manually. Instead, we process the PEtab problem into a [JAXProblem](https://amici.readthedocs.io/en/latest/generated/amici.jax.html#amici.jax.JAXProblem), which enables efficient simulation using [amici.jax.run_simulations]((https://amici.readthedocs.io/en/latest/generated/amici.jax.html#amici.jax.run_simulations)." - ], - "id": "7e0f1c27bd71ee1f" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "ccecc9a29acc7b73", + "metadata": {}, + "outputs": [], "source": [ "from amici.jax import JAXProblem, run_simulations\n", "\n", @@ -75,44 +76,44 @@ "\n", "# Run simulations and compute the log-likelihood\n", "llh, results = run_simulations(jax_problem)" - ], - "id": "ccecc9a29acc7b73" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "This simulates the model for all conditions using the nominal parameter values. Simple, right? Now, let’s take a look at the simulation results.", - "id": "415962751301c64a" + "id": "415962751301c64a", + "metadata": {}, + "source": "This simulates the model for all conditions using the nominal parameter values. Simple, right? Now, let’s take a look at the simulation results." }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "596b86e45e18fe3d", + "metadata": {}, + "outputs": [], "source": [ "# Define the simulation condition\n", "simulation_condition = (\"model1_data1\",)\n", "\n", "# Access the results for the specified condition\n", "results[simulation_condition]" - ], - "id": "596b86e45e18fe3d" + ] }, { - "metadata": {}, "cell_type": "markdown", + "id": "a1b173e013f9210a", + "metadata": {}, "source": [ "Unfortunately, the simulation failed! As seen in the output, the simulation broke down after the initial timepoint, indicated by the `inf` values in the state variables `results[simulation_condition][1].x` and the `nan` likelihood value. A closer inspection of this variable provides additional clues about what might have gone wrong.\n", "\n", "The issue stems from using single precision, as indicated by the `float32` dtype of state variables. Single precision is generally a [bad idea](https://docs.kidger.site/diffrax/examples/stiff_ode/) for stiff systems like the Böhm model. Let’s retry the simulation with double precision." - ], - "id": "a1b173e013f9210a" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "f4f5ff705a3f7402", + "metadata": {}, + "outputs": [], "source": [ "import jax\n", "\n", @@ -123,20 +124,20 @@ "llh, results = run_simulations(jax_problem)\n", "\n", "results" - ], - "id": "f4f5ff705a3f7402" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Success! The simulation completed successfully, and we can now plot the resulting state trajectories.", - "id": "fe4d3b40ee3efdf2" + "id": "fe4d3b40ee3efdf2", + "metadata": {}, + "source": "Success! The simulation completed successfully, and we can now plot the resulting state trajectories." }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "72f1ed397105e14a", + "metadata": {}, + "outputs": [], "source": [ "%matplotlib inline\n", "import matplotlib.pyplot as plt\n", @@ -171,41 +172,41 @@ "\n", "# Plot the simulation results\n", "plot_simulation(results)" - ], - "id": "72f1ed397105e14a" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "`run_simulations` enables users to specify the simulation conditions to be executed. For more complex models, this allows for restricting simulations to a subset of conditions. Since the Böhm model includes only a single condition, we demonstrate this functionality by simulating no condition at all.", - "id": "4fa97c33719c2277" + "id": "4fa97c33719c2277", + "metadata": {}, + "source": "`run_simulations` enables users to specify the simulation conditions to be executed. For more complex models, this allows for restricting simulations to a subset of conditions. Since the Böhm model includes only a single condition, we demonstrate this functionality by simulating no condition at all." }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "7950774a3e989042", + "metadata": {}, + "outputs": [], "source": [ "llh, results = run_simulations(jax_problem, simulation_conditions=tuple())\n", "results" - ], - "id": "7950774a3e989042" + ] }, { - "metadata": {}, "cell_type": "markdown", + "id": "98b8516a75ce4d12", + "metadata": {}, "source": [ "## Updating Parameters\n", "\n", "As next step, we will update the parameter values used for simulation. However, if we attempt to directly modify the values in `JAXModel.parameters`, we encounter a `FrozenInstanceError`." - ], - "id": "98b8516a75ce4d12" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "3d278a3d21e709d", + "metadata": {}, + "outputs": [], "source": [ "from dataclasses import FrozenInstanceError\n", "import jax\n", @@ -223,24 +224,24 @@ " jax_problem.parameters += noise\n", "except FrozenInstanceError as e:\n", " print(\"Error:\", e)" - ], - "id": "3d278a3d21e709d" + ] }, { - "metadata": {}, "cell_type": "markdown", + "id": "4cc3d595de4a4085", + "metadata": {}, "source": [ "The root cause of this error lies in the fact that, to enable autodiff, direct modifications of attributes are not allowed in [equinox](https://docs.kidger.site/equinox/), which AMICI utilizes under the hood. Consequently, attributes of instances like `JAXModel` or `JAXProblem` cannot be updated directly — this is the price we have to pay for autodiff.\n", "\n", "However, `JAXProblem` provides a convenient method called [update_parameters](https://amici.readthedocs.io/en/latest/generated/amici.jax.html#amici.jax.JAXProblem.update_parameters). The caveat is that this method creates a new JAXProblem instance instead of modifying the existing one." - ], - "id": "4cc3d595de4a4085" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "e47748376059628b", + "metadata": {}, + "outputs": [], "source": [ "# Update the parameters and create a new JAXProblem instance\n", "jax_problem = jax_problem.update_parameters(jax_problem.parameters + noise)\n", @@ -250,105 +251,111 @@ "\n", "# Plot the simulation results\n", "plot_simulation(results)" - ], - "id": "e47748376059628b" + ] }, { - "metadata": {}, "cell_type": "markdown", + "id": "660baf605a4e8339", + "metadata": {}, "source": [ "## Computing Gradients\n", "\n", "Similar to updating attributes, computing gradients in the JAX ecosystem can feel a bit unconventional if you’re not familiar with the JAX ecosysmt. JAX offers [powerful automatic differentiation](https://jax.readthedocs.io/en/latest/automatic-differentiation.html) through the `jax.grad` function. However, to use `jax.grad` with `JAXProblem`, we need to specify which parts of the `JAXProblem` should be treated as static." - ], - "id": "660baf605a4e8339" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "7033d09cc81b7f69", + "metadata": {}, + "outputs": [], "source": [ "try:\n", " # Attempt to compute the gradient of the run_simulations function\n", " jax.grad(run_simulations, has_aux=True)(jax_problem)\n", "except TypeError as e:\n", " print(\"Error:\", e)" - ], - "id": "7033d09cc81b7f69" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Fortunately, `equinox` simplifies this process by offering [filter_grad](https://docs.kidger.site/equinox/api/transformations/#equinox.filter_grad), which enables autodiff functionality that is compatible with `JAXProblem` and, in theory, also with `JAXModel`.", - "id": "dc9bc07cde00a926" + "id": "dc9bc07cde00a926", + "metadata": {}, + "source": "Fortunately, `equinox` simplifies this process by offering [filter_grad](https://docs.kidger.site/equinox/api/transformations/#equinox.filter_grad), which enables autodiff functionality that is compatible with `JAXProblem` and, in theory, also with `JAXModel`." }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "a6704182200e6438", + "metadata": {}, + "outputs": [], "source": [ "import equinox as eqx\n", "\n", "# Compute the gradient using equinox's filter_grad, preserving auxiliary outputs\n", "grad, _ = eqx.filter_grad(run_simulations, has_aux=True)(jax_problem)" - ], - "id": "a6704182200e6438" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Functions transformed by `filter_grad` return gradients that share the same structure as the first argument (unless specified otherwise). This allows us to access the gradient with respect to the parameters attribute directly `via grad.parameters`.", - "id": "851c3ec94cb5d086" + "id": "851c3ec94cb5d086", + "metadata": {}, + "source": "Functions transformed by `filter_grad` return gradients that share the same structure as the first argument (unless specified otherwise). This allows us to access the gradient with respect to the parameters attribute directly `via grad.parameters`." }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "grad.parameters", - "id": "c00c1581d7173d7a" + "id": "c00c1581d7173d7a", + "metadata": {}, + "outputs": [], + "source": [ + "grad.parameters" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Attributes for which derivatives cannot be computed (typically anything that is not a [jax.numpy.array](https://jax.readthedocs.io/en/latest/_autosummary/jax.numpy.array.html)) are automatically set to `None`.", - "id": "375b835fecc5a022" + "id": "375b835fecc5a022", + "metadata": {}, + "source": "Attributes for which derivatives cannot be computed (typically anything that is not a [jax.numpy.array](https://jax.readthedocs.io/en/latest/_autosummary/jax.numpy.array.html)) are automatically set to `None`." }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "grad", - "id": "f7c17f7459d0151f" + "id": "f7c17f7459d0151f", + "metadata": {}, + "outputs": [], + "source": [ + "grad" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Observant readers may notice that the gradient above appears to include numeric values for derivatives with respect to some measurements. However, `simulation_conditions` internally disables gradient computations using `jax.lax.stop_gradient`, resulting in these values being zeroed out.", - "id": "8eb7cc3db510c826" + "id": "8eb7cc3db510c826", + "metadata": {}, + "source": "Observant readers may notice that the gradient above appears to include numeric values for derivatives with respect to some measurements. However, `simulation_conditions` internally disables gradient computations using `jax.lax.stop_gradient`, resulting in these values being zeroed out." }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "grad._measurements[simulation_condition]", - "id": "3badd4402cf6b8c6" + "id": "3badd4402cf6b8c6", + "metadata": {}, + "outputs": [], + "source": [ + "grad._measurements[simulation_condition]" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "However, we can compute derivatives with respect to data elements using `JAXModel.simulate_condition`. In the example below, we differentiate the observables `y` (specified by passing `y` to the `ret` argument) with respect to the timepoints at which the model outputs are computed after the solving the differential equation. While this might not be particularly practical, it serves as an nice illustration of the power of automatic differentiation.", - "id": "58eb04393a1463d" + "id": "58eb04393a1463d", + "metadata": {}, + "source": "However, we can compute derivatives with respect to data elements using `JAXModel.simulate_condition`. In the example below, we differentiate the observables `y` (specified by passing `y` to the `ret` argument) with respect to the timepoints at which the model outputs are computed after the solving the differential equation. While this might not be particularly practical, it serves as an nice illustration of the power of automatic differentiation." }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "1a91aff44b93157", + "metadata": {}, + "outputs": [], "source": [ "import jax.numpy as jnp\n", "import diffrax\n", @@ -363,7 +370,7 @@ "]\n", "\n", "# Load parameters for the specified condition\n", - "p = jax_problem.load_parameters(simulation_condition[0])\n", + "p = jax_problem.load_model_parameters(simulation_condition[0])\n", "\n", "\n", "# Define a function to compute the gradient with respect to dynamic timepoints\n", @@ -388,24 +395,24 @@ "# Compute the gradient with respect to `ts_dyn`\n", "g = grad_ts_dyn(ts_dyn)\n", "g" - ], - "id": "1a91aff44b93157" + ] }, { - "metadata": {}, "cell_type": "markdown", + "id": "9f870da7754e139c", + "metadata": {}, "source": [ "## Compilation & Profiling\n", "\n", "To maximize performance with JAX, code should be just-in-time (JIT) compiled. This can be achieved using the `jax.jit` or `equinox.filter_jit` decorators. While JIT compilation introduces some overhead during the first function call, it significantly improves performance for subsequent calls. To demonstrate this, we will first clear the JIT cache and then profile the execution." - ], - "id": "9f870da7754e139c" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "58ebdc110ea7457e", + "metadata": {}, + "outputs": [], "source": [ "from time import time\n", "\n", @@ -414,14 +421,14 @@ "\n", "# Define a JIT-compiled gradient function with auxiliary outputs\n", "gradfun = eqx.filter_jit(eqx.filter_grad(run_simulations, has_aux=True))" - ], - "id": "58ebdc110ea7457e" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "e1242075f7e0faf", + "metadata": {}, + "outputs": [], "source": [ "# Measure the time taken for the first function call (including compilation)\n", "start = time()\n", @@ -432,14 +439,14 @@ "start = time()\n", "gradfun(jax_problem)\n", "print(f\"Gradient compilation time: {time() - start:.2f} seconds\")" - ], - "id": "e1242075f7e0faf" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "27181f367ccb1817", + "metadata": {}, + "outputs": [], "source": [ "%%timeit\n", "run_simulations(\n", @@ -452,14 +459,14 @@ " dcoeff=0.0, # recommended value for stiff systems\n", " ),\n", ")" - ], - "id": "27181f367ccb1817" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "5b8d3a6162a3ae55", + "metadata": {}, + "outputs": [], "source": [ "%%timeit \n", "gradfun(\n", @@ -472,14 +479,14 @@ " dcoeff=0.0, # recommended value for stiff systems\n", " ),\n", ")" - ], - "id": "5b8d3a6162a3ae55" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d733a450635a749b", + "metadata": {}, + "outputs": [], "source": [ "from amici.petab import simulate_petab\n", "import amici\n", @@ -500,8 +507,7 @@ "problem_parameters = dict(\n", " zip(jax_problem.parameter_ids, jax_problem.parameters)\n", ")" - ], - "id": "d733a450635a749b" + ] }, { "cell_type": "code", From 2fb392c9ccb408999d492f2c250b19cc766ffcda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Sun, 8 Dec 2024 14:21:24 +0000 Subject: [PATCH 26/92] ignore test warning --- .github/workflows/test_petab_sciml.yml | 1 - pytest.ini | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_petab_sciml.yml b/.github/workflows/test_petab_sciml.yml index 330a9982f9..c84d7c2f3e 100644 --- a/.github/workflows/test_petab_sciml.yml +++ b/.github/workflows/test_petab_sciml.yml @@ -74,7 +74,6 @@ jobs: - name: Run PEtab SciML testsuite run: | source ./venv/bin/activate \ - && pytest --cov-report=xml:coverage.xml \ --cov=./ python/tests/test_*petab*.py python/tests/sciml/ diff --git a/pytest.ini b/pytest.ini index 8cc45e0fd9..29463d5b09 100644 --- a/pytest.ini +++ b/pytest.ini @@ -12,6 +12,7 @@ filterwarnings = ignore:Conservation laws for non-constant species in models with Species-AssignmentRules are currently not supported and will be turned off.:UserWarning ignore:Conservation laws for non-constant species in combination with parameterized stoichiometric coefficients are not currently supported and will be turned off.:UserWarning ignore:Support for PEtab2.0 is experimental!:UserWarning + ignore:PEtab v2.0.0 mapping tables are only partially supported:UserWarning ignore:The JAX module is experimental and the API may change in the future.:ImportWarning # hundreds of SBML <=5.17 warnings ignore:.*inspect.getargspec\(\) is deprecated.*:DeprecationWarning From 4f3aff30a085be1b15ae102a21d0bbcc51c2803e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Sun, 8 Dec 2024 14:44:06 +0000 Subject: [PATCH 27/92] Update test_petab_sciml.yml --- .github/workflows/test_petab_sciml.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_petab_sciml.yml b/.github/workflows/test_petab_sciml.yml index c84d7c2f3e..ddc91fcbf4 100644 --- a/.github/workflows/test_petab_sciml.yml +++ b/.github/workflows/test_petab_sciml.yml @@ -75,7 +75,7 @@ jobs: run: | source ./venv/bin/activate \ && pytest --cov-report=xml:coverage.xml \ - --cov=./ python/tests/test_*petab*.py python/tests/sciml/ + --cov=./ python/tests/sciml/test_sciml.py - name: Codecov if: github.event_name == 'pull_request' || github.repository_owner == 'AMICI-dev' From 9c3fd4d7d258fd8401261b8e656c3d1bd91fa784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Sun, 8 Dec 2024 17:59:13 +0000 Subject: [PATCH 28/92] add hybridisation support --- python/sdist/amici/de_model.py | 132 +++++++++++++++++++++++ python/sdist/amici/jax/jaxcodeprinter.py | 3 + python/sdist/amici/jax/model.py | 2 +- python/sdist/amici/jax/petab.py | 9 +- python/sdist/amici/petab/petab_import.py | 20 +++- python/sdist/amici/sbml_import.py | 7 +- tests/sciml/test_sciml.py | 48 ++++----- 7 files changed, 186 insertions(+), 35 deletions(-) diff --git a/python/sdist/amici/de_model.py b/python/sdist/amici/de_model.py index 8ad2e7a998..0e6f43dbf7 100644 --- a/python/sdist/amici/de_model.py +++ b/python/sdist/amici/de_model.py @@ -28,10 +28,12 @@ AlgebraicEquation, Observable, EventObservable, + Sigma, SigmaY, SigmaZ, Parameter, Constant, + LogLikelihood, LogLikelihoodY, LogLikelihoodZ, LogLikelihoodRZ, @@ -199,6 +201,7 @@ def __init__( verbose: bool | int | None = False, simplify: Callable | None = _default_simplify, cache_simplify: bool = False, + hybridisation: bool = False, ): """ Create a new DEModel instance. @@ -2305,3 +2308,132 @@ def _process_heavisides( dxdt = dxdt.subs(heaviside_sympy, heaviside_amici) return dxdt + + @property + def _components(self) -> list[ModelQuantity]: + """ + Returns the components of the model + + :return: + components of the model + """ + return ( + self._algebraic_states + + self._algebraic_equations + + self._conservation_laws + + self._constants + + self._differential_states + + self._event_observables + + self._events + + self._expressions + + self._log_likelihood_ys + + self._log_likelihood_zs + + self._log_likelihood_rzs + + self._observables + + self._parameters + + self._sigma_ys + + self._sigma_zs + + self._splines + ) + + def _process_hybridisation(self, hybridisation: dict) -> None: + """ + Parses the hybridisation information and updates the model accordingly + + :param hybridisation: + hybridisation information + """ + added_expressions = False + for net_id, net in hybridisation.items(): + if not (net["output"] == "ode" or net["input"] == "ode"): + continue # do not integrate into ODEs, handle in amici.jax.petab + inputs = [ + comp + for comp in self._components + if str(comp.get_id()) in net["input_vars"] + ] + # sort inputs by order in input_vars + inputs = sorted( + inputs, + key=lambda comp: net["input_vars"].index(str(comp.get_id())), + ) + if len(inputs) != len(net["input_vars"]): + raise ValueError( + f"Could not find all input variables for neural network {net_id}" + ) + for inp in inputs: + if isinstance( + inp, + Sigma + | LogLikelihood + | Event + | ConservationLaw + | Observable, + ): + raise NotImplementedError( + f"{inp.get_name()} ({type(inp)}) is not supported as neural network input." + ) + + outputs = { + out_var: comp + for comp in self._components + if (out_var := str(comp.get_id())) in net["output_vars"] + # TODO: SYNTAX NEEDS to CHANGE + or (out_var := str(comp.get_id()) + "_dot") + in net["output_vars"] + } + if len(outputs.keys()) != len(net["output_vars"]): + raise ValueError( + f"Could not find all output variables for neural network {net_id}" + ) + for iout, (out_var, comp) in enumerate(outputs.items()): + # remove output from model components + if isinstance(comp, Parameter): + self._parameters.remove(comp) + elif isinstance(comp, Expression): + self._expressions.remove(comp) + elif isinstance(comp, DifferentialState): + pass + else: + raise NotImplementedError( + f"{comp.get_name()} ({type(comp)}) is not supported as neural network output." + ) + + # generate dummy Function + out_val = sp.Function(net_id)(*inputs, iout) + + # add to the model + if isinstance(comp, DifferentialState): + ix = self._differential_states.index(comp) + # TODO: SYNTAX NEEDS to CHANGE + if out_var.endswith("_dot"): + self._differential_states[ix].set_dt(out_val) + else: + self._differential_states[ix].set_val(out_val) + else: + self.add_component( + Expression( + identifier=comp.get_id(), + name=net_id, + value=out_val, + ) + ) + added_expressions = True + + if added_expressions: + # toposort expressions + w_sorted = toposort_symbols( + dict( + zip( + self.sym("w"), + self.eq("w"), + strict=True, + ) + ) + ) + old_syms = tuple(self._syms["w"]) + topo_expr_syms = tuple(w_sorted.keys()) + new_order = [old_syms.index(s) for s in topo_expr_syms] + self._expressions = [self._expressions[i] for i in new_order] + self._syms["w"] = sp.Matrix(topo_expr_syms) + self._eqs["w"] = sp.Matrix(list(w_sorted.values())) diff --git a/python/sdist/amici/jax/jaxcodeprinter.py b/python/sdist/amici/jax/jaxcodeprinter.py index 6cfce97b35..2b7d149c81 100644 --- a/python/sdist/amici/jax/jaxcodeprinter.py +++ b/python/sdist/amici/jax/jaxcodeprinter.py @@ -36,6 +36,9 @@ def _print_Mul(self, expr: sp.Expr) -> str: return super()._print_Mul(expr) return f"safe_div({self.doprint(numer)}, {self.doprint(denom)})" + def _print_Function(self, expr): + return f"self.nns['{expr.func.__name__}'].forward(jnp.array([{', '.join(self.doprint(a) for a in expr.args[:-1])}]))[{expr.args[-1]}]" + def _get_sym_lines( self, symbols: sp.Matrix | Iterable[str], diff --git a/python/sdist/amici/jax/model.py b/python/sdist/amici/jax/model.py index 51923fd517..035358d1b2 100644 --- a/python/sdist/amici/jax/model.py +++ b/python/sdist/amici/jax/model.py @@ -439,7 +439,7 @@ def _sigmays( in_axes=(0, 0, None, None, 0), )(ts, xs, p, tcl, iys) - @eqx.filter_jit + # @eqx.filter_jit def simulate_condition( self, p: jt.Float[jt.Array, "np"], diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index 55686a719b..16774a6e3f 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -416,12 +416,6 @@ def _eval_nn(self, output_par: str): .to_dict() ) - for petab_id in model_id_map.values(): - if petab_id in self.model.state_ids: - raise NotImplementedError( - "State variables as inputs to neural networks are not supported" - ) - net_input = jnp.array( [ jax.lax.stop_gradient(self._inputs[net_id][model_id]) @@ -494,6 +488,9 @@ def _state_needs_reinitialisation( :return: True if state needs reinitialisation, False otherwise """ + if state_id in self.nn_output_ids: + return True + if state_id not in self._petab_problem.condition_df: return False xval = self._petab_problem.condition_df.loc[ diff --git a/python/sdist/amici/petab/petab_import.py b/python/sdist/amici/petab/petab_import.py index c23736cd4a..23ddfcf409 100644 --- a/python/sdist/amici/petab/petab_import.py +++ b/python/sdist/amici/petab/petab_import.py @@ -166,13 +166,31 @@ def import_petab_problem( for ml_model in ml_models if ml_model.mlmodel_id == net ), + "input_vars": [ + petab_id + for petab_id, model_id in petab_problem.mapping_df.query( + f"netId == '{net}'" + )[petab.MODEL_ENTITY_ID] + .to_dict() + .items() + if model_id.startswith("input") + ], + "output_vars": [ + petab_id + for petab_id, model_id in petab_problem.mapping_df.query( + f"netId == '{net}'" + )[petab.MODEL_ENTITY_ID] + .to_dict() + .items() + if model_id.startswith("output") + ], **hybrid, } for net, hybrid in config["hybridization"].items() } if not jax or petab_problem.model.type_id == MODEL_TYPE_PYSB: raise NotImplementedError( - "petab_sciml extension is currently only supported for JAX models" + "petab_sciml extension is currently only supported for sbml models" ) else: hybridisation = None diff --git a/python/sdist/amici/sbml_import.py b/python/sdist/amici/sbml_import.py index 9e66a5d924..3a7678224d 100644 --- a/python/sdist/amici/sbml_import.py +++ b/python/sdist/amici/sbml_import.py @@ -287,7 +287,6 @@ def sbml2amici( log_as_log10: bool = True, generate_sensitivity_code: bool = True, hardcode_symbols: Sequence[str] = None, - hybridisation: dict = None, ) -> None: """ Generate and compile AMICI C++ files for the model provided to the @@ -435,7 +434,6 @@ def sbml2amici( compiler=compiler, allow_reinit_fixpar_initcond=allow_reinit_fixpar_initcond, generate_sensitivity_code=generate_sensitivity_code, - hybridisation=hybridisation, ) exporter.generate_model_code() @@ -541,6 +539,7 @@ def sbml2jax( simplify=simplify, cache_simplify=cache_simplify, log_as_log10=log_as_log10, + hybridisation=hybridisation, ) from amici.jax.ode_export import ODEExporter @@ -569,6 +568,7 @@ def _build_ode_model( cache_simplify: bool = False, log_as_log10: bool = True, hardcode_symbols: Sequence[str] = None, + hybridisation: dict = None, ) -> DEModel: """Generate an ODEModel from this SBML model. @@ -731,6 +731,9 @@ def _build_ode_model( if compute_conservation_laws: self._process_conservation_laws(ode_model) + if hybridisation: + ode_model._process_hybridisation(hybridisation) + # fill in 'self._sym' based on prototypes and components in ode_model ode_model.generate_basic_variables() diff --git a/tests/sciml/test_sciml.py b/tests/sciml/test_sciml.py index c6a98fd8cc..9ce9929981 100644 --- a/tests/sciml/test_sciml.py +++ b/tests/sciml/test_sciml.py @@ -139,7 +139,7 @@ def test_net(test): ) w = _reshape_flat_array(df) if isinstance(net.layers[layer], eqx.nn.ConvTranspose): - # see FAQ in https://docs.kidger.site/equinox/api/nn/conv/#equinox.nn.ConvTranspose + # see FAQ in https://docs.kidger.site/equinox/api/nn/conv/#equinox.nn.ConvTranspose w = np.flip( w, axis=tuple(range(2, w.ndim)) ).swapaxes(0, 1) @@ -268,17 +268,8 @@ def test_ude(test): jax_problem = JAXProblem(jax_model, petab_problem) # llh - if test in ( - "001", "004", - "005", - "008", - "010", - "011", - "012", - "013", - "014", "016", ): with pytest.raises(NotImplementedError): @@ -316,16 +307,12 @@ def test_ude(test): controller=diffrax.PIDController(atol=1e-14, rtol=1e-14), max_steps=2**16, ) - expected = ( - pd.concat( - [ - pd.read_csv(test_dir / simulation, sep="\t") - for simulation in solutions["grad_llh_files"] - ] - ) - .set_index(petab.PARAMETER_ID) - .sort_index() - ) + expected = pd.concat( + [ + pd.read_csv(test_dir / simulation, sep="\t") + for simulation in solutions["grad_llh_files"] + ] + ).set_index(petab.PARAMETER_ID) actual_dict = {} for ip in expected.index: if ip in jax_problem.parameter_ids: @@ -337,12 +324,23 @@ def test_ude(test): layer = ip.split("_")[1] attribute = ip.split("_")[2] index = tuple(np.array(ip.split("_")[3:]).astype(int)) - actual_dict[ip] = getattr( - sllh.model.nns[net].layers[layer], attribute - )[*index].item() - actual = pd.Series(actual_dict).sort_index() + + attr_grad = getattr(sllh.model.nns[net].layers[layer], attribute) + if ( + isinstance( + sllh.model.nns[net].layers[layer], eqx.nn.ConvTranspose + ) + and attribute == "weight" + ): + # invert np.flip(w, axis=tuple(range(2, w.ndim))).swapaxes(0, 1) + attr_grad = np.flip( + attr_grad.swapaxes(0, 1), + axis=tuple(range(2, attr_grad.ndim)), + ) + actual_dict[ip] = attr_grad[*index].item() + actual = pd.Series(actual_dict).loc[expected.index].values np.testing.assert_allclose( - actual.values, + actual, expected["value"].values, atol=solutions["tol_grad_llh"], rtol=solutions["tol_grad_llh"], From 1349ddb1f1b8fe77d317fc8ca6ed362f8aabb6cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Sun, 8 Dec 2024 18:29:13 +0000 Subject: [PATCH 29/92] fix workflow --- .github/workflows/test_petab_sciml.yml | 2 +- python/sdist/amici/jax/model.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test_petab_sciml.yml b/.github/workflows/test_petab_sciml.yml index ddc91fcbf4..eb8ca39394 100644 --- a/.github/workflows/test_petab_sciml.yml +++ b/.github/workflows/test_petab_sciml.yml @@ -75,7 +75,7 @@ jobs: run: | source ./venv/bin/activate \ && pytest --cov-report=xml:coverage.xml \ - --cov=./ python/tests/sciml/test_sciml.py + --cov=./ tests/sciml/test_sciml.py - name: Codecov if: github.event_name == 'pull_request' || github.repository_owner == 'AMICI-dev' diff --git a/python/sdist/amici/jax/model.py b/python/sdist/amici/jax/model.py index 035358d1b2..51923fd517 100644 --- a/python/sdist/amici/jax/model.py +++ b/python/sdist/amici/jax/model.py @@ -439,7 +439,7 @@ def _sigmays( in_axes=(0, 0, None, None, 0), )(ts, xs, p, tcl, iys) - # @eqx.filter_jit + @eqx.filter_jit def simulate_condition( self, p: jt.Float[jt.Array, "np"], From 4596dc494269b910e84809e2cac8625ec6b5bb84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 19 Dec 2024 16:01:55 +0000 Subject: [PATCH 30/92] update testsuite --- tests/sciml/testsuite | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/sciml/testsuite b/tests/sciml/testsuite index 4518f54dd6..f836be3852 160000 --- a/tests/sciml/testsuite +++ b/tests/sciml/testsuite @@ -1 +1 @@ -Subproject commit 4518f54dd62c1256fb1803b9f5e9817f4f78c26d +Subproject commit f836be38526da0850f0e540010accc94217bdf53 From bd103dbe52cae05c01b14ccd011edbc1e5869d29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 19 Dec 2024 18:01:26 +0000 Subject: [PATCH 31/92] update after test refactor --- python/sdist/amici/de_model.py | 9 +- python/sdist/amici/jax/ode_export.py | 9 +- python/sdist/amici/jax/petab.py | 65 ++++++--- python/sdist/amici/petab/petab_import.py | 47 +++---- tests/sciml/test_sciml.py | 165 ++++++++++++----------- 5 files changed, 166 insertions(+), 129 deletions(-) diff --git a/python/sdist/amici/de_model.py b/python/sdist/amici/de_model.py index 0e6f43dbf7..5c0c0ff7b5 100644 --- a/python/sdist/amici/de_model.py +++ b/python/sdist/amici/de_model.py @@ -2345,7 +2345,10 @@ def _process_hybridisation(self, hybridisation: dict) -> None: """ added_expressions = False for net_id, net in hybridisation.items(): - if not (net["output"] == "ode" or net["input"] == "ode"): + if not ( + net["hybridization"]["output"] == "ode" + or net["hybridization"]["input"] == "ode" + ): continue # do not integrate into ODEs, handle in amici.jax.petab inputs = [ comp @@ -2400,7 +2403,9 @@ def _process_hybridisation(self, hybridisation: dict) -> None: ) # generate dummy Function - out_val = sp.Function(net_id)(*inputs, iout) + out_val = sp.Function(net_id)( + *[input.get_id() for input in inputs], iout + ) # add to the model if isinstance(comp, DifferentialState): diff --git a/python/sdist/amici/jax/ode_export.py b/python/sdist/amici/jax/ode_export.py index 08ba8bc0cd..a374042f4a 100644 --- a/python/sdist/amici/jax/ode_export.py +++ b/python/sdist/amici/jax/ode_export.py @@ -256,10 +256,11 @@ def _generate_jax_code(self) -> None: def _generate_nn_code(self) -> None: for net_name, net in self.hybridisation.items(): - generate_equinox( - net["model"], - self.model_path / f"{net_name}.py", - ) + for model in net["model"]: + generate_equinox( + model, + self.model_path / f"{net_name}.py", + ) def set_paths(self, output_dir: str | Path | None = None) -> None: """ diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index d23c5a1b5e..5439d37092 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -282,14 +282,36 @@ def _get_nominal_parameter_values( } # extract nominal values from petab problem for pname, row in self._petab_problem.parameter_df.iterrows(): - if (net := pname.split("_")[0]) in model.nns: + if (net := pname.split(".")[0]) in model.nns: + to_set = [] nn = model_pars[net] - layer = nn[pname.split("_")[1]] - attribute = pname.split("_")[2] - index = tuple(np.array(pname.split("_")[3:]).astype(int)) - layer[attribute] = ( - layer[attribute].at[index].set(row[petab.NOMINAL_VALUE]) - ) + if len(pname.split(".")) > 1: + layer = nn[pname.split(".")[1]] + if len(pname.split(".")) > 2: + to_set.append( + (pname.split(".")[1], pname.split(".")[2]) + ) + else: + to_set.extend( + [ + (pname.split(".")[1], attribute) + for attribute in layer.keys() + ] + ) + else: + to_set.extend( + [ + (layer_name, attribute) + for layer_name, layer in nn.items() + for attribute in layer.keys() + ] + ) + + for layer, attribute in to_set: + nn[layer][attribute] = row[ + petab.NOMINAL_VALUE + ] * jnp.ones_like(nn[layer][attribute]) + # set values in model for net_id in model_pars: for layer_id in model_pars[net_id]: @@ -316,14 +338,9 @@ def _get_nominal_parameter_values( ), model def _get_inputs(self): - if ( - self._petab_problem.mapping_df is None - or "netId" not in self._petab_problem.mapping_df.columns - ): + if self._petab_problem.mapping_df is None: return {} - inputs = { - net: {} for net in self._petab_problem.mapping_df["netId"].unique() - } + inputs = {net: {} for net in self.model.nns.keys()} for petab_id, row in self._petab_problem.mapping_df.iterrows(): if (filepath := Path(petab_id)).is_file(): data_flat = pd.read_csv(filepath, sep="\t").sort_values( @@ -368,9 +385,10 @@ def nn_output_ids(self) -> list[str]: if self._petab_problem.mapping_df is None: return [] return self._petab_problem.mapping_df[ - self._petab_problem.mapping_df[ - petab.MODEL_ENTITY_ID - ].str.startswith("output") + self._petab_problem.mapping_df[petab.MODEL_ENTITY_ID] + .str.split(".") + .str[1] + .str.startswith("output") ].index.tolist() def get_petab_parameter_by_id(self, name: str) -> jnp.float_: @@ -402,11 +420,18 @@ def _unscale( ) def _eval_nn(self, output_par: str): - net_id = self._petab_problem.mapping_df.loc[output_par, "netId"] + net_id = self._petab_problem.mapping_df.loc[ + output_par, petab.MODEL_ENTITY_ID + ].split(".")[0] nn = self.model.nns[net_id] model_id_map = ( - self._petab_problem.mapping_df.query(f'netId == "{net_id}"') + self._petab_problem.mapping_df[ + self._petab_problem.mapping_df[petab.MODEL_ENTITY_ID] + .str.split(".") + .str[0] + == net_id + ] .reset_index() .set_index(petab.MODEL_ENTITY_ID)[petab.PETAB_ENTITY_ID] .to_dict() @@ -422,7 +447,7 @@ def _eval_nn(self, output_par: str): petab_id, petab.NOMINAL_VALUE ] for model_id, petab_id in model_id_map.items() - if model_id.startswith("input") + if model_id.split(".")[1].startswith("input") ] ) return nn.forward(net_input).squeeze() diff --git a/python/sdist/amici/petab/petab_import.py b/python/sdist/amici/petab/petab_import.py index 23ddfcf409..b536342191 100644 --- a/python/sdist/amici/petab/petab_import.py +++ b/python/sdist/amici/petab/petab_import.py @@ -150,43 +150,40 @@ def import_petab_problem( from petab_sciml import PetabScimlStandard config = petab_problem.extensions_config["petab_sciml"] - net_files = config.get("net_files", []) - # TODO: net files need to be absolute paths - ml_models = [ - model - for net_file in net_files - for model in PetabScimlStandard.load_data( - Path() / net_file - ).models - ] hybridisation = { - net: { - "model": next( - ml_model - for ml_model in ml_models - if ml_model.mlmodel_id == net - ), + net_id: { + "model": PetabScimlStandard.load_data( + Path() / net_config["file"] + ).models, "input_vars": [ petab_id - for petab_id, model_id in petab_problem.mapping_df.query( - f"netId == '{net}'" - )[petab.MODEL_ENTITY_ID] + for petab_id, model_id in petab_problem.mapping_df.loc[ + petab_problem.mapping_df[petab.MODEL_ENTITY_ID] + .str.split(".") + .str[0] + == net_id, + petab.MODEL_ENTITY_ID, + ] .to_dict() .items() - if model_id.startswith("input") + if model_id.split(".")[1].startswith("input") ], "output_vars": [ petab_id - for petab_id, model_id in petab_problem.mapping_df.query( - f"netId == '{net}'" - )[petab.MODEL_ENTITY_ID] + for petab_id, model_id in petab_problem.mapping_df.loc[ + petab_problem.mapping_df[petab.MODEL_ENTITY_ID] + .str.split(".") + .str[0] + == net_id, + petab.MODEL_ENTITY_ID, + ] .to_dict() .items() - if model_id.startswith("output") + if model_id.split(".")[1].startswith("output") ], - **hybrid, + **net_config, } - for net, hybrid in config["hybridization"].items() + for net_id, net_config in config.items() } if not jax or petab_problem.model.type_id == MODEL_TYPE_PYSB: raise NotImplementedError( diff --git a/tests/sciml/test_sciml.py b/tests/sciml/test_sciml.py index 9ce9929981..e99b0a88cc 100644 --- a/tests/sciml/test_sciml.py +++ b/tests/sciml/test_sciml.py @@ -19,6 +19,7 @@ import numpy as np import equinox as eqx import os +import h5py from contextlib import contextmanager from petab_sciml import PetabScimlStandard @@ -208,55 +209,27 @@ def test_ude(test): solutions = safe_load(f) with change_directory(test_dir / "petab"): + from petab.v2 import Problem + petab_yaml["format_version"] = "2.0.0" for problem in petab_yaml["problems"]: problem["model_files"] = { - file.split(".")[0]: { - "language": "sbml", - "location": file, - } - for file in problem.pop("sbml_files") + problem["model_files"]["location"].split(".")[0]: problem[ + "model_files" + ] } - problem["mapping_files"] = [problem.pop("mapping_tables")] - for mapping_file in problem["mapping_files"]: df = pd.read_csv( mapping_file, sep="\t", ) - df.rename( - columns={ - "ioId": petab.MODEL_ENTITY_ID, - "ioValue": petab.PETAB_ENTITY_ID, - } - ).to_csv(mapping_file, sep="\t", index=False) - for observable_file in problem["observable_files"]: - df = pd.read_csv(observable_file, sep="\t") - df[petab.OBSERVABLE_ID] = df[petab.OBSERVABLE_ID].map( - lambda x: x + "_o" if not x.endswith("_o") else x - ) - df.to_csv(observable_file, sep="\t", index=False) - for measurement_file in problem["measurement_files"]: - df = pd.read_csv(measurement_file, sep="\t") - df[petab.OBSERVABLE_ID] = df[petab.OBSERVABLE_ID].map( - lambda x: x + "_o" if not x.endswith("_o") else x - ) - df.to_csv(measurement_file, sep="\t", index=False) - - petab_yaml["parameter_file"] = [ - petab_yaml["parameter_file"], - petab_yaml["parameter_file"].replace("ude", "nn"), - ] - df = pd.read_csv(petab_yaml["parameter_file"][1], sep="\t") - df.rename( - columns={ - "value": petab.NOMINAL_VALUE, - }, - inplace=True, - ) - df.to_csv(petab_yaml["parameter_file"][1], sep="\t", index=False) - - from petab.v2 import Problem + if df[petab.PETAB_ENTITY_ID].str.startswith("net").any(): + df.rename( + columns={ + petab.PETAB_ENTITY_ID: petab.MODEL_ENTITY_ID, + petab.MODEL_ENTITY_ID: petab.PETAB_ENTITY_ID, + } + ).to_csv(mapping_file, sep="\t", index=False) petab_problem = Problem.from_yaml(petab_yaml) jax_model = import_petab_problem( @@ -266,6 +239,35 @@ def test_ude(test): jax=True, ) jax_problem = JAXProblem(jax_model, petab_problem) + for net, net_config in petab_problem.extensions_config[ + "petab_sciml" + ].items(): + pars = h5py.File( + net_config["parameters"].replace(".h5", ".hf5"), "r" + ) + for layer_name, layer in jax_problem.model.nns[net].layers.items(): + for attribute in dir(layer): + if not isinstance( + getattr(layer, attribute), jax.numpy.ndarray + ): + continue + value = jnp.array(pars[layer_name][attribute]) + + if ( + isinstance(layer, eqx.nn.ConvTranspose) + and attribute == "weight" + ): + # see FAQ in https://docs.kidger.site/equinox/api/nn/conv/#equinox.nn.ConvTranspose + value = jnp.flip( + value, axis=tuple(range(2, value.ndim)) + ).swapaxes(0, 1) + jax_problem = eqx.tree_at( + lambda x: getattr( + x.model.nns[net].layers[layer_name], attribute + ), + jax_problem, + value, + ) # llh if test in ( @@ -307,41 +309,48 @@ def test_ude(test): controller=diffrax.PIDController(atol=1e-14, rtol=1e-14), max_steps=2**16, ) - expected = pd.concat( - [ - pd.read_csv(test_dir / simulation, sep="\t") - for simulation in solutions["grad_llh_files"] - ] - ).set_index(petab.PARAMETER_ID) - actual_dict = {} - for ip in expected.index: - if ip in jax_problem.parameter_ids: - actual_dict[ip] = sllh.parameters[ - jax_problem.parameter_ids.index(ip) - ].item() - if ip.split("_")[0] in jax_problem.model.nns: - net = ip.split("_")[0] - layer = ip.split("_")[1] - attribute = ip.split("_")[2] - index = tuple(np.array(ip.split("_")[3:]).astype(int)) - - attr_grad = getattr(sllh.model.nns[net].layers[layer], attribute) - if ( - isinstance( - sllh.model.nns[net].layers[layer], eqx.nn.ConvTranspose - ) - and attribute == "weight" - ): - # invert np.flip(w, axis=tuple(range(2, w.ndim))).swapaxes(0, 1) - attr_grad = np.flip( - attr_grad.swapaxes(0, 1), - axis=tuple(range(2, attr_grad.ndim)), - ) - actual_dict[ip] = attr_grad[*index].item() - actual = pd.Series(actual_dict).loc[expected.index].values - np.testing.assert_allclose( - actual, - expected["value"].values, - atol=solutions["tol_grad_llh"], - rtol=solutions["tol_grad_llh"], - ) + for component, file in solutions["grad_llh_files"].items(): + actual_dict = {} + if component == "mech": + expected = pd.read_csv(test_dir / file, sep="\t").set_index( + petab.PARAMETER_ID + ) + for ip in expected.index: + if ip in jax_problem.parameter_ids: + actual_dict[ip] = sllh.parameters[ + jax_problem.parameter_ids.index(ip) + ].item() + actual = pd.Series(actual_dict).loc[expected.index].values + np.testing.assert_allclose( + actual, + expected["value"].values, + atol=solutions["tol_grad_llh"], + rtol=solutions["tol_grad_llh"], + ) + else: + expected = h5py.File(test_dir / file, "r") + for layer_name, layer in jax_problem.model.nns[ + component + ].layers.items(): + for attribute in dir(layer): + if not isinstance( + getattr(layer, attribute), jax.numpy.ndarray + ): + continue + actual = getattr( + sllh.model.nns[component].layers[layer_name], attribute + ) + if ( + isinstance(layer, eqx.nn.ConvTranspose) + and attribute == "weight" + ): + actual = np.flip( + actual.swapaxes(0, 1), + axis=tuple(range(2, actual.ndim)), + ) + np.testing.assert_allclose( + actual, + expected[layer_name][attribute][:], + atol=solutions["tol_grad_llh"], + rtol=solutions["tol_grad_llh"], + ) From 2b6308d5c6deef96f501287b2589206c8acdb141 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Mon, 27 Jan 2025 16:08:09 +0000 Subject: [PATCH 32/92] fix hybridization --- python/sdist/amici/de_model.py | 8 ++++---- python/sdist/amici/petab/petab_import.py | 6 +++--- python/sdist/amici/petab/sbml_import.py | 5 +++++ python/sdist/amici/sbml_import.py | 12 ++++++------ 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/python/sdist/amici/de_model.py b/python/sdist/amici/de_model.py index 5c0c0ff7b5..4f4f9f466b 100644 --- a/python/sdist/amici/de_model.py +++ b/python/sdist/amici/de_model.py @@ -2336,15 +2336,15 @@ def _components(self) -> list[ModelQuantity]: + self._splines ) - def _process_hybridisation(self, hybridisation: dict) -> None: + def _process_hybridization(self, hybridization: dict) -> None: """ Parses the hybridisation information and updates the model accordingly - :param hybridisation: - hybridisation information + :param hybridization: + hybridization information """ added_expressions = False - for net_id, net in hybridisation.items(): + for net_id, net in hybridization.items(): if not ( net["hybridization"]["output"] == "ode" or net["hybridization"]["input"] == "ode" diff --git a/python/sdist/amici/petab/petab_import.py b/python/sdist/amici/petab/petab_import.py index b536342191..a13ab5c4d9 100644 --- a/python/sdist/amici/petab/petab_import.py +++ b/python/sdist/amici/petab/petab_import.py @@ -150,7 +150,7 @@ def import_petab_problem( from petab_sciml import PetabScimlStandard config = petab_problem.extensions_config["petab_sciml"] - hybridisation = { + hybridization = { net_id: { "model": PetabScimlStandard.load_data( Path() / net_config["file"] @@ -190,7 +190,7 @@ def import_petab_problem( "petab_sciml extension is currently only supported for sbml models" ) else: - hybridisation = None + hybridization = None # compile the model if petab_problem.model.type_id == MODEL_TYPE_PYSB: @@ -207,7 +207,7 @@ def import_petab_problem( model_name=model_name, model_output_dir=model_output_dir, non_estimated_parameters_as_constants=non_estimated_parameters_as_constants, - hybridisation=hybridisation, + hybridization=hybridization, jax=jax, **kwargs, ) diff --git a/python/sdist/amici/petab/sbml_import.py b/python/sdist/amici/petab/sbml_import.py index 52376c3324..d9659e5fd6 100644 --- a/python/sdist/amici/petab/sbml_import.py +++ b/python/sdist/amici/petab/sbml_import.py @@ -381,10 +381,15 @@ def import_model_sbml( sigmas=sigmas, noise_distributions=noise_distrs, verbose=verbose, + hybridization=hybridization, **kwargs, ) return sbml_importer else: + if hybridization: + raise NotImplementedError( + "Hybridization is currently only supported for JAX models." + ) sbml_importer.sbml2amici( model_name=model_name, output_dir=model_output_dir, diff --git a/python/sdist/amici/sbml_import.py b/python/sdist/amici/sbml_import.py index 3a7678224d..df540ba1da 100644 --- a/python/sdist/amici/sbml_import.py +++ b/python/sdist/amici/sbml_import.py @@ -458,7 +458,7 @@ def sbml2jax( simplify: Callable | None = _default_simplify, cache_simplify: bool = False, log_as_log10: bool = True, - hybridisation: dict = None, + hybridization: dict = None, ) -> None: """ Generate and compile AMICI jax files for the model provided to the @@ -539,7 +539,7 @@ def sbml2jax( simplify=simplify, cache_simplify=cache_simplify, log_as_log10=log_as_log10, - hybridisation=hybridisation, + hybridization=hybridization, ) from amici.jax.ode_export import ODEExporter @@ -549,7 +549,7 @@ def sbml2jax( model_name=model_name, outdir=output_dir, verbose=verbose, - hybridisation=hybridisation, + hybridisation=hybridization, ) exporter.generate_model_code() @@ -568,7 +568,7 @@ def _build_ode_model( cache_simplify: bool = False, log_as_log10: bool = True, hardcode_symbols: Sequence[str] = None, - hybridisation: dict = None, + hybridization: dict = None, ) -> DEModel: """Generate an ODEModel from this SBML model. @@ -731,8 +731,8 @@ def _build_ode_model( if compute_conservation_laws: self._process_conservation_laws(ode_model) - if hybridisation: - ode_model._process_hybridisation(hybridisation) + if hybridization: + ode_model._process_hybridization(hybridization) # fill in 'self._sym' based on prototypes and components in ode_model ode_model.generate_basic_variables() From eaf3e09dc0d2adfad74b0cdb6ec8ee19dc3189c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Wed, 16 Apr 2025 10:24:25 +0100 Subject: [PATCH 33/92] update testsuite --- tests/sciml/testsuite | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/sciml/testsuite b/tests/sciml/testsuite index f836be3852..90599dd22d 160000 --- a/tests/sciml/testsuite +++ b/tests/sciml/testsuite @@ -1 +1 @@ -Subproject commit f836be38526da0850f0e540010accc94217bdf53 +Subproject commit 90599dd22d362b6e0c8bf8f55531a6f448b005fb From 4c6947edf1892de194041cba9812f257b4ab3b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Wed, 16 Apr 2025 17:43:06 +0100 Subject: [PATCH 34/92] update testsuite, some fixes --- .gitmodules | 2 +- python/sdist/amici/de_model.py | 5 +- python/sdist/amici/jax/petab.py | 38 ++++-- python/sdist/amici/petab/petab_import.py | 10 +- tests/sciml/test_sciml.py | 166 ++++++++++------------- 5 files changed, 105 insertions(+), 116 deletions(-) diff --git a/.gitmodules b/.gitmodules index 327b90c6ad..c67d8b0014 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "tests/sciml/testsuite"] path = tests/sciml/testsuite - url = https://github.com/sebapersson/petab_sciml + url = https://github.com/sebapersson/petab_sciml_testsuite diff --git a/python/sdist/amici/de_model.py b/python/sdist/amici/de_model.py index 6a83adf1a9..64deb8f3b9 100644 --- a/python/sdist/amici/de_model.py +++ b/python/sdist/amici/de_model.py @@ -2381,10 +2381,7 @@ def _process_hybridization(self, hybridization: dict) -> None: """ added_expressions = False for net_id, net in hybridization.items(): - if not ( - net["hybridization"]["output"] == "ode" - or net["hybridization"]["input"] == "ode" - ): + if net["static"]: continue # do not integrate into ODEs, handle in amici.jax.petab inputs = [ comp diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index 4527b14ee4..bc80529574 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -16,6 +16,7 @@ import numpy as np import pandas as pd import petab.v1 as petab +import h5py from amici import _module_from_path from amici.petab.parameter_mapping import ( @@ -128,8 +129,6 @@ def __init__(self, model: JAXModel, petab_problem: petab.Problem): self._np_indices, ) = self._get_measurements(scs) - self.parameters = self._get_nominal_parameter_values() - def save(self, directory: Path): """ Save the problem to a directory. @@ -518,6 +517,13 @@ def _get_nominal_parameter_values( if (net := pname.split(".")[0]) in model.nns: to_set = [] nn = model_pars[net] + scalar = True + try: + value = float(row[petab.NOMINAL_VALUE]) + except ValueError: + value = h5py.File(row[petab.NOMINAL_VALUE], "r") + scalar = False + if len(pname.split(".")) > 1: layer = nn[pname.split(".")[1]] if len(pname.split(".")) > 2: @@ -541,9 +547,12 @@ def _get_nominal_parameter_values( ) for layer, attribute in to_set: - nn[layer][attribute] = row[ - petab.NOMINAL_VALUE - ] * jnp.ones_like(nn[layer][attribute]) + if scalar: + nn[layer][attribute] = value * jnp.ones_like( + nn[layer][attribute] + ) + else: + nn[layer][attribute] = value[layer][attribute] # set values in model for net_id in model_pars: @@ -559,9 +568,11 @@ def _get_nominal_parameter_values( return jnp.array( [ petab.scale( - self._petab_problem.parameter_df.loc[ - pval, petab.NOMINAL_VALUE - ], + float( + self._petab_problem.parameter_df.loc[ + pval, petab.NOMINAL_VALUE + ] + ), self._petab_problem.parameter_df.loc[ pval, petab.PARAMETER_SCALE ], @@ -604,7 +615,12 @@ def parameter_ids(self) -> list[str]: PEtab parameter ids """ return self._petab_problem.parameter_df[ - self._petab_problem.parameter_df[petab.ESTIMATE] == 1 + self._petab_problem.parameter_df[petab.ESTIMATE] + == 1 + & pd.to_numeric( + self._petab_problem.parameter_df[petab.NOMINAL_VALUE], + errors="coerce", + ).notna() ].index.tolist() @property @@ -886,7 +902,9 @@ def _prepare_conditions( Tuple of parameter arrays, reinitialisation masks and reinitialisation values, observable parameters and noise parameters. """ - p_array = jnp.stack([self.load_parameters(sc) for sc in conditions]) + p_array = jnp.stack( + [self.load_model_parameters(sc) for sc in conditions] + ) unscaled_parameters = jnp.stack( [ jax_unscale( diff --git a/python/sdist/amici/petab/petab_import.py b/python/sdist/amici/petab/petab_import.py index a13ab5c4d9..112b99a5d6 100644 --- a/python/sdist/amici/petab/petab_import.py +++ b/python/sdist/amici/petab/petab_import.py @@ -146,14 +146,16 @@ def import_petab_problem( logger.info(f"Compiling model {model_name} to {model_output_dir}.") - if "petab_sciml" in petab_problem.extensions_config: + if "neural_nets" in petab_problem.extensions_config: # TODO: fixme from petab_sciml import PetabScimlStandard - config = petab_problem.extensions_config["petab_sciml"] + config = petab_problem.extensions_config + # TODO: only accept YAML format for now + # TODO: edit petab library to load hybridization table and map input and output vars here hybridization = { net_id: { "model": PetabScimlStandard.load_data( - Path() / net_config["file"] + Path() / net_config["location"] ).models, "input_vars": [ petab_id @@ -183,7 +185,7 @@ def import_petab_problem( ], **net_config, } - for net_id, net_config in config.items() + for net_id, net_config in config["neural_nets"].items() } if not jax or petab_problem.model.type_id == MODEL_TYPE_PYSB: raise NotImplementedError( diff --git a/tests/sciml/test_sciml.py b/tests/sciml/test_sciml.py index e99b0a88cc..bdf33e8246 100644 --- a/tests/sciml/test_sciml.py +++ b/tests/sciml/test_sciml.py @@ -44,6 +44,8 @@ def change_directory(destination): # pip install git+https://github.com/sebapersson/petab_sciml@add_standard#egg=petab_sciml\&subdirectory=src/python cases_dir = Path(__file__).parent / "testsuite" / "test_cases" +net_cases_dir = cases_dir / "net_import" +ude_cases_dir = cases_dir / "hybrid" def _reshape_flat_array(array_flat): @@ -65,10 +67,10 @@ def _reshape_flat_array(array_flat): @pytest.mark.parametrize( - "test", sorted([d.stem for d in cases_dir.glob("net_[0-9]*")]) + "test", sorted([d.stem for d in net_cases_dir.glob("[0-9]*")]) ) def test_net(test): - test_dir = cases_dir / test + test_dir = net_cases_dir / test with open(test_dir / "solutions.yaml") as f: solutions = safe_load(f) @@ -83,20 +85,20 @@ def test_net(test): for ml_model in ml_models.models: module_dir = outdir / f"{ml_model.mlmodel_id}.py" if test in ( - "net_002", - "net_009", - "net_018", - "net_019", - "net_020", - "net_021", - "net_022", - "net_042", - "net_043", - "net_044", - "net_045", - "net_046", - "net_047", - "net_048", + "002", + "009", + "018", + "019", + "020", + "021", + "022", + "042", + "043", + "044", + "045", + "046", + "047", + "048", ): with pytest.raises(NotImplementedError): generate_equinox(ml_model, module_dir) @@ -111,34 +113,20 @@ def test_net(test): solutions.get("net_ps", solutions["net_input"]), solutions["net_output"], ): - input_flat = pd.read_csv(test_dir / input_file, sep="\t") - input = _reshape_flat_array(input_flat) - - output_flat = pd.read_csv(test_dir / output_file, sep="\t") - output = _reshape_flat_array(output_flat) + input = h5py.File(test_dir / input_file, "r")["input"][:] + output = h5py.File(test_dir / output_file, "r")["output"][:] if "net_ps" in solutions: - par = pd.read_csv(test_dir / par_file, sep="\t") + par = h5py.File(test_dir / par_file, "r") for ml_model in ml_models.models: net = nets[ml_model.mlmodel_id](jr.PRNGKey(0)) for layer in net.layers.keys(): - layer_prefix = f"net_{layer}" if ( isinstance(net.layers[layer], eqx.Module) and hasattr(net.layers[layer], "weight") and net.layers[layer].weight is not None ): - prefix = layer_prefix + "_weight" - df = par[ - par[petab.PARAMETER_ID].str.startswith(prefix) - ] - df["ix"] = ( - df[petab.PARAMETER_ID] - .str.split("_") - .str[3:] - .apply(lambda x: ";".join(x)) - ) - w = _reshape_flat_array(df) + w = par[layer]["weight"][:] if isinstance(net.layers[layer], eqx.nn.ConvTranspose): # see FAQ in https://docs.kidger.site/equinox/api/nn/conv/#equinox.nn.ConvTranspose w = np.flip( @@ -155,17 +143,7 @@ def test_net(test): and hasattr(net.layers[layer], "bias") and net.layers[layer].bias is not None ): - prefix = layer_prefix + "_bias" - df = par[ - par[petab.PARAMETER_ID].str.startswith(prefix) - ] - df["ix"] = ( - df[petab.PARAMETER_ID] - .str.split("_") - .str[3:] - .apply(lambda x: ";".join(x)) - ) - b = _reshape_flat_array(df) + b = par[layer]["bias"][:] if isinstance( net.layers[layer], eqx.nn.Conv | eqx.nn.ConvTranspose, @@ -199,11 +177,11 @@ def test_net(test): @pytest.mark.parametrize( - "test", sorted([d.stem for d in cases_dir.glob("[0-9]*")]) + "test", sorted([d.stem for d in ude_cases_dir.glob("[0-9]*")]) ) def test_ude(test): - test_dir = cases_dir / test - with open(test_dir / "petab" / "problem_ude.yaml") as f: + test_dir = ude_cases_dir / test + with open(test_dir / "petab" / "problem.yaml") as f: petab_yaml = safe_load(f) with open(test_dir / "solutions.yaml") as f: solutions = safe_load(f) @@ -211,26 +189,7 @@ def test_ude(test): with change_directory(test_dir / "petab"): from petab.v2 import Problem - petab_yaml["format_version"] = "2.0.0" - for problem in petab_yaml["problems"]: - problem["model_files"] = { - problem["model_files"]["location"].split(".")[0]: problem[ - "model_files" - ] - } - for mapping_file in problem["mapping_files"]: - df = pd.read_csv( - mapping_file, - sep="\t", - ) - if df[petab.PETAB_ENTITY_ID].str.startswith("net").any(): - df.rename( - columns={ - petab.PETAB_ENTITY_ID: petab.MODEL_ENTITY_ID, - petab.MODEL_ENTITY_ID: petab.PETAB_ENTITY_ID, - } - ).to_csv(mapping_file, sep="\t", index=False) - + petab_yaml["format_version"] = "2.0.0" # TODO: fixme petab_problem = Problem.from_yaml(petab_yaml) jax_model = import_petab_problem( petab_problem, @@ -238,36 +197,49 @@ def test_ude(test): compile_=True, jax=True, ) - jax_problem = JAXProblem(jax_model, petab_problem) - for net, net_config in petab_problem.extensions_config[ - "petab_sciml" - ].items(): - pars = h5py.File( - net_config["parameters"].replace(".h5", ".hf5"), "r" - ) - for layer_name, layer in jax_problem.model.nns[net].layers.items(): - for attribute in dir(layer): - if not isinstance( - getattr(layer, attribute), jax.numpy.ndarray - ): - continue - value = jnp.array(pars[layer_name][attribute]) + # non_numeric = pd.to_numeric(petab_problem.parameter_df[petab.NOMINAL_VALUE], errors='coerce').isna() + # par_files = petab_problem.parameter_df.loc[non_numeric, petab.NOMINAL_VALUE].unique() + # par_values = { + # par_file: h5py.File(par_file, "r") + # for par_file in par_files + # } + # for par_id, row in petab_problem.parameter_df.iterrows(): + # if not non_numeric[par_id]: + # continue + # petab_problem.parameter_df.loc[par_id, petab.NOMINAL_VALUE] = \ + # (par_values[row[petab.NOMINAL_VALUE]],) + # petab_problem.parameter_df.loc[np.logical_not(non_numeric), petab.NOMINAL_VALUE] = pd.to_numeric( + # petab_problem.parameter_df.loc[np.logical_not(non_numeric), petab.NOMINAL_VALUE] + # ) - if ( - isinstance(layer, eqx.nn.ConvTranspose) - and attribute == "weight" - ): - # see FAQ in https://docs.kidger.site/equinox/api/nn/conv/#equinox.nn.ConvTranspose - value = jnp.flip( - value, axis=tuple(range(2, value.ndim)) - ).swapaxes(0, 1) - jax_problem = eqx.tree_at( - lambda x: getattr( - x.model.nns[net].layers[layer_name], attribute - ), - jax_problem, - value, - ) + jax_problem = JAXProblem(jax_model, petab_problem) + # for net, net_config in petab_problem.extensions_config.items(): # TODO: FIXME (https://github.com/sebapersson/petab_sciml_testsuite/issues/1) + # pars = h5py.File( + # net_config["net1_ps_file"]['location'], "r" # TODO: check format and actually use propoer petab nominal parameter infrastructure + # ) + # for layer_name, layer in jax_problem.model.nns[net].layers.items(): + # for attribute in dir(layer): + # if not isinstance( + # getattr(layer, attribute), jax.numpy.ndarray + # ): + # continue + # value = jnp.array(pars[layer_name][attribute]) + # + # if ( + # isinstance(layer, eqx.nn.ConvTranspose) + # and attribute == "weight" + # ): + # # see FAQ in https://docs.kidger.site/equinox/api/nn/conv/#equinox.nn.ConvTranspose + # value = jnp.flip( + # value, axis=tuple(range(2, value.ndim)) + # ).swapaxes(0, 1) + # jax_problem = eqx.tree_at( + # lambda x: getattr( + # x.model.nns[net].layers[layer_name], attribute + # ), + # jax_problem, + # value, + # ) # llh if test in ( From 59c9be6ca996bbabc5f18dace1b1210cab3c9b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Wed, 16 Apr 2025 17:43:13 +0100 Subject: [PATCH 35/92] Update testsuite --- tests/sciml/testsuite | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/sciml/testsuite b/tests/sciml/testsuite index 90599dd22d..3d5f91543d 160000 --- a/tests/sciml/testsuite +++ b/tests/sciml/testsuite @@ -1 +1 @@ -Subproject commit 90599dd22d362b6e0c8bf8f55531a6f448b005fb +Subproject commit 3d5f91543d000f2468c7380853db4c0206596a00 From 6632a9c53be91a425f6024b93b40e9b0e5837655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 17 Apr 2025 13:30:16 +0100 Subject: [PATCH 36/92] fix #2687 --- src/misc.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/misc.cpp b/src/misc.cpp index f54a45052d..920d7f9acb 100644 --- a/src/misc.cpp +++ b/src/misc.cpp @@ -83,10 +83,10 @@ std::string backtraceString(int const maxFrames, int const first_frame) { trace_buf << "stacktrace not available on windows platforms\n"; #else int const last_frame = first_frame + maxFrames; - void* callstack[last_frame]; + std::vector callstack(last_frame); char buf[1024]; - int nFrames = backtrace(callstack, last_frame); - char** symbols = backtrace_symbols(callstack, nFrames); + int nFrames = backtrace(callstack.data(), last_frame); + char** symbols = backtrace_symbols(callstack.data(), nFrames); for (int i = first_frame; i < nFrames; i++) { // call From 6b133e20d54b3e7e1c3a28fd37d87f0c4364dc5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Sat, 17 May 2025 19:45:51 +0100 Subject: [PATCH 37/92] spec updates --- python/sdist/amici/petab/petab_import.py | 21 ++++++++++++++++++--- python/sdist/pyproject.toml | 4 ++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/python/sdist/amici/petab/petab_import.py b/python/sdist/amici/petab/petab_import.py index 112b99a5d6..ac6e113609 100644 --- a/python/sdist/amici/petab/petab_import.py +++ b/python/sdist/amici/petab/petab_import.py @@ -12,6 +12,7 @@ from warnings import warn import amici +import pandas as pd import petab.v1 as petab from petab.v1.models import MODEL_TYPE_PYSB, MODEL_TYPE_SBML @@ -151,14 +152,28 @@ def import_petab_problem( config = petab_problem.extensions_config # TODO: only accept YAML format for now - # TODO: edit petab library to load hybridization table and map input and output vars here + hybridization_table = pd.read_csv( + config["hybridization_file"], sep="\t" + ) + input_mapping = dict( + zip( + hybridization_table["targetId"], + hybridization_table["targetValue"], + ) + ) + output_mapping = dict( + zip( + hybridization_table["targetValue"], + hybridization_table["targetId"], + ) + ) hybridization = { net_id: { "model": PetabScimlStandard.load_data( Path() / net_config["location"] ).models, "input_vars": [ - petab_id + input_mapping[petab_id] for petab_id, model_id in petab_problem.mapping_df.loc[ petab_problem.mapping_df[petab.MODEL_ENTITY_ID] .str.split(".") @@ -171,7 +186,7 @@ def import_petab_problem( if model_id.split(".")[1].startswith("input") ], "output_vars": [ - petab_id + output_mapping[petab_id] for petab_id, model_id in petab_problem.mapping_df.loc[ petab_problem.mapping_df[petab.MODEL_ENTITY_ID] .str.split(".") diff --git a/python/sdist/pyproject.toml b/python/sdist/pyproject.toml index ddac4f9392..e5a6b41ec5 100644 --- a/python/sdist/pyproject.toml +++ b/python/sdist/pyproject.toml @@ -73,6 +73,7 @@ test = [ "scipy", "pooch", "beartype", + "" ] vis = [ "matplotlib", @@ -91,6 +92,9 @@ jax = [ "optimistix>=0.0.9", "interpax>=0.3.3,<=0.3.6", ] +sciml = [ + "h5py" +] [project.scripts] # amici_import_petab.py is kept for backwards compatibility From 7f5c3ad449e6419a530119560f9dbe40de5dc897 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Mon, 16 Jun 2025 14:57:00 +0100 Subject: [PATCH 38/92] updates for overhauled testsuite --- .github/workflows/test_petab_sciml.yml | 11 ++---- python/sdist/amici/jax/nn.py | 16 ++++---- python/sdist/amici/jax/ode_export.py | 9 ++--- python/sdist/amici/jax/petab.py | 36 +++++++++++++----- python/sdist/amici/petab/petab_import.py | 6 +-- python/sdist/pyproject.toml | 1 - tests/sciml/test_sciml.py | 47 ++---------------------- 7 files changed, 48 insertions(+), 78 deletions(-) diff --git a/.github/workflows/test_petab_sciml.yml b/.github/workflows/test_petab_sciml.yml index eb8ca39394..98cc735e7a 100644 --- a/.github/workflows/test_petab_sciml.yml +++ b/.github/workflows/test_petab_sciml.yml @@ -22,7 +22,7 @@ jobs: strategy: matrix: - python-version: ["3.11"] + python-version: ["3.12"] steps: - name: Set up Python ${{ matrix.python-version }} @@ -56,13 +56,10 @@ jobs: && pip3 install wheel pytest shyaml pytest-cov # retrieve test models - - name: Download and install PEtab SciML test suite + - name: Download and install PEtab SciML run: | - git clone --depth 1 --branch main \ - https://github.com/sebapersson/petab_sciml.git \ - && export SCIML_TESTSUITE="$(pwd)/petab_sciml" \ - && source venv/bin/activate \ - && python -m pip install -e $SCIML_TESTSUITE/src/python + source ./venv/bin/activate \ + && python -m pip install git+https://github.com/sebapersson/petab_sciml.git@unify_data#subdirectory=src/python \ - name: Install petab diff --git a/python/sdist/amici/jax/nn.py b/python/sdist/amici/jax/nn.py index d503df2393..74966ddd57 100644 --- a/python/sdist/amici/jax/nn.py +++ b/python/sdist/amici/jax/nn.py @@ -30,7 +30,7 @@ def tanhshrink(x: jnp.ndarray) -> jnp.ndarray: return x - jnp.tanh(x) -def generate_equinox(ml_model: "MLModel", filename: Path | str): # noqa: F821 +def generate_equinox(nn_model: "NNModel", filename: Path | str): # noqa: F821 # TODO: move to top level import and replace forward type definitions from petab_sciml import Layer @@ -38,14 +38,14 @@ def generate_equinox(ml_model: "MLModel", filename: Path | str): # noqa: F821 layer_indent = 12 node_indent = 8 - layers = {layer.layer_id: layer for layer in ml_model.layers} + layers = {layer.layer_id: layer for layer in nn_model.layers} tpl_data = { - "MODEL_ID": ml_model.mlmodel_id, + "MODEL_ID": nn_model.nn_model_id, "LAYERS": ",\n".join( [ _generate_layer(layer, layer_indent, ilayer) - for ilayer, layer in enumerate(ml_model.layers) + for ilayer, layer in enumerate(nn_model.layers) ] )[layer_indent:], "FORWARD": "\n".join( @@ -58,19 +58,19 @@ def generate_equinox(ml_model: "MLModel", filename: Path | str): # noqa: F821 Layer(layer_id="dummy", layer_type="Linear"), ).layer_type, ) - for node in ml_model.forward + for node in nn_model.forward ] )[node_indent:], - "INPUT": ", ".join([f"'{inp.input_id}'" for inp in ml_model.inputs]), + "INPUT": ", ".join([f"'{inp.input_id}'" for inp in nn_model.inputs]), "OUTPUT": ", ".join( [ f"'{arg}'" for arg in next( - node for node in ml_model.forward if node.op == "output" + node for node in nn_model.forward if node.op == "output" ).args ] ), - "N_LAYERS": len(ml_model.layers), + "N_LAYERS": len(nn_model.layers), } filename.parent.mkdir(parents=True, exist_ok=True) diff --git a/python/sdist/amici/jax/ode_export.py b/python/sdist/amici/jax/ode_export.py index 846b873a3c..cb3a39b16d 100644 --- a/python/sdist/amici/jax/ode_export.py +++ b/python/sdist/amici/jax/ode_export.py @@ -267,11 +267,10 @@ def _generate_jax_code(self) -> None: def _generate_nn_code(self) -> None: for net_name, net in self.hybridisation.items(): - for model in net["model"]: - generate_equinox( - model, - self.model_path / f"{net_name}.py", - ) + generate_equinox( + net["model"], + self.model_path / f"{net_name}.py", + ) def set_paths(self, output_dir: str | Path | None = None) -> None: """ diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index bc80529574..39d55e7631 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -6,6 +6,7 @@ from collections.abc import Sized, Iterable from pathlib import Path from collections.abc import Callable +import logging import diffrax @@ -23,6 +24,7 @@ ParameterMappingForCondition, create_parameter_mapping, ) +from amici.logging import get_logger from amici.jax.model import JAXModel, ReturnValue DEFAULT_CONTROLLER_SETTINGS = { @@ -39,6 +41,8 @@ petab.LOG10: 2, } +logger = get_logger(__name__, logging.WARNING) + def jax_unscale( parameter: jnp.float_, @@ -512,28 +516,35 @@ def _get_nominal_parameter_values( } for net_id, nn in model.nns.items() } + # load nn parameters from file + par_arrays = { + array_id: h5py.File(file_spec["location"], "r") + for array_id, file_spec in self._petab_problem.extensions_config[ + "array_files" + ].items() + # TODO: FIXME (https://github.com/sebapersson/petab_sciml_testsuite/issues/1) + } + # extract nominal values from petab problem for pname, row in self._petab_problem.parameter_df.iterrows(): if (net := pname.split(".")[0]) in model.nns: to_set = [] nn = model_pars[net] - scalar = True try: value = float(row[petab.NOMINAL_VALUE]) except ValueError: - value = h5py.File(row[petab.NOMINAL_VALUE], "r") + value = par_arrays[row[petab.NOMINAL_VALUE]] scalar = False - if len(pname.split(".")) > 1: - layer = nn[pname.split(".")[1]] + layer_name = pname.split(".")[1] + layer = nn[layer_name] if len(pname.split(".")) > 2: - to_set.append( - (pname.split(".")[1], pname.split(".")[2]) - ) + attribute_name = pname.split(".")[2] + to_set.append((layer_name, attribute_name)) else: to_set.extend( [ - (pname.split(".")[1], attribute) + (layer_name, attribute) for attribute in layer.keys() ] ) @@ -549,15 +560,20 @@ def _get_nominal_parameter_values( for layer, attribute in to_set: if scalar: nn[layer][attribute] = value * jnp.ones_like( - nn[layer][attribute] + model.nns[net].layers[layer][attribute] ) else: - nn[layer][attribute] = value[layer][attribute] + nn[layer][attribute] = jnp.array( + value[layer][attribute] + ) # set values in model for net_id in model_pars: for layer_id in model_pars[net_id]: for attribute in model_pars[net_id][layer_id]: + logger.debug( + f"Setting {attribute} of layer {layer_id} in network {net_id} to {model_pars[net_id][layer_id][attribute]}" + ) model = eqx.tree_at( lambda model: getattr( model.nns[net_id].layers[layer_id], attribute diff --git a/python/sdist/amici/petab/petab_import.py b/python/sdist/amici/petab/petab_import.py index ac6e113609..cb398b5a55 100644 --- a/python/sdist/amici/petab/petab_import.py +++ b/python/sdist/amici/petab/petab_import.py @@ -148,7 +148,7 @@ def import_petab_problem( logger.info(f"Compiling model {model_name} to {model_output_dir}.") if "neural_nets" in petab_problem.extensions_config: # TODO: fixme - from petab_sciml import PetabScimlStandard + from petab_sciml.standard import NNModelStandard config = petab_problem.extensions_config # TODO: only accept YAML format for now @@ -169,9 +169,9 @@ def import_petab_problem( ) hybridization = { net_id: { - "model": PetabScimlStandard.load_data( + "model": NNModelStandard.load_data( Path() / net_config["location"] - ).models, + ), "input_vars": [ input_mapping[petab_id] for petab_id, model_id in petab_problem.mapping_df.loc[ diff --git a/python/sdist/pyproject.toml b/python/sdist/pyproject.toml index bf84b65e3e..374864508d 100644 --- a/python/sdist/pyproject.toml +++ b/python/sdist/pyproject.toml @@ -72,7 +72,6 @@ test = [ "scipy", "pooch", "beartype", - "" ] vis = [ "matplotlib", diff --git a/tests/sciml/test_sciml.py b/tests/sciml/test_sciml.py index bdf33e8246..9b46f80fc2 100644 --- a/tests/sciml/test_sciml.py +++ b/tests/sciml/test_sciml.py @@ -22,7 +22,7 @@ import h5py from contextlib import contextmanager -from petab_sciml import PetabScimlStandard +from petab_sciml import NNModelStandard @contextmanager @@ -78,7 +78,7 @@ def test_net(test): net_file = cases_dir / test.replace("_alt", "") / solutions["net_file"] else: net_file = test_dir / solutions["net_file"] - ml_models = PetabScimlStandard.load_data(net_file) + ml_models = NNModelStandard.load_data(net_file) nets = {} outdir = Path(__file__).parent / "models" / test @@ -197,49 +197,8 @@ def test_ude(test): compile_=True, jax=True, ) - # non_numeric = pd.to_numeric(petab_problem.parameter_df[petab.NOMINAL_VALUE], errors='coerce').isna() - # par_files = petab_problem.parameter_df.loc[non_numeric, petab.NOMINAL_VALUE].unique() - # par_values = { - # par_file: h5py.File(par_file, "r") - # for par_file in par_files - # } - # for par_id, row in petab_problem.parameter_df.iterrows(): - # if not non_numeric[par_id]: - # continue - # petab_problem.parameter_df.loc[par_id, petab.NOMINAL_VALUE] = \ - # (par_values[row[petab.NOMINAL_VALUE]],) - # petab_problem.parameter_df.loc[np.logical_not(non_numeric), petab.NOMINAL_VALUE] = pd.to_numeric( - # petab_problem.parameter_df.loc[np.logical_not(non_numeric), petab.NOMINAL_VALUE] - # ) jax_problem = JAXProblem(jax_model, petab_problem) - # for net, net_config in petab_problem.extensions_config.items(): # TODO: FIXME (https://github.com/sebapersson/petab_sciml_testsuite/issues/1) - # pars = h5py.File( - # net_config["net1_ps_file"]['location'], "r" # TODO: check format and actually use propoer petab nominal parameter infrastructure - # ) - # for layer_name, layer in jax_problem.model.nns[net].layers.items(): - # for attribute in dir(layer): - # if not isinstance( - # getattr(layer, attribute), jax.numpy.ndarray - # ): - # continue - # value = jnp.array(pars[layer_name][attribute]) - # - # if ( - # isinstance(layer, eqx.nn.ConvTranspose) - # and attribute == "weight" - # ): - # # see FAQ in https://docs.kidger.site/equinox/api/nn/conv/#equinox.nn.ConvTranspose - # value = jnp.flip( - # value, axis=tuple(range(2, value.ndim)) - # ).swapaxes(0, 1) - # jax_problem = eqx.tree_at( - # lambda x: getattr( - # x.model.nns[net].layers[layer_name], attribute - # ), - # jax_problem, - # value, - # ) # llh if test in ( @@ -281,7 +240,7 @@ def test_ude(test): controller=diffrax.PIDController(atol=1e-14, rtol=1e-14), max_steps=2**16, ) - for component, file in solutions["grad_llh_files"].items(): + for component, file in solutions["grad_files"].items(): actual_dict = {} if component == "mech": expected = pd.read_csv(test_dir / file, sep="\t").set_index( From 0348ee43f5cc0a8564a1a23700b94e1debdeff4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Fri, 20 Jun 2025 23:51:46 +0100 Subject: [PATCH 39/92] Update nn.py --- python/sdist/amici/jax/nn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/sdist/amici/jax/nn.py b/python/sdist/amici/jax/nn.py index 74966ddd57..a7d49bd0f7 100644 --- a/python/sdist/amici/jax/nn.py +++ b/python/sdist/amici/jax/nn.py @@ -190,7 +190,7 @@ def _generate_forward(node: "Node", indent, layer_type=str) -> str: # noqa: F82 args = ", ".join([f"{arg}" for arg in node.args]) kwargs = [ - f"{k}={v}" for k, v in node.kwargs.items() if k not in ("inplace",) + "=".join(item) for item in node.kwargs.items() if k not in ("inplace",) ] if layer_type.startswith(("Dropout",)): kwargs += ["key=key"] From f1ece15faa8f405d30fabf202893bc7b9ef56c32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Tue, 7 Oct 2025 15:26:27 +0200 Subject: [PATCH 40/92] Update petab_import.py --- python/sdist/amici/petab/petab_import.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/python/sdist/amici/petab/petab_import.py b/python/sdist/amici/petab/petab_import.py index dcba31bc7b..8119d970ac 100644 --- a/python/sdist/amici/petab/petab_import.py +++ b/python/sdist/amici/petab/petab_import.py @@ -87,12 +87,6 @@ def import_petab_problem( "Unsupported model type " + petab_problem.model.type_id ) - if petab_problem.mapping_df is not None: - warn( - "PEtab v2.0.0 mapping tables are only partially supported, use at your own risk.", - stacklevel=2, - ) - model_name = model_name or petab_problem.model.model_id if petab_problem.model.type_id == MODEL_TYPE_PYSB and model_name is None: From ec1ed55a31bbb41a8ad3f6a49b9448684a617f72 Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Mon, 1 Sep 2025 16:37:18 +0100 Subject: [PATCH 41/92] Getting test_net petab-sciml tests to pass - update sciml testsuite submodule to point at main - fix a eqx LayerNorm deprecation warning - fix string formatting of bool in kwarg - updates to test code driven by updated sciml format --- python/sdist/amici/jax/nn.py | 4 +- tests/sciml/test_sciml.py | 160 +++++++++++++++++------------------ tests/sciml/testsuite | 2 +- 3 files changed, 83 insertions(+), 83 deletions(-) diff --git a/python/sdist/amici/jax/nn.py b/python/sdist/amici/jax/nn.py index a7d49bd0f7..e26d58693e 100644 --- a/python/sdist/amici/jax/nn.py +++ b/python/sdist/amici/jax/nn.py @@ -120,7 +120,7 @@ def _generate_layer(layer: "Layer", indent: int, ilayer: int) -> str: # noqa: F "bias": "use_bias", }, "LayerNorm": { - "affine": "elementwise_affine", + "elementwise_affine": "use_bias", # Deprecation warning - replace LayerNorm(elementwise_affine) with LayerNorm(use_bias) "normalized_shape": "shape", }, } @@ -190,7 +190,7 @@ def _generate_forward(node: "Node", indent, layer_type=str) -> str: # noqa: F82 args = ", ".join([f"{arg}" for arg in node.args]) kwargs = [ - "=".join(item) for item in node.kwargs.items() if k not in ("inplace",) + f"{k}={item}" for k, item in node.kwargs.items() if k not in ("inplace",) ] if layer_type.startswith(("Dropout",)): kwargs += ["key=key"] diff --git a/tests/sciml/test_sciml.py b/tests/sciml/test_sciml.py index 9b46f80fc2..72629d023d 100644 --- a/tests/sciml/test_sciml.py +++ b/tests/sciml/test_sciml.py @@ -78,102 +78,102 @@ def test_net(test): net_file = cases_dir / test.replace("_alt", "") / solutions["net_file"] else: net_file = test_dir / solutions["net_file"] - ml_models = NNModelStandard.load_data(net_file) + ml_model = NNModelStandard.load_data(net_file) nets = {} outdir = Path(__file__).parent / "models" / test - for ml_model in ml_models.models: - module_dir = outdir / f"{ml_model.mlmodel_id}.py" - if test in ( - "002", - "009", - "018", - "019", - "020", - "021", - "022", - "042", - "043", - "044", - "045", - "046", - "047", - "048", - ): - with pytest.raises(NotImplementedError): - generate_equinox(ml_model, module_dir) - return - generate_equinox(ml_model, module_dir) - nets[ml_model.mlmodel_id] = amici._module_from_path( - ml_model.mlmodel_id, module_dir - ).net + module_dir = outdir / f"{ml_model.nn_model_id}.py" + if test in ( + "002", + "009", + "018", + "019", + "020", + "021", + "022", + "042", + "043", + "044", + "045", + "046", + "047", + "048", + ): + with pytest.raises(NotImplementedError): + generate_equinox(ml_model, module_dir) + return + generate_equinox(ml_model, module_dir) + nets[ml_model.nn_model_id] = amici._module_from_path( + ml_model.nn_model_id, module_dir + ).net for input_file, par_file, output_file in zip( solutions["net_input"], solutions.get("net_ps", solutions["net_input"]), solutions["net_output"], ): - input = h5py.File(test_dir / input_file, "r")["input"][:] - output = h5py.File(test_dir / output_file, "r")["output"][:] + input = h5py.File(test_dir / input_file, "r")["inputs"]["input0"]["data"][:] + output = h5py.File(test_dir / output_file, "r")["outputs"]["output0"]["data"][:] if "net_ps" in solutions: par = h5py.File(test_dir / par_file, "r") - for ml_model in ml_models.models: - net = nets[ml_model.mlmodel_id](jr.PRNGKey(0)) - for layer in net.layers.keys(): - if ( - isinstance(net.layers[layer], eqx.Module) - and hasattr(net.layers[layer], "weight") - and net.layers[layer].weight is not None - ): - w = par[layer]["weight"][:] - if isinstance(net.layers[layer], eqx.nn.ConvTranspose): - # see FAQ in https://docs.kidger.site/equinox/api/nn/conv/#equinox.nn.ConvTranspose - w = np.flip( - w, axis=tuple(range(2, w.ndim)) - ).swapaxes(0, 1) - assert w.shape == net.layers[layer].weight.shape - net = eqx.tree_at( - lambda x: x.layers[layer].weight, - net, - jnp.array(w), - ) - if ( - isinstance(net.layers[layer], eqx.Module) - and hasattr(net.layers[layer], "bias") - and net.layers[layer].bias is not None + net = nets[ml_model.nn_model_id](jr.PRNGKey(0)) + for layer in net.layers.keys(): + if ( + isinstance(net.layers[layer], eqx.Module) + and hasattr(net.layers[layer], "weight") + and net.layers[layer].weight is not None + ): + # ?? grabbing weights from the parameters file ?? need to check if they are present in above if condition ?? + w = par["parameters"][ml_model.nn_model_id][layer]["weight"][:] + if isinstance(net.layers[layer], eqx.nn.ConvTranspose): + # see FAQ in https://docs.kidger.site/equinox/api/nn/conv/#equinox.nn.ConvTranspose + w = np.flip( + w, axis=tuple(range(2, w.ndim)) + ).swapaxes(0, 1) + assert w.shape == net.layers[layer].weight.shape + net = eqx.tree_at( + lambda x: x.layers[layer].weight, + net, + jnp.array(w), + ) + if ( + isinstance(net.layers[layer], eqx.Module) + and hasattr(net.layers[layer], "bias") + and net.layers[layer].bias is not None + ): + # ?? grabbing biases from the parameters file ?? need to check if they are present in above if condition ?? + b = par["parameters"][ml_model.nn_model_id][layer]["bias"][:] + if isinstance( + net.layers[layer], + eqx.nn.Conv | eqx.nn.ConvTranspose, ): - b = par[layer]["bias"][:] - if isinstance( - net.layers[layer], - eqx.nn.Conv | eqx.nn.ConvTranspose, - ): - b = np.expand_dims( - b, - tuple( - range( - 1, - net.layers[layer].num_spatial_dims + 1, - ) - ), - ) - assert b.shape == net.layers[layer].bias.shape - net = eqx.tree_at( - lambda x: x.layers[layer].bias, - net, - jnp.array(b), + b = np.expand_dims( + b, + tuple( + range( + 1, + net.layers[layer].num_spatial_dims + 1, + ) + ), ) - net = eqx.nn.inference_mode(net) + assert b.shape == net.layers[layer].bias.shape + net = eqx.tree_at( + lambda x: x.layers[layer].bias, + net, + jnp.array(b), + ) + net = eqx.nn.inference_mode(net) - if test == "net_004_alt": - return # skipping, no support for non-cross-correlation in equinox + if test == "net_004_alt": + return # skipping, no support for non-cross-correlation in equinox - np.testing.assert_allclose( - net.forward(input), - output, - atol=1e-3, - rtol=1e-3, - ) + np.testing.assert_allclose( + net.forward(input), + output, + atol=1e-3, + rtol=1e-3, + ) @pytest.mark.parametrize( diff --git a/tests/sciml/testsuite b/tests/sciml/testsuite index 3d5f91543d..596cf82a14 160000 --- a/tests/sciml/testsuite +++ b/tests/sciml/testsuite @@ -1 +1 @@ -Subproject commit 3d5f91543d000f2468c7380853db4c0206596a00 +Subproject commit 596cf82a145093bb893420d79ea93be5ebfc725b From 2e1419815ef0d6c6ebfe708667a90517b51d57ce Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Mon, 1 Sep 2025 16:37:44 +0100 Subject: [PATCH 42/92] Implementing features for a subset of ude petab_sciml test cases. Excludes: - frozen nn layers - nns in observable formulae --- python/sdist/amici/jax/model.py | 11 + python/sdist/amici/jax/nn.py | 6 +- python/sdist/amici/jax/ode_export.py | 2 - python/sdist/amici/jax/petab.py | 222 ++++++++++++++++-- python/sdist/amici/petab/parameter_mapping.py | 24 +- python/sdist/amici/petab/petab_import.py | 16 +- tests/sciml/test_sciml.py | 36 ++- 7 files changed, 267 insertions(+), 50 deletions(-) diff --git a/python/sdist/amici/jax/model.py b/python/sdist/amici/jax/model.py index 917836b6d9..d920bf5ad0 100644 --- a/python/sdist/amici/jax/model.py +++ b/python/sdist/amici/jax/model.py @@ -545,6 +545,8 @@ def simulate_condition( x_preeq: jt.Float[jt.Array, "*nx"] = jnp.array([]), mask_reinit: jt.Bool[jt.Array, "*nx"] = jnp.array([]), x_reinit: jt.Float[jt.Array, "*nx"] = jnp.array([]), + init_override: jt.Float[jt.Array, "*nx"] = jnp.array([]), + init_override_mask: jt.Bool[jt.Array, "*nx"] = jnp.array([]), ts_mask: jt.Bool[jt.Array, "nt"] = jnp.array([]), ret: ReturnValue = ReturnValue.llh, ) -> tuple[jt.Float[jt.Array, "nt *nx"] | jnp.float_, dict]: @@ -588,6 +590,10 @@ def simulate_condition( mask for re-initialization. If `True`, the corresponding state variable is re-initialized. :param x_reinit: re-initialized state vector. If not provided, the state vector is not re-initialized. + :param init_override: + override model input e.g. with neural net outputs. If not provided, the inputs are not overridden. + :param init_override_mask: + mask for input override. If `True`, the corresponding input is replaced with value from init_override. :param ts_mask: mask to remove (padded) time points. If `True`, the corresponding time point is used for the evaluation of the output. Only applied if ret is ReturnValue.llh, ReturnValue.nllhs, ReturnValue.res, or ReturnValue.chi2. @@ -602,6 +608,11 @@ def simulate_condition( if x_preeq.shape[0]: x = x_preeq + elif init_override.shape[0]: + x_def = self._x0(t0, p) + x = jnp.squeeze( + jnp.where(init_override_mask, init_override, x_def) + ) else: x = self._x0(t0, p) diff --git a/python/sdist/amici/jax/nn.py b/python/sdist/amici/jax/nn.py index e26d58693e..d409063c20 100644 --- a/python/sdist/amici/jax/nn.py +++ b/python/sdist/amici/jax/nn.py @@ -120,7 +120,7 @@ def _generate_layer(layer: "Layer", indent: int, ilayer: int) -> str: # noqa: F "bias": "use_bias", }, "LayerNorm": { - "elementwise_affine": "use_bias", # Deprecation warning - replace LayerNorm(elementwise_affine) with LayerNorm(use_bias) + "elementwise_affine": "use_bias", # Deprecation warning - replace LayerNorm(elementwise_affine) with LayerNorm(use_bias) "normalized_shape": "shape", }, } @@ -190,7 +190,9 @@ def _generate_forward(node: "Node", indent, layer_type=str) -> str: # noqa: F82 args = ", ".join([f"{arg}" for arg in node.args]) kwargs = [ - f"{k}={item}" for k, item in node.kwargs.items() if k not in ("inplace",) + f"{k}={item}" + for k, item in node.kwargs.items() + if k not in ("inplace",) ] if layer_type.startswith(("Dropout",)): kwargs += ["key=key"] diff --git a/python/sdist/amici/jax/ode_export.py b/python/sdist/amici/jax/ode_export.py index c57492e390..53b8479155 100644 --- a/python/sdist/amici/jax/ode_export.py +++ b/python/sdist/amici/jax/ode_export.py @@ -283,7 +283,6 @@ def _generate_jax_code(self) -> None: tpl_data, ) - def _generate_nn_code(self) -> None: for net_name, net in self.hybridisation.items(): generate_equinox( @@ -303,7 +302,6 @@ def _implicit_roots(self) -> list[sp.Expr]: roots.append(root) return roots - def set_paths(self, output_dir: str | Path | None = None) -> None: """ Set output paths for the model and create if necessary diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index 29f1e65b66..86bed469c6 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -20,6 +20,7 @@ import pandas as pd import petab.v1 as petab import h5py +import re from amici import _module_from_path from amici.petab.parameter_mapping import ( @@ -95,6 +96,7 @@ class JAXProblem(eqx.Module): model: JAXModel simulation_conditions: tuple[tuple[str, ...], ...] _parameter_mappings: dict[str, ParameterMappingForCondition] + _hybridization_df: pd.DataFrame _ts_dyn: np.ndarray _ts_posteq: np.ndarray _my: np.ndarray @@ -122,6 +124,7 @@ def __init__(self, model: JAXModel, petab_problem: petab.Problem): scs = petab_problem.get_simulation_conditions_from_measurement_df() self.simulation_conditions = tuple(tuple(sc) for sc in scs.values) self._petab_problem = petab_problem + self._hybridization_df = self._get_hybridization_df() self.parameters, self.model = self._get_nominal_parameter_values(model) self._parameter_mappings = self._get_parameter_mappings(scs) ( @@ -524,28 +527,51 @@ def _get_nominal_parameter_values( for net_id, nn in model.nns.items() } # load nn parameters from file - par_arrays = { - array_id: h5py.File(file_spec["location"], "r") - for array_id, file_spec in self._petab_problem.extensions_config[ - "array_files" - ].items() - # TODO: FIXME (https://github.com/sebapersson/petab_sciml_testsuite/issues/1) - } + # ?? dict similar to the one above ?? {nn_id: {layer_id: {weight, bias}}} ?? + par_arrays = dict( + [ + ( + file_spec.split("_")[0], + h5py.File(file_spec, "r")["parameters"][ + file_spec.split("_")[0] + ], + ) + for file_spec in self._petab_problem.extensions_config[ + "sciml" + ]["array_files"] + if "parameters" in h5py.File(file_spec, "r").keys() + ] + ) + + nn_input_arrays = dict( + [ + (file_spec.split("_")[0], h5py.File(file_spec, "r")["inputs"]) + for file_spec in self._petab_problem.extensions_config[ + "sciml" + ]["array_files"] + if "inputs" in h5py.File(file_spec, "r").keys() + ] + ) # extract nominal values from petab problem for pname, row in self._petab_problem.parameter_df.iterrows(): - if (net := pname.split(".")[0]) in model.nns: + if (net := pname.split("_")[0]) in model.nns: to_set = [] nn = model_pars[net] - try: - value = float(row[petab.NOMINAL_VALUE]) - except ValueError: - value = par_arrays[row[petab.NOMINAL_VALUE]] + scalar = True + + # ?? When value is NaN do we want to go to par_arrays ?? + if np.isnan(row[petab.NOMINAL_VALUE]): + value = par_arrays[net] scalar = False + else: + value = float(row[petab.NOMINAL_VALUE]) + if len(pname.split(".")) > 1: layer_name = pname.split(".")[1] layer = nn[layer_name] if len(pname.split(".")) > 2: + # ?? Recursion needed ?? attribute_name = pname.split(".")[2] to_set.append((layer_name, attribute_name)) else: @@ -567,11 +593,11 @@ def _get_nominal_parameter_values( for layer, attribute in to_set: if scalar: nn[layer][attribute] = value * jnp.ones_like( - model.nns[net].layers[layer][attribute] + getattr(model.nns[net].layers[layer], attribute) ) else: nn[layer][attribute] = jnp.array( - value[layer][attribute] + value[layer][attribute][:] ) # set values in model @@ -588,6 +614,38 @@ def _get_nominal_parameter_values( model, model_pars[net_id][layer_id][attribute], ) + + # set inputs in the model if provided + if len(nn_input_arrays) > 0: + for net_id in model_pars: + for input in model.nns[net_id].inputs: + input_array = dict( + [ + ( + input, + dict( + [ + ( + k, + jnp.array( + nn_input_arrays[net_id][input][ + k + ][:], + dtype=jnp.float64, + ), + ) # ?? hardcoded dtype not ideal ?? could infer from env somehow ?? + for k in nn_input_arrays[net_id][ + input + ].keys() + ] + ), + ) + ] + ) + model = eqx.tree_at( + lambda model: model.nns[net_id].inputs, model, input_array + ) + return jnp.array( [ petab.scale( @@ -629,6 +687,17 @@ def _get_inputs(self): ].values.reshape(shape) return inputs + def _get_hybridization_df(self): + if "sciml" in self._petab_problem.extensions_config: + hybridizations = [ + pd.read_csv(hf, sep="\t", index_col=0) + for hf in self._petab_problem.extensions_config["sciml"][ + "hybridization_files" + ] + ] + hybridization_df = pd.concat(hybridizations) + return hybridization_df + @property def parameter_ids(self) -> list[str]: """ @@ -691,7 +760,7 @@ def _unscale( [jax_unscale(pval, scale) for pval, scale in zip(p, scales)] ) - def _eval_nn(self, output_par: str): + def _eval_nn(self, output_par: str, condition_id: str): net_id = self._petab_problem.mapping_df.loc[ output_par, petab.MODEL_ENTITY_ID ].split(".")[0] @@ -709,15 +778,70 @@ def _eval_nn(self, output_par: str): .to_dict() ) + condition_parameter_map = ( + dict( + [ + ( + petab_id, + self._petab_problem.parameter_df.loc[ + self._petab_problem.condition_df.loc[ + condition_id, petab_id + ], + petab.NOMINAL_VALUE, + ], + ) + if "input" + in self._petab_problem.condition_df.loc[ + condition_id, petab_id + ] + else ( + petab_id, + np.float64( + self._petab_problem.condition_df.loc[ + condition_id, petab_id + ] + ), + ) + for petab_id in [ + s for s in model_id_map.values() if "input" in s + ] + ] + ) + if not self._petab_problem.condition_df.empty + else {} + ) + + hybridization_parameter_map = dict( + [ + (petab_id, self._hybridization_df.loc[petab_id, "targetValue"]) + for petab_id in model_id_map.values() + if petab_id in set(self._hybridization_df.index) + ] + ) + + # ?? conditional nightmare ?? refactor ?? net_input = jnp.array( [ - jax.lax.stop_gradient(self._inputs[net_id][model_id]) - if model_id in self._inputs[net_id] + jax.lax.stop_gradient(self.model.nns[net_id][model_id]) + if model_id in self.model.nns[net_id].inputs else self.get_petab_parameter_by_id(petab_id) if petab_id in self.parameter_ids else self._petab_problem.parameter_df.loc[ petab_id, petab.NOMINAL_VALUE ] + if petab_id in set(self._petab_problem.parameter_df.index) + else self.model.nns[net_id].inputs[petab_id][condition_id] + if isinstance(self.model.nns[net_id].inputs, dict) + and condition_id in self.model.nns[net_id].inputs[petab_id] + else self.model.nns[net_id].inputs[petab_id][ + "0" + ] # ?? "0" always the key if inputs for all conditions ?? + if petab_id in self.model.nns[net_id].inputs + else self._petab_problem.parameter_df.loc[ + hybridization_parameter_map[petab_id], petab.NOMINAL_VALUE + ] + if self._petab_problem.condition_df.empty + else condition_parameter_map[petab_id] for model_id, petab_id in model_id_map.items() if model_id.split(".")[1].startswith("input") ] @@ -728,10 +852,20 @@ def _map_model_parameter_value( self, mapping: ParameterMappingForCondition, pname: str, + condition_id: str, ) -> jt.Float[jt.Scalar, ""] | float: # noqa: F722 - if pname in self.nn_output_ids: - return self._eval_nn(pname) pval = mapping.map_sim_var[pname] + if pval in self.nn_output_ids: + nn_output = self._eval_nn(pval, condition_id) + if nn_output.size > 1: + # ?? can this approach work for single dimension return ?? maybe remove the squeeze from _eval_nn ?? + entityId = self._petab_problem.mapping_df.loc[ + pval, petab.MODEL_ENTITY_ID + ] + ind = int(re.search(r"\[\d+\]\[(\d+)\]", entityId).group(1)) + return nn_output[ind] + else: + return nn_output if isinstance(pval, Number): return pval return self.get_petab_parameter_by_id(pval) @@ -751,7 +885,9 @@ def load_model_parameters( p = jnp.array( [ - self._map_model_parameter_value(mapping, pname) + self._map_model_parameter_value( + mapping, pname, simulation_condition + ) for pname in self.model.parameter_ids ] ) @@ -980,6 +1116,8 @@ def _prepare_conditions( in_axes={ "max_steps": None, "self": None, + "init_override": None, # ?? performance hit ?? flip arrays to avoid ?? + "init_override_mask": None, }, # only list arguments here where eqx.is_array(0) is not the right thing ) def run_simulation( @@ -994,6 +1132,8 @@ def run_simulation( nps: jt.Float[jt.Array, "nt *nnp"], # noqa: F821, F722 mask_reinit: jt.Bool[jt.Array, "nx"], # noqa: F821, F722 x_reinit: jt.Float[jt.Array, "nx"], # noqa: F821, F722 + init_override: jt.Float[jt.Array, "np"], # ?? what do these annotations mean ?? + init_override_mask: jt.Bool[jt.Array, "np"], solver: diffrax.AbstractSolver, controller: diffrax.AbstractStepSizeController, root_finder: AbstractRootFinder, @@ -1059,6 +1199,8 @@ def run_simulation( x_preeq=x_preeq, mask_reinit=jax.lax.stop_gradient(mask_reinit), x_reinit=x_reinit, + init_override=init_override, + init_override_mask=init_override_mask, ts_mask=jax.lax.stop_gradient(jnp.array(ts_mask)), solver=solver, controller=controller, @@ -1118,6 +1260,44 @@ def run_simulations( self._np_indices, ) ) + # state values override? + init_override_mask = jnp.stack( + [ + jnp.array( + [ + True + if p + in set(self._parameter_mappings[sc].map_sim_var.keys()) + else False + for p in self.model.state_ids + ] + ) + for sc in simulation_conditions + ] + ) + if not init_override_mask.any(): + init_override_mask = jnp.array([]) + init_override = jnp.array([]) + else: + init_override = jnp.stack( + [ + jnp.array( + [ + self._eval_nn( + self._parameter_mappings[sc].map_sim_var[p], sc + ) + if p + in set( + self._parameter_mappings[sc].map_sim_var.keys() + ) + else 1.0 # ?? dummy value - shouldn't matter ?? + for p in self.model.state_ids + ] + ) + for sc in simulation_conditions + ] + ) + return self.run_simulation( p_array, self._ts_dyn, @@ -1129,6 +1309,8 @@ def run_simulations( np_array, mask_reinit_array, x_reinit_array, + init_override, + init_override_mask, solver, controller, root_finder, diff --git a/python/sdist/amici/petab/parameter_mapping.py b/python/sdist/amici/petab/parameter_mapping.py index beac7321bf..7264ea3a14 100644 --- a/python/sdist/amici/petab/parameter_mapping.py +++ b/python/sdist/amici/petab/parameter_mapping.py @@ -384,7 +384,7 @@ def create_parameter_mapping( measurement_df=petab_problem.measurement_df, parameter_df=petab_problem.parameter_df, observable_df=petab_problem.observable_df, - mapping_df=petab_problem.mapping_df, + # mapping_df=petab_problem.mapping_df, model=petab_problem.model, simulation_conditions=simulation_conditions, fill_fixed_parameters=fill_fixed_parameters, @@ -394,6 +394,8 @@ def create_parameter_mapping( ) ) + # ?? put mappings in later ?? after mapping for condition ?? will there be a performance regression as a result ?? + parameter_mapping = ParameterMapping() for (_, condition), prelim_mapping_for_condition in zip( simulation_conditions.iterrows(), prelim_parameter_mapping, strict=True @@ -585,6 +587,26 @@ def create_parameter_mapping_for_condition( ) logger.debug(f"Merged: {condition_map_sim_var}") + # ?? right place for static hybridization here ?? + + if "sciml" in petab_problem.extensions_config: + hybridizations = [ + pd.read_csv(hf, sep="\t") + for hf in petab_problem.extensions_config["sciml"][ + "hybridization_files" + ] + ] + hybridization_df = pd.concat(hybridizations) + for net_id, config in petab_problem.extensions_config["sciml"][ + "neural_nets" + ].items(): + if config["static"]: + for _, row in hybridization_df.iterrows(): + if row["targetValue"].startswith(net_id): + condition_map_sim_var[row["targetId"]] = row[ + "targetValue" + ] + parameter_mapping_for_condition = ParameterMappingForCondition( map_preeq_fix=condition_map_preeq_fix, map_sim_fix=condition_map_sim_fix, diff --git a/python/sdist/amici/petab/petab_import.py b/python/sdist/amici/petab/petab_import.py index 8119d970ac..8f996cc60b 100644 --- a/python/sdist/amici/petab/petab_import.py +++ b/python/sdist/amici/petab/petab_import.py @@ -129,14 +129,17 @@ def import_petab_problem( logger.info(f"Compiling model {model_name} to {model_output_dir}.") - if "neural_nets" in petab_problem.extensions_config: # TODO: fixme + if "sciml" in petab_problem.extensions_config: from petab_sciml.standard import NNModelStandard - config = petab_problem.extensions_config + config = petab_problem.extensions_config["sciml"] # TODO: only accept YAML format for now - hybridization_table = pd.read_csv( - config["hybridization_file"], sep="\t" - ) + hybridizations = [ + pd.read_csv(hf, sep="\t") + for hf in config["hybridization_files"] + ] + hybridization_table = pd.concat(hybridizations) + input_mapping = dict( zip( hybridization_table["targetId"], @@ -166,6 +169,7 @@ def import_petab_problem( .to_dict() .items() if model_id.split(".")[1].startswith("input") + and petab_id in input_mapping.keys() ], "output_vars": [ output_mapping[petab_id] @@ -179,7 +183,9 @@ def import_petab_problem( .to_dict() .items() if model_id.split(".")[1].startswith("output") + and petab_id in output_mapping.keys() ], + # ?? static included here ?? and handled later ?? **net_config, } for net_id, net_config in config["neural_nets"].items() diff --git a/tests/sciml/test_sciml.py b/tests/sciml/test_sciml.py index 72629d023d..a39c5b8c9f 100644 --- a/tests/sciml/test_sciml.py +++ b/tests/sciml/test_sciml.py @@ -50,9 +50,7 @@ def change_directory(destination): def _reshape_flat_array(array_flat): array_flat["ix"] = array_flat["ix"].astype(str) - ix_cols = [ - f"ix_{i}" for i in range(len(array_flat["ix"].values[0].split(";"))) - ] + ix_cols = [f"ix_{i}" for i in range(len(array_flat["ix"].values[0].split(";")))] if len(ix_cols) == 1: array_flat[ix_cols[0]] = array_flat["ix"].apply(int) else: @@ -66,9 +64,7 @@ def _reshape_flat_array(array_flat): return array -@pytest.mark.parametrize( - "test", sorted([d.stem for d in net_cases_dir.glob("[0-9]*")]) -) +@pytest.mark.parametrize("test", sorted([d.stem for d in net_cases_dir.glob("[0-9]*")])) def test_net(test): test_dir = net_cases_dir / test with open(test_dir / "solutions.yaml") as f: @@ -128,9 +124,7 @@ def test_net(test): w = par["parameters"][ml_model.nn_model_id][layer]["weight"][:] if isinstance(net.layers[layer], eqx.nn.ConvTranspose): # see FAQ in https://docs.kidger.site/equinox/api/nn/conv/#equinox.nn.ConvTranspose - w = np.flip( - w, axis=tuple(range(2, w.ndim)) - ).swapaxes(0, 1) + w = np.flip(w, axis=tuple(range(2, w.ndim))).swapaxes(0, 1) assert w.shape == net.layers[layer].weight.shape net = eqx.tree_at( lambda x: x.layers[layer].weight, @@ -176,9 +170,7 @@ def test_net(test): ) -@pytest.mark.parametrize( - "test", sorted([d.stem for d in ude_cases_dir.glob("[0-9]*")]) -) +@pytest.mark.parametrize("test", sorted([d.stem for d in ude_cases_dir.glob("[0-9]*")])) def test_ude(test): test_dir = ude_cases_dir / test with open(test_dir / "petab" / "problem.yaml") as f: @@ -202,8 +194,16 @@ def test_ude(test): # llh if test in ( + # ?? cases where nn part of observable formula ?? "004", - "016", + "009", + "012", + "013", + "018", + "020", + "022", + "025", + "028", ): with pytest.raises(NotImplementedError): run_simulations(jax_problem) @@ -260,13 +260,9 @@ def test_ude(test): ) else: expected = h5py.File(test_dir / file, "r") - for layer_name, layer in jax_problem.model.nns[ - component - ].layers.items(): + for layer_name, layer in jax_problem.model.nns[component].layers.items(): for attribute in dir(layer): - if not isinstance( - getattr(layer, attribute), jax.numpy.ndarray - ): + if not isinstance(getattr(layer, attribute), jax.numpy.ndarray): continue actual = getattr( sllh.model.nns[component].layers[layer_name], attribute @@ -281,7 +277,7 @@ def test_ude(test): ) np.testing.assert_allclose( actual, - expected[layer_name][attribute][:], + expected["parameters"][component][layer_name][attribute][:], atol=solutions["tol_grad_llh"], rtol=solutions["tol_grad_llh"], ) From d2137c3ae2424bea43c74224af946d336d07caaf Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Mon, 1 Sep 2025 16:29:16 +0100 Subject: [PATCH 43/92] update petab_sciml workflow - on branches and sciml install branch --- .github/workflows/test_petab_sciml.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test_petab_sciml.yml b/.github/workflows/test_petab_sciml.yml index 98cc735e7a..25839e6b46 100644 --- a/.github/workflows/test_petab_sciml.yml +++ b/.github/workflows/test_petab_sciml.yml @@ -3,11 +3,12 @@ on: push: branches: - develop - - master + - main pull_request: branches: - - master + - main - develop + - jax_sciml merge_group: workflow_dispatch: @@ -59,7 +60,7 @@ jobs: - name: Download and install PEtab SciML run: | source ./venv/bin/activate \ - && python -m pip install git+https://github.com/sebapersson/petab_sciml.git@unify_data#subdirectory=src/python \ + && python -m pip install git+https://github.com/sebapersson/petab_sciml.git@main#subdirectory=src/python \ - name: Install petab From 75a36308cd4cca10cac9fd953ddc1b03abc84f2f Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Tue, 2 Sep 2025 11:37:32 +0100 Subject: [PATCH 44/92] updates to petab sciml workflow --- .github/workflows/test_petab_sciml.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test_petab_sciml.yml b/.github/workflows/test_petab_sciml.yml index 25839e6b46..20ac50a235 100644 --- a/.github/workflows/test_petab_sciml.yml +++ b/.github/workflows/test_petab_sciml.yml @@ -34,6 +34,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 20 + submodules: recursive - name: Install apt dependencies uses: ./.github/actions/install-apt-dependencies @@ -67,7 +68,7 @@ jobs: run: | source ./venv/bin/activate \ && python3 -m pip uninstall -y petab \ - && python3 -m pip install git+https://github.com/petab-dev/libpetab-python.git@develop \ + && python3 -m pip install git+https://github.com/petab-dev/libpetab-python.git@sciml \ - name: Run PEtab SciML testsuite run: | From 8a76e4420717e2b7aaa6aa2edb1ee3ad3d04c949 Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Tue, 2 Sep 2025 15:53:08 +0100 Subject: [PATCH 45/92] fix undef local var in jax tests --- python/sdist/amici/jax/petab.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index 86bed469c6..0105d330ba 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -541,7 +541,7 @@ def _get_nominal_parameter_values( ]["array_files"] if "parameters" in h5py.File(file_spec, "r").keys() ] - ) + ) if self._petab_problem.extensions_config else {} nn_input_arrays = dict( [ @@ -551,7 +551,7 @@ def _get_nominal_parameter_values( ]["array_files"] if "inputs" in h5py.File(file_spec, "r").keys() ] - ) + ) if self._petab_problem.extensions_config else {} # extract nominal values from petab problem for pname, row in self._petab_problem.parameter_df.iterrows(): @@ -696,7 +696,9 @@ def _get_hybridization_df(self): ] ] hybridization_df = pd.concat(hybridizations) - return hybridization_df + return hybridization_df + else: + return None @property def parameter_ids(self) -> list[str]: From 374922c0233567407a4abf4bcc51a39a0e364711 Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Mon, 8 Sep 2025 16:19:41 +0100 Subject: [PATCH 46/92] frozen layers for RHS networks generalise frozen layers to networks across system Use stop_grad instead --- python/sdist/amici/jax/petab.py | 96 +++++++++++++++++++++++++++++---- tests/sciml/test_sciml.py | 19 ++++--- 2 files changed, 97 insertions(+), 18 deletions(-) diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index 0105d330ba..36ae8d0c12 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -16,6 +16,7 @@ import jaxtyping as jt import jax.lax import jax.numpy as jnp +import jax.tree_util as jtu import numpy as np import pandas as pd import petab.v1 as petab @@ -1414,6 +1415,7 @@ def run_simulations( ..., diffrax._custom_types.BoolScalarLike ] = diffrax.steady_state_event(), max_steps: int = 2**10, + is_grad_mode: bool = False, ret: ReturnValue | str = ReturnValue.llh, ): """ @@ -1481,16 +1483,39 @@ def run_simulations( for sc in simulation_conditions ] ) - output, results = problem.run_simulations( - dynamic_conditions, - preeq_array, - solver, - controller, - root_finder, - steady_state_event, - max_steps, - ret, - ) + if is_grad_mode: + output, _ = eqx.filter_grad( + grad_filter_run_simulations, has_aux=True + )( + problem, + dynamic_conditions, + preeq_array, + solver, + controller, + root_finder, + steady_state_event, + max_steps, + ret, + ) + results = { + "llh": jnp.array([]), + "stats_dyn": None, + "stats_posteq": None, + "ts": jnp.array([]), + "x": jnp.array([]), + } + + else: + output, results = problem.run_simulations( + dynamic_conditions, + preeq_array, + solver, + controller, + root_finder, + steady_state_event, + max_steps, + ret, + ) else: output = jnp.array(0.0) results = { @@ -1501,7 +1526,7 @@ def run_simulations( "x": jnp.array([]), } - if ret in (ReturnValue.llh, ReturnValue.chi2): + if ret in (ReturnValue.llh, ReturnValue.chi2) and not is_grad_mode: output = jnp.sum(output) return output, results | preresults | conditions @@ -1590,3 +1615,52 @@ def petab_simulate( ) dfs.append(df_sc) return pd.concat(dfs).sort_index() + +def apply_grad_filter(problem: JAXProblem,): + for entity in problem._petab_problem.mapping_df[petab.MODEL_ENTITY_ID]: + if "layer" in entity: + net_id = entity.split(".")[0] + layer_id = re.findall(r"\[(.*?)\]", entity)[0] + array_attr = entity.split(".")[-1] + if array_attr in ("weight", "bias"): + problem = eqx.tree_at( + lambda problem: getattr(problem.model.nns[net_id].layers[layer_id], array_attr), + problem, + replace_fn=lambda array_attr: jax.lax.stop_gradient(array_attr) + ) + else: + problem = eqx.tree_at( + lambda problem: problem.model.nns[net_id].layers[layer_id], + problem, + replace_fn=lambda layer: jax.lax.stop_gradient(layer) + ) + + return problem + +def grad_filter_run_simulations( + problem, + simulation_conditions: list[str], + preeq_array: jt.Float[jt.Array, "ncond *nx"], # noqa: F821, F722 + solver: diffrax.AbstractSolver, + controller: diffrax.AbstractStepSizeController, + root_finder: AbstractRootFinder, + steady_state_event: Callable[ + ..., diffrax._custom_types.BoolScalarLike + ], + max_steps: jnp.int_, + ret: ReturnValue = ReturnValue.llh, + ): + problem_grad_filtered = apply_grad_filter(problem) + output, stats = problem_grad_filtered.run_simulations( + simulation_conditions, + preeq_array, + solver, + controller, + root_finder, + steady_state_event, + max_steps, + ret, + ) + output = jnp.sum(output) + + return output, stats diff --git a/tests/sciml/test_sciml.py b/tests/sciml/test_sciml.py index a39c5b8c9f..91e44977fb 100644 --- a/tests/sciml/test_sciml.py +++ b/tests/sciml/test_sciml.py @@ -234,11 +234,12 @@ def test_ude(test): ) # gradient - sllh, _ = eqx.filter_grad(run_simulations, has_aux=True)( + sllh, _ = run_simulations( jax_problem, solver=diffrax.Kvaerno5(), controller=diffrax.PIDController(atol=1e-14, rtol=1e-14), max_steps=2**16, + is_grad_mode=True, ) for component, file in solutions["grad_files"].items(): actual_dict = {} @@ -246,6 +247,7 @@ def test_ude(test): expected = pd.read_csv(test_dir / file, sep="\t").set_index( petab.PARAMETER_ID ) + for ip in expected.index: if ip in jax_problem.parameter_ids: actual_dict[ip] = sllh.parameters[ @@ -275,9 +277,12 @@ def test_ude(test): actual.swapaxes(0, 1), axis=tuple(range(2, actual.ndim)), ) - np.testing.assert_allclose( - actual, - expected["parameters"][component][layer_name][attribute][:], - atol=solutions["tol_grad_llh"], - rtol=solutions["tol_grad_llh"], - ) + if np.squeeze(expected["parameters"][component][layer_name][attribute][:]).size == 0: + assert np.all(actual == 0.0) + else: + np.testing.assert_allclose( + np.squeeze(actual), + np.squeeze(expected["parameters"][component][layer_name][attribute][:]), + atol=solutions["tol_grad_llh"], + rtol=solutions["tol_grad_llh"], + ) From c2a386bcd662b18f5b98f4103f18059b7109d550 Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Wed, 17 Sep 2025 14:56:07 +0100 Subject: [PATCH 47/92] implement nns in the observable formula --- python/sdist/amici/de_model.py | 41 +++++++++++++++++++++++- python/sdist/amici/petab/petab_import.py | 21 +++++++++++- tests/sciml/test_sciml.py | 15 --------- 3 files changed, 60 insertions(+), 17 deletions(-) diff --git a/python/sdist/amici/de_model.py b/python/sdist/amici/de_model.py index 1625fed73d..019518cb0f 100644 --- a/python/sdist/amici/de_model.py +++ b/python/sdist/amici/de_model.py @@ -2564,6 +2564,7 @@ def _process_hybridization(self, hybridization: dict) -> None: hybridization information """ added_expressions = False + orig_obs = tuple([s.get_id() for s in self._observables]) for net_id, net in hybridization.items(): if net["static"]: continue # do not integrate into ODEs, handle in amici.jax.petab @@ -2606,6 +2607,7 @@ def _process_hybridization(self, hybridization: dict) -> None: raise ValueError( f"Could not find all output variables for neural network {net_id}" ) + for iout, (out_var, comp) in enumerate(outputs.items()): # remove output from model components if isinstance(comp, Parameter): @@ -2620,8 +2622,10 @@ def _process_hybridization(self, hybridization: dict) -> None: ) # generate dummy Function + # FIXME: not robust to an observable output and a regular output being in the other order + ind = iout + len(net["observable_vars"]) out_val = sp.Function(net_id)( - *[input.get_id() for input in inputs], iout + *[input.get_id() for input in inputs], ind ) # add to the model @@ -2641,6 +2645,41 @@ def _process_hybridization(self, hybridization: dict) -> None: ) ) added_expressions = True + + observables = { + ob_var: comp + for comp in self._components + if (ob_var := str(comp.get_id())) in net["observable_vars"] + # TODO: SYNTAX NEEDS to CHANGE + or (ob_var := str(comp.get_id()) + "_dot") + in net["observable_vars"] + } + if len(observables.keys()) != len(net["observable_vars"]): + raise ValueError( + f"Could not find all observable variables for neural network {net_id}" + ) + + for iout, (ob_var, comp) in enumerate(observables.items()): + if isinstance(comp, Observable): + self._observables.remove(comp) + else: + raise ValueError( + f"{comp.get_name()} ({type(comp)}) is not an observable." + ) + out_val = sp.Function(net_id)( + *[input.get_id() for input in inputs], iout + ) + # add to the model + self.add_component( + Observable( + identifier=comp.get_id(), + name=net_id, + value=out_val, + ) + ) + + new_order = [orig_obs.index(s.get_id()) for s in self._observables] + self._observables = [self._observables[i] for i in new_order] if added_expressions: # toposort expressions diff --git a/python/sdist/amici/petab/petab_import.py b/python/sdist/amici/petab/petab_import.py index 8f996cc60b..940d794f71 100644 --- a/python/sdist/amici/petab/petab_import.py +++ b/python/sdist/amici/petab/petab_import.py @@ -152,6 +152,12 @@ def import_petab_problem( hybridization_table["targetId"], ) ) + observable_mapping = dict( + zip( + petab_problem.observable_df["observableFormula"], + petab_problem.observable_df.index, + ) + ) hybridization = { net_id: { "model": NNModelStandard.load_data( @@ -185,7 +191,20 @@ def import_petab_problem( if model_id.split(".")[1].startswith("output") and petab_id in output_mapping.keys() ], - # ?? static included here ?? and handled later ?? + "observable_vars": [ + observable_mapping[petab_id] + for petab_id, model_id in petab_problem.mapping_df.loc[ + petab_problem.mapping_df[petab.MODEL_ENTITY_ID] + .str.split(".") + .str[0] + == net_id, + petab.MODEL_ENTITY_ID, + ] + .to_dict() + .items() + if model_id.split(".")[1].startswith("output") + and petab_id in observable_mapping.keys() + ], **net_config, } for net_id, net_config in config["neural_nets"].items() diff --git a/tests/sciml/test_sciml.py b/tests/sciml/test_sciml.py index 91e44977fb..3faf06b3f4 100644 --- a/tests/sciml/test_sciml.py +++ b/tests/sciml/test_sciml.py @@ -193,21 +193,6 @@ def test_ude(test): jax_problem = JAXProblem(jax_model, petab_problem) # llh - if test in ( - # ?? cases where nn part of observable formula ?? - "004", - "009", - "012", - "013", - "018", - "020", - "022", - "025", - "028", - ): - with pytest.raises(NotImplementedError): - run_simulations(jax_problem) - return llh, r = run_simulations(jax_problem) np.testing.assert_allclose( llh, From 8bce41615e1dedb08367b588ed0e122f40b54f8d Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Fri, 19 Sep 2025 16:13:49 +0100 Subject: [PATCH 48/92] tidy, refactor, generalise sciml test case implementations --- python/sdist/amici/de_model.py | 28 +-- python/sdist/amici/jax/petab.py | 179 ++++++++++-------- python/sdist/amici/petab/parameter_mapping.py | 13 +- python/sdist/amici/petab/petab_import.py | 78 +++++--- tests/sciml/test_sciml.py | 17 +- 5 files changed, 190 insertions(+), 125 deletions(-) diff --git a/python/sdist/amici/de_model.py b/python/sdist/amici/de_model.py index 019518cb0f..db343019a4 100644 --- a/python/sdist/amici/de_model.py +++ b/python/sdist/amici/de_model.py @@ -2596,7 +2596,7 @@ def _process_hybridization(self, hybridization: dict) -> None: ) outputs = { - out_var: comp + out_var: {"comp": comp, "ind": net["output_vars"][out_var]} for comp in self._components if (out_var := str(comp.get_id())) in net["output_vars"] # TODO: SYNTAX NEEDS to CHANGE @@ -2607,8 +2607,9 @@ def _process_hybridization(self, hybridization: dict) -> None: raise ValueError( f"Could not find all output variables for neural network {net_id}" ) - - for iout, (out_var, comp) in enumerate(outputs.items()): + + for out_var, parts in outputs.items(): + comp = parts["comp"] # remove output from model components if isinstance(comp, Parameter): self._parameters.remove(comp) @@ -2622,10 +2623,8 @@ def _process_hybridization(self, hybridization: dict) -> None: ) # generate dummy Function - # FIXME: not robust to an observable output and a regular output being in the other order - ind = iout + len(net["observable_vars"]) out_val = sp.Function(net_id)( - *[input.get_id() for input in inputs], ind + *[input.get_id() for input in inputs], parts["ind"] ) # add to the model @@ -2645,21 +2644,22 @@ def _process_hybridization(self, hybridization: dict) -> None: ) ) added_expressions = True - + observables = { - ob_var: comp + ob_var: {"comp": comp, "ind": net["observable_vars"][ob_var]} for comp in self._components if (ob_var := str(comp.get_id())) in net["observable_vars"] - # TODO: SYNTAX NEEDS to CHANGE - or (ob_var := str(comp.get_id()) + "_dot") - in net["observable_vars"] + # # TODO: SYNTAX NEEDS to CHANGE + # or (ob_var := str(comp.get_id()) + "_dot") + # in net["observable_vars"] } if len(observables.keys()) != len(net["observable_vars"]): raise ValueError( f"Could not find all observable variables for neural network {net_id}" ) - - for iout, (ob_var, comp) in enumerate(observables.items()): + + for ob_var, parts in observables.items(): + comp = parts["comp"] if isinstance(comp, Observable): self._observables.remove(comp) else: @@ -2667,7 +2667,7 @@ def _process_hybridization(self, hybridization: dict) -> None: f"{comp.get_name()} ({type(comp)}) is not an observable." ) out_val = sp.Function(net_id)( - *[input.get_id() for input in inputs], iout + *[input.get_id() for input in inputs], parts["ind"] ) # add to the model self.add_component( diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index 36ae8d0c12..79650364fb 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -528,31 +528,41 @@ def _get_nominal_parameter_values( for net_id, nn in model.nns.items() } # load nn parameters from file - # ?? dict similar to the one above ?? {nn_id: {layer_id: {weight, bias}}} ?? - par_arrays = dict( - [ - ( - file_spec.split("_")[0], - h5py.File(file_spec, "r")["parameters"][ - file_spec.split("_")[0] - ], - ) - for file_spec in self._petab_problem.extensions_config[ - "sciml" - ]["array_files"] - if "parameters" in h5py.File(file_spec, "r").keys() - ] - ) if self._petab_problem.extensions_config else {} + par_arrays = ( + dict( + [ + ( + file_spec.split("_")[0], + h5py.File(file_spec, "r")["parameters"][ + file_spec.split("_")[0] + ], + ) + for file_spec in self._petab_problem.extensions_config[ + "sciml" + ]["array_files"] + if "parameters" in h5py.File(file_spec, "r").keys() + ] + ) + if self._petab_problem.extensions_config + else {} + ) - nn_input_arrays = dict( - [ - (file_spec.split("_")[0], h5py.File(file_spec, "r")["inputs"]) - for file_spec in self._petab_problem.extensions_config[ - "sciml" - ]["array_files"] - if "inputs" in h5py.File(file_spec, "r").keys() - ] - ) if self._petab_problem.extensions_config else {} + nn_input_arrays = ( + dict( + [ + ( + file_spec.split("_")[0], + h5py.File(file_spec, "r")["inputs"], + ) + for file_spec in self._petab_problem.extensions_config[ + "sciml" + ]["array_files"] + if "inputs" in h5py.File(file_spec, "r").keys() + ] + ) + if self._petab_problem.extensions_config + else {} + ) # extract nominal values from petab problem for pname, row in self._petab_problem.parameter_df.iterrows(): @@ -561,7 +571,6 @@ def _get_nominal_parameter_values( nn = model_pars[net] scalar = True - # ?? When value is NaN do we want to go to par_arrays ?? if np.isnan(row[petab.NOMINAL_VALUE]): value = par_arrays[net] scalar = False @@ -572,7 +581,6 @@ def _get_nominal_parameter_values( layer_name = pname.split(".")[1] layer = nn[layer_name] if len(pname.split(".")) > 2: - # ?? Recursion needed ?? attribute_name = pname.split(".")[2] to_set.append((layer_name, attribute_name)) else: @@ -632,9 +640,11 @@ def _get_nominal_parameter_values( nn_input_arrays[net_id][input][ k ][:], - dtype=jnp.float64, + dtype=jnp.float64 + if jax.config.jax_enable_x64 + else jnp.float32, ), - ) # ?? hardcoded dtype not ideal ?? could infer from env somehow ?? + ) for k in nn_input_arrays[net_id][ input ].keys() @@ -769,19 +779,22 @@ def _eval_nn(self, output_par: str, condition_id: str): ].split(".")[0] nn = self.model.nns[net_id] + def _is_net_input(model_id): + comps = model_id.split(".") + return comps[0] == net_id and comps[1].startswith("inputs") + model_id_map = ( self._petab_problem.mapping_df[ - self._petab_problem.mapping_df[petab.MODEL_ENTITY_ID] - .str.split(".") - .str[0] - == net_id + self._petab_problem.mapping_df[petab.MODEL_ENTITY_ID].apply( + _is_net_input + ) ] .reset_index() .set_index(petab.MODEL_ENTITY_ID)[petab.PETAB_ENTITY_ID] .to_dict() ) - condition_parameter_map = ( + condition_input_map = ( dict( [ ( @@ -793,10 +806,10 @@ def _eval_nn(self, output_par: str, condition_id: str): petab.NOMINAL_VALUE, ], ) - if "input" - in self._petab_problem.condition_df.loc[ + if self._petab_problem.condition_df.loc[ condition_id, petab_id ] + in self._petab_problem.parameter_df.index else ( petab_id, np.float64( @@ -805,9 +818,7 @@ def _eval_nn(self, output_par: str, condition_id: str): ] ), ) - for petab_id in [ - s for s in model_id_map.values() if "input" in s - ] + for petab_id in model_id_map.values() ] ) if not self._petab_problem.condition_df.empty @@ -822,7 +833,28 @@ def _eval_nn(self, output_par: str, condition_id: str): ] ) - # ?? conditional nightmare ?? refactor ?? + # handle conditions + if len(condition_input_map) > 0: + net_input = jnp.array( + [ + condition_input_map[petab_id] + for _, petab_id in model_id_map.items() + ] + ) + return nn.forward(net_input).squeeze() + + # handle array inputs + if isinstance(self.model.nns[net_id].inputs, dict): + net_input = jnp.array( + [ + self.model.nns[net_id].inputs[petab_id][condition_id] + if condition_id in self.model.nns[net_id].inputs[petab_id] + else self.model.nns[net_id].inputs[petab_id]["0"] + for _, petab_id in model_id_map.items() + ] + ) + return nn.forward(net_input).squeeze() + net_input = jnp.array( [ jax.lax.stop_gradient(self.model.nns[net_id][model_id]) @@ -833,20 +865,10 @@ def _eval_nn(self, output_par: str, condition_id: str): petab_id, petab.NOMINAL_VALUE ] if petab_id in set(self._petab_problem.parameter_df.index) - else self.model.nns[net_id].inputs[petab_id][condition_id] - if isinstance(self.model.nns[net_id].inputs, dict) - and condition_id in self.model.nns[net_id].inputs[petab_id] - else self.model.nns[net_id].inputs[petab_id][ - "0" - ] # ?? "0" always the key if inputs for all conditions ?? - if petab_id in self.model.nns[net_id].inputs else self._petab_problem.parameter_df.loc[ hybridization_parameter_map[petab_id], petab.NOMINAL_VALUE ] - if self._petab_problem.condition_df.empty - else condition_parameter_map[petab_id] for model_id, petab_id in model_id_map.items() - if model_id.split(".")[1].startswith("input") ] ) return nn.forward(net_input).squeeze() @@ -861,7 +883,6 @@ def _map_model_parameter_value( if pval in self.nn_output_ids: nn_output = self._eval_nn(pval, condition_id) if nn_output.size > 1: - # ?? can this approach work for single dimension return ?? maybe remove the squeeze from _eval_nn ?? entityId = self._petab_problem.mapping_df.loc[ pval, petab.MODEL_ENTITY_ID ] @@ -1119,8 +1140,6 @@ def _prepare_conditions( in_axes={ "max_steps": None, "self": None, - "init_override": None, # ?? performance hit ?? flip arrays to avoid ?? - "init_override_mask": None, }, # only list arguments here where eqx.is_array(0) is not the right thing ) def run_simulation( @@ -1135,8 +1154,8 @@ def run_simulation( nps: jt.Float[jt.Array, "nt *nnp"], # noqa: F821, F722 mask_reinit: jt.Bool[jt.Array, "nx"], # noqa: F821, F722 x_reinit: jt.Float[jt.Array, "nx"], # noqa: F821, F722 - init_override: jt.Float[jt.Array, "np"], # ?? what do these annotations mean ?? - init_override_mask: jt.Bool[jt.Array, "np"], + init_override: jt.Float[jt.Array, "nx"], # noqa: F821, F722 + init_override_mask: jt.Bool[jt.Array, "nx"], # noqa: F821, F722 solver: diffrax.AbstractSolver, controller: diffrax.AbstractStepSizeController, root_finder: AbstractRootFinder, @@ -1203,7 +1222,7 @@ def run_simulation( mask_reinit=jax.lax.stop_gradient(mask_reinit), x_reinit=x_reinit, init_override=init_override, - init_override_mask=init_override_mask, + init_override_mask=jax.lax.stop_gradient(init_override_mask), ts_mask=jax.lax.stop_gradient(jnp.array(ts_mask)), solver=solver, controller=controller, @@ -1263,7 +1282,7 @@ def run_simulations( self._np_indices, ) ) - # state values override? + init_override_mask = jnp.stack( [ jnp.array( @@ -1279,8 +1298,12 @@ def run_simulations( ] ) if not init_override_mask.any(): - init_override_mask = jnp.array([]) - init_override = jnp.array([]) + init_override_mask = jnp.stack( + [jnp.array([]) for _ in simulation_conditions] + ) + init_override = jnp.stack( + [jnp.array([]) for _ in simulation_conditions] + ) else: init_override = jnp.stack( [ @@ -1293,7 +1316,7 @@ def run_simulations( in set( self._parameter_mappings[sc].map_sim_var.keys() ) - else 1.0 # ?? dummy value - shouldn't matter ?? + else 1.0 for p in self.model.state_ids ] ) @@ -1616,7 +1639,10 @@ def petab_simulate( dfs.append(df_sc) return pd.concat(dfs).sort_index() -def apply_grad_filter(problem: JAXProblem,): + +def apply_grad_filter( + problem: JAXProblem, +): for entity in problem._petab_problem.mapping_df[petab.MODEL_ENTITY_ID]: if "layer" in entity: net_id = entity.split(".")[0] @@ -1624,32 +1650,35 @@ def apply_grad_filter(problem: JAXProblem,): array_attr = entity.split(".")[-1] if array_attr in ("weight", "bias"): problem = eqx.tree_at( - lambda problem: getattr(problem.model.nns[net_id].layers[layer_id], array_attr), + lambda problem: getattr( + problem.model.nns[net_id].layers[layer_id], array_attr + ), problem, - replace_fn=lambda array_attr: jax.lax.stop_gradient(array_attr) + replace_fn=lambda array_attr: jax.lax.stop_gradient( + array_attr + ), ) else: problem = eqx.tree_at( lambda problem: problem.model.nns[net_id].layers[layer_id], problem, - replace_fn=lambda layer: jax.lax.stop_gradient(layer) + replace_fn=lambda layer: jax.lax.stop_gradient(layer), ) return problem + def grad_filter_run_simulations( - problem, - simulation_conditions: list[str], - preeq_array: jt.Float[jt.Array, "ncond *nx"], # noqa: F821, F722 - solver: diffrax.AbstractSolver, - controller: diffrax.AbstractStepSizeController, - root_finder: AbstractRootFinder, - steady_state_event: Callable[ - ..., diffrax._custom_types.BoolScalarLike - ], - max_steps: jnp.int_, - ret: ReturnValue = ReturnValue.llh, - ): + problem, + simulation_conditions: list[str], + preeq_array: jt.Float[jt.Array, "ncond *nx"], # noqa: F821, F722 + solver: diffrax.AbstractSolver, + controller: diffrax.AbstractStepSizeController, + root_finder: AbstractRootFinder, + steady_state_event: Callable[..., diffrax._custom_types.BoolScalarLike], + max_steps: jnp.int_, + ret: ReturnValue = ReturnValue.llh, +): problem_grad_filtered = apply_grad_filter(problem) output, stats = problem_grad_filtered.run_simulations( simulation_conditions, diff --git a/python/sdist/amici/petab/parameter_mapping.py b/python/sdist/amici/petab/parameter_mapping.py index 7264ea3a14..0f70ce0d71 100644 --- a/python/sdist/amici/petab/parameter_mapping.py +++ b/python/sdist/amici/petab/parameter_mapping.py @@ -378,13 +378,20 @@ def create_parameter_mapping( if parameter_mapping_kwargs is None: parameter_mapping_kwargs = {} + # TODO: Add support for conditions with sciml mappings in petab library + mapping = ( + None + if "sciml" in petab_problem.extensions_config + else petab_problem.mapping_df + ) + prelim_parameter_mapping = ( petab.get_optimization_to_simulation_parameter_mapping( condition_df=petab_problem.condition_df, measurement_df=petab_problem.measurement_df, parameter_df=petab_problem.parameter_df, observable_df=petab_problem.observable_df, - # mapping_df=petab_problem.mapping_df, + mapping_df=mapping, model=petab_problem.model, simulation_conditions=simulation_conditions, fill_fixed_parameters=fill_fixed_parameters, @@ -394,8 +401,6 @@ def create_parameter_mapping( ) ) - # ?? put mappings in later ?? after mapping for condition ?? will there be a performance regression as a result ?? - parameter_mapping = ParameterMapping() for (_, condition), prelim_mapping_for_condition in zip( simulation_conditions.iterrows(), prelim_parameter_mapping, strict=True @@ -587,8 +592,6 @@ def create_parameter_mapping_for_condition( ) logger.debug(f"Merged: {condition_map_sim_var}") - # ?? right place for static hybridization here ?? - if "sciml" in petab_problem.extensions_config: hybridizations = [ pd.read_csv(hf, sep="\t") diff --git a/python/sdist/amici/petab/petab_import.py b/python/sdist/amici/petab/petab_import.py index 940d794f71..5ae20cd4c3 100644 --- a/python/sdist/amici/petab/petab_import.py +++ b/python/sdist/amici/petab/petab_import.py @@ -9,6 +9,11 @@ import os import shutil from pathlib import Path +<<<<<<< HEAD +======= +from warnings import warn +import re +>>>>>>> 05f22cc9 (tidy, refactor, generalise sciml test case implementations) import amici import pandas as pd @@ -177,34 +182,44 @@ def import_petab_problem( if model_id.split(".")[1].startswith("input") and petab_id in input_mapping.keys() ], - "output_vars": [ - output_mapping[petab_id] - for petab_id, model_id in petab_problem.mapping_df.loc[ - petab_problem.mapping_df[petab.MODEL_ENTITY_ID] - .str.split(".") - .str[0] - == net_id, - petab.MODEL_ENTITY_ID, + "output_vars": dict( + [ + ( + output_mapping[petab_id], + _get_net_index(model_id), + ) + for petab_id, model_id in petab_problem.mapping_df.loc[ + petab_problem.mapping_df[petab.MODEL_ENTITY_ID] + .str.split(".") + .str[0] + == net_id, + petab.MODEL_ENTITY_ID, + ] + .to_dict() + .items() + if model_id.split(".")[1].startswith("output") + and petab_id in output_mapping.keys() ] - .to_dict() - .items() - if model_id.split(".")[1].startswith("output") - and petab_id in output_mapping.keys() - ], - "observable_vars": [ - observable_mapping[petab_id] - for petab_id, model_id in petab_problem.mapping_df.loc[ - petab_problem.mapping_df[petab.MODEL_ENTITY_ID] - .str.split(".") - .str[0] - == net_id, - petab.MODEL_ENTITY_ID, + ), + "observable_vars": dict( + [ + ( + observable_mapping[petab_id], + _get_net_index(model_id), + ) + for petab_id, model_id in petab_problem.mapping_df.loc[ + petab_problem.mapping_df[petab.MODEL_ENTITY_ID] + .str.split(".") + .str[0] + == net_id, + petab.MODEL_ENTITY_ID, + ] + .to_dict() + .items() + if model_id.split(".")[1].startswith("output") + and petab_id in observable_mapping.keys() ] - .to_dict() - .items() - if model_id.split(".")[1].startswith("output") - and petab_id in observable_mapping.keys() - ], + ), **net_config, } for net_id, net_config in config["neural_nets"].items() @@ -258,3 +273,14 @@ def import_petab_problem( ) return model + + +def _get_net_index(model_id: str): + matches = re.findall(r"\[(\d+)\]", model_id) + if matches: + return int(matches[-1]) + return None + + +# for backwards compatibility +import_model = import_model_sbml diff --git a/tests/sciml/test_sciml.py b/tests/sciml/test_sciml.py index 3faf06b3f4..80f0881e55 100644 --- a/tests/sciml/test_sciml.py +++ b/tests/sciml/test_sciml.py @@ -120,7 +120,6 @@ def test_net(test): and hasattr(net.layers[layer], "weight") and net.layers[layer].weight is not None ): - # ?? grabbing weights from the parameters file ?? need to check if they are present in above if condition ?? w = par["parameters"][ml_model.nn_model_id][layer]["weight"][:] if isinstance(net.layers[layer], eqx.nn.ConvTranspose): # see FAQ in https://docs.kidger.site/equinox/api/nn/conv/#equinox.nn.ConvTranspose @@ -136,7 +135,6 @@ def test_net(test): and hasattr(net.layers[layer], "bias") and net.layers[layer].bias is not None ): - # ?? grabbing biases from the parameters file ?? need to check if they are present in above if condition ?? b = par["parameters"][ml_model.nn_model_id][layer]["bias"][:] if isinstance( net.layers[layer], @@ -232,7 +230,7 @@ def test_ude(test): expected = pd.read_csv(test_dir / file, sep="\t").set_index( petab.PARAMETER_ID ) - + for ip in expected.index: if ip in jax_problem.parameter_ids: actual_dict[ip] = sllh.parameters[ @@ -262,12 +260,21 @@ def test_ude(test): actual.swapaxes(0, 1), axis=tuple(range(2, actual.ndim)), ) - if np.squeeze(expected["parameters"][component][layer_name][attribute][:]).size == 0: + if ( + np.squeeze( + expected["parameters"][component][layer_name][attribute][:] + ).size + == 0 + ): assert np.all(actual == 0.0) else: np.testing.assert_allclose( np.squeeze(actual), - np.squeeze(expected["parameters"][component][layer_name][attribute][:]), + np.squeeze( + expected["parameters"][component][layer_name][ + attribute + ][:] + ), atol=solutions["tol_grad_llh"], rtol=solutions["tol_grad_llh"], ) From b3225552b2dd88bcb3d16eb7424146767d3df0df Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Tue, 30 Sep 2025 12:03:35 +0100 Subject: [PATCH 49/92] hybridization df in _petab_problem - makes JAXProblem jit-able --- python/sdist/amici/jax/petab.py | 100 ++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 45 deletions(-) diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index 79650364fb..e160b568d7 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -7,6 +7,7 @@ from pathlib import Path from collections.abc import Callable import logging +from typing import Union import diffrax @@ -76,6 +77,30 @@ def jax_unscale( return jnp.power(10, parameter) raise ValueError(f"Invalid parameter scaling: {scale_str}") +# IDEA: Implement hybridization_df in petab.v2.Problem instead? Then class here could be removed +class HybridProblem(petab.Problem): + hybridization_df: pd.DataFrame + + def __init__(self, petab_problem: petab.Problem): + self.__dict__.update(petab_problem.__dict__) + self.hybridization_df = _get_hybridization_df(petab_problem) + + +def _get_hybridization_df(petab_problem): + if "sciml" in petab_problem.extensions_config: + hybridizations = [ + pd.read_csv(hf, sep="\t", index_col=0) + for hf in petab_problem.extensions_config["sciml"][ + "hybridization_files" + ] + ] + hybridization_df = pd.concat(hybridizations) + return hybridization_df + else: + return None + +def _get_hybrid_petab_problem(petab_problem: petab.Problem): + return HybridProblem(petab_problem) class JAXProblem(eqx.Module): """ @@ -97,7 +122,6 @@ class JAXProblem(eqx.Module): model: JAXModel simulation_conditions: tuple[tuple[str, ...], ...] _parameter_mappings: dict[str, ParameterMappingForCondition] - _hybridization_df: pd.DataFrame _ts_dyn: np.ndarray _ts_posteq: np.ndarray _my: np.ndarray @@ -111,7 +135,7 @@ class JAXProblem(eqx.Module): _np_mask: np.ndarray _np_indices: np.ndarray _petab_measurement_indices: np.ndarray - _petab_problem: petab.Problem + _petab_problem: petab.Problem | HybridProblem def __init__(self, model: JAXModel, petab_problem: petab.Problem): """ @@ -124,8 +148,7 @@ def __init__(self, model: JAXModel, petab_problem: petab.Problem): """ scs = petab_problem.get_simulation_conditions_from_measurement_df() self.simulation_conditions = tuple(tuple(sc) for sc in scs.values) - self._petab_problem = petab_problem - self._hybridization_df = self._get_hybridization_df() + self._petab_problem = _get_hybrid_petab_problem(petab_problem) self.parameters, self.model = self._get_nominal_parameter_values(model) self._parameter_mappings = self._get_parameter_mappings(scs) ( @@ -698,19 +721,6 @@ def _get_inputs(self): ].values.reshape(shape) return inputs - def _get_hybridization_df(self): - if "sciml" in self._petab_problem.extensions_config: - hybridizations = [ - pd.read_csv(hf, sep="\t", index_col=0) - for hf in self._petab_problem.extensions_config["sciml"][ - "hybridization_files" - ] - ] - hybridization_df = pd.concat(hybridizations) - return hybridization_df - else: - return None - @property def parameter_ids(self) -> list[str]: """ @@ -827,9 +837,9 @@ def _is_net_input(model_id): hybridization_parameter_map = dict( [ - (petab_id, self._hybridization_df.loc[petab_id, "targetValue"]) + (petab_id, self._petab_problem.hybridization_df.loc[petab_id, "targetValue"]) for petab_id in model_id_map.values() - if petab_id in set(self._hybridization_df.index) + if petab_id in set(self._petab_problem.hybridization_df.index) ] ) @@ -1297,32 +1307,32 @@ def run_simulations( for sc in simulation_conditions ] ) - if not init_override_mask.any(): - init_override_mask = jnp.stack( - [jnp.array([]) for _ in simulation_conditions] - ) - init_override = jnp.stack( - [jnp.array([]) for _ in simulation_conditions] - ) - else: - init_override = jnp.stack( - [ - jnp.array( - [ - self._eval_nn( - self._parameter_mappings[sc].map_sim_var[p], sc - ) - if p - in set( - self._parameter_mappings[sc].map_sim_var.keys() - ) - else 1.0 - for p in self.model.state_ids - ] - ) - for sc in simulation_conditions - ] - ) + # if init_override_mask.sum() == 0.0: + # init_override_mask = jnp.stack( + # [jnp.array([]) for _ in simulation_conditions] + # ) + # init_override = jnp.stack( + # [jnp.array([]) for _ in simulation_conditions] + # ) + # else: + init_override = jnp.stack( + [ + jnp.array( + [ + self._eval_nn( + self._parameter_mappings[sc].map_sim_var[p], sc + ) + if p + in set( + self._parameter_mappings[sc].map_sim_var.keys() + ) + else 1.0 + for p in self.model.state_ids + ] + ) + for sc in simulation_conditions + ] + ) return self.run_simulation( p_array, From dddc4c2285454d0c858af50baf800e51daa5647e Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Fri, 3 Oct 2025 16:57:47 +0100 Subject: [PATCH 50/92] update frozen layer/arrays implementation --- python/sdist/amici/jax/nn.py | 22 +++- python/sdist/amici/jax/ode_export.py | 3 +- python/sdist/amici/jax/petab.py | 127 ++++------------------- python/sdist/amici/petab/petab_import.py | 27 +++++ tests/sciml/test_sciml.py | 3 +- 5 files changed, 72 insertions(+), 110 deletions(-) diff --git a/python/sdist/amici/jax/nn.py b/python/sdist/amici/jax/nn.py index d409063c20..f94a0dcca4 100644 --- a/python/sdist/amici/jax/nn.py +++ b/python/sdist/amici/jax/nn.py @@ -30,7 +30,7 @@ def tanhshrink(x: jnp.ndarray) -> jnp.ndarray: return x - jnp.tanh(x) -def generate_equinox(nn_model: "NNModel", filename: Path | str): # noqa: F821 +def generate_equinox(nn_model: "NNModel", filename: Path | str, frozen_layers: dict = {}): # noqa: F821 # TODO: move to top level import and replace forward type definitions from petab_sciml import Layer @@ -53,6 +53,7 @@ def generate_equinox(nn_model: "NNModel", filename: Path | str): # noqa: F821 _generate_forward( node, node_indent, + frozen_layers, layers.get( node.target, Layer(layer_id="dummy", layer_type="Linear"), @@ -149,13 +150,25 @@ def _generate_layer(layer: "Layer", indent: int, ilayer: int) -> str: # noqa: F return f"{' ' * indent}'{layer.layer_id}': {layer_str}" -def _generate_forward(node: "Node", indent, layer_type=str) -> str: # noqa: F821 +def _generate_forward(node: "Node", indent, frozen_layers: dict = {}, layer_type=str) -> str: # noqa: F821 if node.op == "placeholder": # TODO: inconsistent target vs name return f"{' ' * indent}{node.name} = input" if node.op == "call_module": fun_str = f"self.layers['{node.target}']" + if node.name in frozen_layers: + if frozen_layers[node.name]: + arr_attr = frozen_layers[node.name] + get_lambda = f"lambda layer: getattr(layer, '{arr_attr}')" + replacer = ( + "replace_fn = lambda arr: jax.lax.stop_gradient(arr)" + ) + tree_string = f"tree_{node.name} = eqx.tree_at({get_lambda}, {fun_str}, {replacer})" + fun_str = f"tree_{node.name}" + else: + fun_str = f"jax.lax.stop_gradient({fun_str})" + tree_string = "" if layer_type.startswith(("Conv", "Linear", "LayerNorm")): if layer_type in ("LayerNorm",): dims = f"len({fun_str}.shape)+1" @@ -198,6 +211,9 @@ def _generate_forward(node: "Node", indent, layer_type=str) -> str: # noqa: F82 kwargs += ["key=key"] kwargs_str = ", ".join(kwargs) if node.op in ("call_module", "call_function", "call_method"): - return f"{' ' * indent}{node.name} = {fun_str}({args + ', ' + kwargs_str})" + if node.name in frozen_layers: + return f"{' ' * indent}{tree_string}\n{' ' * indent}{node.name} = {fun_str}({args + ', ' + kwargs_str})" + else: + return f"{' ' * indent}{node.name} = {fun_str}({args + ', ' + kwargs_str})" if node.op == "output": return f"{' ' * indent}{node.target} = {args}" diff --git a/python/sdist/amici/jax/ode_export.py b/python/sdist/amici/jax/ode_export.py index 53b8479155..69cc413c73 100644 --- a/python/sdist/amici/jax/ode_export.py +++ b/python/sdist/amici/jax/ode_export.py @@ -6,7 +6,7 @@ The user generally won't have to directly call any function from this module as this will be done by :py:func:`amici.pysb_import.pysb2jax`, -:py:func:`amici.sbml_import.SbmlImporter.sbml2jax` and +:py:func:`amici.sbml_import.SbmlImporter.` and :py:func:`amici.petab_import.import_model`. """ @@ -288,6 +288,7 @@ def _generate_nn_code(self) -> None: generate_equinox( net["model"], self.model_path / f"{net_name}.py", + net["frozen_layers"], ) def _implicit_roots(self) -> list[sp.Expr]: diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index e160b568d7..b649071f80 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -77,7 +77,8 @@ def jax_unscale( return jnp.power(10, parameter) raise ValueError(f"Invalid parameter scaling: {scale_str}") -# IDEA: Implement hybridization_df in petab.v2.Problem instead? Then class here could be removed + +# IDEA: Implement this class in petab-sciml instead? class HybridProblem(petab.Problem): hybridization_df: pd.DataFrame @@ -98,10 +99,12 @@ def _get_hybridization_df(petab_problem): return hybridization_df else: return None - + + def _get_hybrid_petab_problem(petab_problem: petab.Problem): return HybridProblem(petab_problem) + class JAXProblem(eqx.Module): """ PEtab problem wrapper for JAX models. @@ -837,7 +840,12 @@ def _is_net_input(model_id): hybridization_parameter_map = dict( [ - (petab_id, self._petab_problem.hybridization_df.loc[petab_id, "targetValue"]) + ( + petab_id, + self._petab_problem.hybridization_df.loc[ + petab_id, "targetValue" + ], + ) for petab_id in model_id_map.values() if petab_id in set(self._petab_problem.hybridization_df.index) ] @@ -1307,14 +1315,6 @@ def run_simulations( for sc in simulation_conditions ] ) - # if init_override_mask.sum() == 0.0: - # init_override_mask = jnp.stack( - # [jnp.array([]) for _ in simulation_conditions] - # ) - # init_override = jnp.stack( - # [jnp.array([]) for _ in simulation_conditions] - # ) - # else: init_override = jnp.stack( [ jnp.array( @@ -1323,9 +1323,7 @@ def run_simulations( self._parameter_mappings[sc].map_sim_var[p], sc ) if p - in set( - self._parameter_mappings[sc].map_sim_var.keys() - ) + in set(self._parameter_mappings[sc].map_sim_var.keys()) else 1.0 for p in self.model.state_ids ] @@ -1448,7 +1446,6 @@ def run_simulations( ..., diffrax._custom_types.BoolScalarLike ] = diffrax.steady_state_event(), max_steps: int = 2**10, - is_grad_mode: bool = False, ret: ReturnValue | str = ReturnValue.llh, ): """ @@ -1516,39 +1513,16 @@ def run_simulations( for sc in simulation_conditions ] ) - if is_grad_mode: - output, _ = eqx.filter_grad( - grad_filter_run_simulations, has_aux=True - )( - problem, - dynamic_conditions, - preeq_array, - solver, - controller, - root_finder, - steady_state_event, - max_steps, - ret, - ) - results = { - "llh": jnp.array([]), - "stats_dyn": None, - "stats_posteq": None, - "ts": jnp.array([]), - "x": jnp.array([]), - } - - else: - output, results = problem.run_simulations( - dynamic_conditions, - preeq_array, - solver, - controller, - root_finder, - steady_state_event, - max_steps, - ret, - ) + output, results = problem.run_simulations( + dynamic_conditions, + preeq_array, + solver, + controller, + root_finder, + steady_state_event, + max_steps, + ret, + ) else: output = jnp.array(0.0) results = { @@ -1559,7 +1533,7 @@ def run_simulations( "x": jnp.array([]), } - if ret in (ReturnValue.llh, ReturnValue.chi2) and not is_grad_mode: + if ret in (ReturnValue.llh, ReturnValue.chi2): output = jnp.sum(output) return output, results | preresults | conditions @@ -1648,58 +1622,3 @@ def petab_simulate( ) dfs.append(df_sc) return pd.concat(dfs).sort_index() - - -def apply_grad_filter( - problem: JAXProblem, -): - for entity in problem._petab_problem.mapping_df[petab.MODEL_ENTITY_ID]: - if "layer" in entity: - net_id = entity.split(".")[0] - layer_id = re.findall(r"\[(.*?)\]", entity)[0] - array_attr = entity.split(".")[-1] - if array_attr in ("weight", "bias"): - problem = eqx.tree_at( - lambda problem: getattr( - problem.model.nns[net_id].layers[layer_id], array_attr - ), - problem, - replace_fn=lambda array_attr: jax.lax.stop_gradient( - array_attr - ), - ) - else: - problem = eqx.tree_at( - lambda problem: problem.model.nns[net_id].layers[layer_id], - problem, - replace_fn=lambda layer: jax.lax.stop_gradient(layer), - ) - - return problem - - -def grad_filter_run_simulations( - problem, - simulation_conditions: list[str], - preeq_array: jt.Float[jt.Array, "ncond *nx"], # noqa: F821, F722 - solver: diffrax.AbstractSolver, - controller: diffrax.AbstractStepSizeController, - root_finder: AbstractRootFinder, - steady_state_event: Callable[..., diffrax._custom_types.BoolScalarLike], - max_steps: jnp.int_, - ret: ReturnValue = ReturnValue.llh, -): - problem_grad_filtered = apply_grad_filter(problem) - output, stats = problem_grad_filtered.run_simulations( - simulation_conditions, - preeq_array, - solver, - controller, - root_finder, - steady_state_event, - max_steps, - ret, - ) - output = jnp.sum(output) - - return output, stats diff --git a/python/sdist/amici/petab/petab_import.py b/python/sdist/amici/petab/petab_import.py index 5ae20cd4c3..438256fee2 100644 --- a/python/sdist/amici/petab/petab_import.py +++ b/python/sdist/amici/petab/petab_import.py @@ -220,6 +220,25 @@ def import_petab_problem( and petab_id in observable_mapping.keys() ] ), + "frozen_layers": dict( + [ + _get_frozen_layers(model_id) + for petab_id, model_id in petab_problem.mapping_df.loc[ + petab_problem.mapping_df[petab.MODEL_ENTITY_ID] + .str.split(".") + .str[0] + == net_id, + petab.MODEL_ENTITY_ID, + ] + .to_dict() + .items() + if petab_id in petab_problem.parameter_df.index + and petab_problem.parameter_df.loc[ + petab_id, petab.ESTIMATE + ] + == 0 + ] + ), **net_config, } for net_id, net_config in config["neural_nets"].items() @@ -282,5 +301,13 @@ def _get_net_index(model_id: str): return None +def _get_frozen_layers(model_id): + layers = re.findall(r"\[(.*?)\]", model_id) + array_attr = model_id.split(".")[-1] + layer_id = layers[0] if len(layers) else None + array_attr = array_attr if array_attr in ("weight", "bias") else None + return layer_id, array_attr + + # for backwards compatibility import_model = import_model_sbml diff --git a/tests/sciml/test_sciml.py b/tests/sciml/test_sciml.py index 80f0881e55..3f00a5747d 100644 --- a/tests/sciml/test_sciml.py +++ b/tests/sciml/test_sciml.py @@ -217,12 +217,11 @@ def test_ude(test): ) # gradient - sllh, _ = run_simulations( + sllh, _ = eqx.filter_grad(run_simulations, has_aux=True)( jax_problem, solver=diffrax.Kvaerno5(), controller=diffrax.PIDController(atol=1e-14, rtol=1e-14), max_steps=2**16, - is_grad_mode=True, ) for component, file in solutions["grad_files"].items(): actual_dict = {} From 5f5fb8c7de497b946c0623129d7e319fd9e1ed35 Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Mon, 6 Oct 2025 11:18:37 +0100 Subject: [PATCH 51/92] update jax petab notebook --- .../example_jax_petab/ExampleJaxPEtab.ipynb | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/doc/examples/example_jax_petab/ExampleJaxPEtab.ipynb b/doc/examples/example_jax_petab/ExampleJaxPEtab.ipynb index 983e139237..2e16bf7d50 100644 --- a/doc/examples/example_jax_petab/ExampleJaxPEtab.ipynb +++ b/doc/examples/example_jax_petab/ExampleJaxPEtab.ipynb @@ -82,7 +82,9 @@ "cell_type": "markdown", "id": "415962751301c64a", "metadata": {}, - "source": "This simulates the model for all conditions using the nominal parameter values. Simple, right? Now, let’s take a look at the simulation results." + "source": [ + "This simulates the model for all conditions using the nominal parameter values. Simple, right? Now, let’s take a look at the simulation results." + ] }, { "cell_type": "code", @@ -94,7 +96,9 @@ "simulation_condition = (\"model1_data1\",)\n", "\n", "# Access the results for the specified condition\n", - "results[simulation_condition]" + "ic = results[\"simulation_conditions\"].index(simulation_condition)\n", + "print(\"llh: \", results[\"llh\"][ic])\n", + "print(\"state variables: \", results[\"x\"][ic, :])" ] }, { @@ -129,7 +133,9 @@ "cell_type": "markdown", "id": "fe4d3b40ee3efdf2", "metadata": {}, - "source": "Success! The simulation completed successfully, and we can now plot the resulting state trajectories." + "source": [ + "Success! The simulation completed successfully, and we can now plot the resulting state trajectories." + ] }, { "cell_type": "code", @@ -180,7 +186,9 @@ "cell_type": "markdown", "id": "4fa97c33719c2277", "metadata": {}, - "source": "`run_simulations` enables users to specify the simulation conditions to be executed. For more complex models, this allows for restricting simulations to a subset of conditions. Since the Böhm model includes only a single condition, we demonstrate this functionality by simulating no condition at all." + "source": [ + "`run_simulations` enables users to specify the simulation conditions to be executed. For more complex models, this allows for restricting simulations to a subset of conditions. Since the Böhm model includes only a single condition, we demonstrate this functionality by simulating no condition at all." + ] }, { "cell_type": "code", @@ -283,7 +291,9 @@ "cell_type": "markdown", "id": "dc9bc07cde00a926", "metadata": {}, - "source": "Fortunately, `equinox` simplifies this process by offering [filter_grad](https://docs.kidger.site/equinox/api/transformations/#equinox.filter_grad), which enables autodiff functionality that is compatible with `JAXProblem` and, in theory, also with `JAXModel`." + "source": [ + "Fortunately, `equinox` simplifies this process by offering [filter_grad](https://docs.kidger.site/equinox/api/transformations/#equinox.filter_grad), which enables autodiff functionality that is compatible with `JAXProblem` and, in theory, also with `JAXModel`." + ] }, { "cell_type": "code", @@ -302,7 +312,9 @@ "cell_type": "markdown", "id": "851c3ec94cb5d086", "metadata": {}, - "source": "Functions transformed by `filter_grad` return gradients that share the same structure as the first argument (unless specified otherwise). This allows us to access the gradient with respect to the parameters attribute directly `via grad.parameters`." + "source": [ + "Functions transformed by `filter_grad` return gradients that share the same structure as the first argument (unless specified otherwise). This allows us to access the gradient with respect to the parameters attribute directly `via grad.parameters`." + ] }, { "cell_type": "code", @@ -318,7 +330,9 @@ "cell_type": "markdown", "id": "375b835fecc5a022", "metadata": {}, - "source": "Attributes for which derivatives cannot be computed (typically anything that is not a [jax.numpy.array](https://jax.readthedocs.io/en/latest/_autosummary/jax.numpy.array.html)) are automatically set to `None`." + "source": [ + "Attributes for which derivatives cannot be computed (typically anything that is not a [jax.numpy.array](https://jax.readthedocs.io/en/latest/_autosummary/jax.numpy.array.html)) are automatically set to `None`." + ] }, { "cell_type": "code", @@ -334,7 +348,9 @@ "cell_type": "markdown", "id": "8eb7cc3db510c826", "metadata": {}, - "source": "Observant readers may notice that the gradient above appears to include numeric values for derivatives with respect to some measurements. However, `simulation_conditions` internally disables gradient computations using `jax.lax.stop_gradient`, resulting in these values being zeroed out." + "source": [ + "Observant readers may notice that the gradient above appears to include numeric values for derivatives with respect to some measurements. However, `simulation_conditions` internally disables gradient computations using `jax.lax.stop_gradient`, resulting in these values being zeroed out." + ] }, { "cell_type": "code", @@ -342,14 +358,16 @@ "metadata": {}, "outputs": [], "source": [ - "grad._measurements[simulation_condition]" + "grad._my[ic, :]" ] }, { "cell_type": "markdown", "id": "58eb04393a1463d", "metadata": {}, - "source": "However, we can compute derivatives with respect to data elements using `JAXModel.simulate_condition`. In the example below, we differentiate the observables `y` (specified by passing `y` to the `ret` argument) with respect to the timepoints at which the model outputs are computed after the solving the differential equation. While this might not be particularly practical, it serves as an nice illustration of the power of automatic differentiation." + "source": [ + "However, we can compute derivatives with respect to data elements using `JAXModel.simulate_condition`. In the example below, we differentiate the observables `y` (specified by passing `y` to the `ret` argument) with respect to the timepoints at which the model outputs are computed after the solving the differential equation. While this might not be particularly practical, it serves as an nice illustration of the power of automatic differentiation." + ] }, { "cell_type": "code", @@ -671,8 +689,7 @@ "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.0" + "pygments_lexer": "ipython3" } }, "nbformat": 4, From ddc68fa785e6dc5f5af532f1e594202ecd53767e Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Mon, 6 Oct 2025 11:27:49 +0100 Subject: [PATCH 52/92] add h5py to docs deps --- doc/rtd_requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/rtd_requirements.txt b/doc/rtd_requirements.txt index cbb21058c2..235b11d00e 100644 --- a/doc/rtd_requirements.txt +++ b/doc/rtd_requirements.txt @@ -16,6 +16,7 @@ sphinx_rtd_theme>=1.2.0 petab[vis]>=0.2.0 sphinx-autodoc-typehints ipython>=8.13.2 +h5py>=3.14.0 breathe>=4.35.0 exhale>=0.3.7 -e git+https://github.com/mithro/sphinx-contrib-mithro#egg=sphinx-contrib-exhale-multiproject&subdirectory=sphinx-contrib-exhale-multiproject From 9e744c7afa30f1032d204685a2cdb01416bbdd67 Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Tue, 7 Oct 2025 10:30:42 +0100 Subject: [PATCH 53/92] fix sbml jax tests --- python/sdist/amici/jax/jaxcodeprinter.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/python/sdist/amici/jax/jaxcodeprinter.py b/python/sdist/amici/jax/jaxcodeprinter.py index f96186ac3b..ed022e9fd6 100644 --- a/python/sdist/amici/jax/jaxcodeprinter.py +++ b/python/sdist/amici/jax/jaxcodeprinter.py @@ -6,6 +6,7 @@ import sympy as sp from sympy.printing.numpy import NumPyPrinter +from sympy.core.function import UndefinedFunction def _jnp_array_str(array) -> str: @@ -42,8 +43,11 @@ def _print_Mul(self, expr: sp.Expr) -> str: return super()._print_Mul(expr) return f"safe_div({self.doprint(numer)}, {self.doprint(denom)})" - def _print_Function(self, expr): - return f"self.nns['{expr.func.__name__}'].forward(jnp.array([{', '.join(self.doprint(a) for a in expr.args[:-1])}]))[{expr.args[-1]}]" + def _print_Function(self, expr: sp.Expr) -> str: + if isinstance(expr.func, UndefinedFunction): + return f"self.nns['{expr.func.__name__}'].forward(jnp.array([{', '.join(self.doprint(a) for a in expr.args[:-1])}]))[{expr.args[-1]}]" + else: + return super()._print_Function(expr) def _print_Max(self, expr: sp.Expr) -> str: """ From 021ea450899b7846752afea99a0eb100908ed3bc Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Tue, 7 Oct 2025 14:58:55 +0100 Subject: [PATCH 54/92] missed rebased imports --- python/sdist/amici/petab/petab_import.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/python/sdist/amici/petab/petab_import.py b/python/sdist/amici/petab/petab_import.py index 438256fee2..486c34c432 100644 --- a/python/sdist/amici/petab/petab_import.py +++ b/python/sdist/amici/petab/petab_import.py @@ -9,11 +9,7 @@ import os import shutil from pathlib import Path -<<<<<<< HEAD -======= -from warnings import warn import re ->>>>>>> 05f22cc9 (tidy, refactor, generalise sciml test case implementations) import amici import pandas as pd From e630100e7f7a38e33ded5e2f3405576a5f960a15 Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Tue, 7 Oct 2025 16:39:16 +0100 Subject: [PATCH 55/92] codecov maybe --- python/sdist/amici/jax/petab.py | 2 -- python/sdist/amici/petab/petab_import.py | 1 - 2 files changed, 3 deletions(-) diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index b649071f80..dde7ea1c8c 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -97,8 +97,6 @@ def _get_hybridization_df(petab_problem): ] hybridization_df = pd.concat(hybridizations) return hybridization_df - else: - return None def _get_hybrid_petab_problem(petab_problem: petab.Problem): diff --git a/python/sdist/amici/petab/petab_import.py b/python/sdist/amici/petab/petab_import.py index 486c34c432..0317c14256 100644 --- a/python/sdist/amici/petab/petab_import.py +++ b/python/sdist/amici/petab/petab_import.py @@ -294,7 +294,6 @@ def _get_net_index(model_id: str): matches = re.findall(r"\[(\d+)\]", model_id) if matches: return int(matches[-1]) - return None def _get_frozen_layers(model_id): From 08e58a03222e0c2de9b4291b56cca57bb9759dc7 Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Fri, 10 Oct 2025 08:56:31 +0100 Subject: [PATCH 56/92] codecov - update cov file name --- .github/workflows/test_petab_sciml.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test_petab_sciml.yml b/.github/workflows/test_petab_sciml.yml index 20ac50a235..23f182012a 100644 --- a/.github/workflows/test_petab_sciml.yml +++ b/.github/workflows/test_petab_sciml.yml @@ -73,7 +73,7 @@ jobs: - name: Run PEtab SciML testsuite run: | source ./venv/bin/activate \ - && pytest --cov-report=xml:coverage.xml \ + && pytest --cov-report=xml:coverage_petab_sciml.xml \ --cov=./ tests/sciml/test_sciml.py - name: Codecov @@ -81,6 +81,6 @@ jobs: uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - file: coverage.xml - flags: petab + file: coverage_petab_sciml.xml + flags: petab_sciml fail_ci_if_error: true From 55363e191a0212772c136eb1e9005e60913b50a8 Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Fri, 10 Oct 2025 10:38:58 +0100 Subject: [PATCH 57/92] codecov - specify cov path --- .github/workflows/test_petab_sciml.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_petab_sciml.yml b/.github/workflows/test_petab_sciml.yml index 23f182012a..c01af88739 100644 --- a/.github/workflows/test_petab_sciml.yml +++ b/.github/workflows/test_petab_sciml.yml @@ -74,7 +74,7 @@ jobs: run: | source ./venv/bin/activate \ && pytest --cov-report=xml:coverage_petab_sciml.xml \ - --cov=./ tests/sciml/test_sciml.py + --cov=amici tests/sciml/test_sciml.py - name: Codecov if: github.event_name == 'pull_request' || github.repository_owner == 'AMICI-dev' From 6782793fa250e3fa8a486503dc9d3695c74e87c6 Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Tue, 14 Oct 2025 11:11:05 +0100 Subject: [PATCH 58/92] enable zero params case --- python/sdist/amici/jax/petab.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index dde7ea1c8c..481643a95f 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -1104,17 +1104,21 @@ def _prepare_conditions( p_array = jnp.stack( [self.load_model_parameters(sc) for sc in conditions] ) - unscaled_parameters = jnp.stack( - [ - jax_unscale( - self.parameters[ip], - self._petab_problem.parameter_df.loc[ - p_id, petab.PARAMETER_SCALE - ], - ) - for ip, p_id in enumerate(self.parameter_ids) - ] - ) + + if self.parameters.size: + unscaled_parameters = jnp.stack( + [ + jax_unscale( + self.parameters[ip], + self._petab_problem.parameter_df.loc[ + p_id, petab.PARAMETER_SCALE + ], + ) + for ip, p_id in enumerate(self.parameter_ids) + ] + ) + else: + unscaled_parameters = jnp.zeros((*self._ts_masks.shape[:2], 0)) if op_numeric is not None and op_numeric.size: op_array = jnp.where( From 3ad23096edb39aeb7b4c34f261c3dd166cff2946 Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Tue, 14 Oct 2025 12:21:31 +0100 Subject: [PATCH 59/92] doc build forward type definition workaround --- doc/conf.py | 5 +++++ doc/rtd_requirements.txt | 2 ++ 2 files changed, 7 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index 78e8534768..99f1396c0f 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -33,6 +33,7 @@ import amici import pandas as pd # noqa: F401 import sympy as sp # noqa: F401 +import warnings def install_doxygen(): @@ -364,6 +365,10 @@ def install_doxygen(): "ExpDataPtrVector": ":class:`amici.amici.ExpData`", } +# TODO: alias for forward type definition, remove after release of petab_sciml +autodoc_type_aliases = { + "NNModel": "petab_sciml.NNModel", +} def process_docstring(app, what, name, obj, options, lines): # only apply in the amici.amici module diff --git a/doc/rtd_requirements.txt b/doc/rtd_requirements.txt index 235b11d00e..da9ccf87ba 100644 --- a/doc/rtd_requirements.txt +++ b/doc/rtd_requirements.txt @@ -7,6 +7,8 @@ setuptools>=67.7.2 # https://github.com/pysb/pysb/pull/599 # for building the documentation, we don't care whether this fully works git+https://github.com/pysb/pysb@0afeaab385e9a1d813ecf6fdaf0153f4b91358af +# For forward type definition in generate_equinox +git+https://github.com/PEtab-dev/petab_sciml.git@727d177fd3f85509d0bdcc278b672e9eeafd2384#subdirectory=src/python matplotlib>=3.7.1 optax nbsphinx From 23227aa2eca96b8d615314f2d3aae77652f354ac Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Tue, 14 Oct 2025 16:17:42 +0100 Subject: [PATCH 60/92] simplify array input processing --- python/sdist/amici/jax/petab.py | 38 +++++++++++---------------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index 481643a95f..520c141d9c 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -651,32 +651,18 @@ def _get_nominal_parameter_values( # set inputs in the model if provided if len(nn_input_arrays) > 0: for net_id in model_pars: - for input in model.nns[net_id].inputs: - input_array = dict( - [ - ( - input, - dict( - [ - ( - k, - jnp.array( - nn_input_arrays[net_id][input][ - k - ][:], - dtype=jnp.float64 - if jax.config.jax_enable_x64 - else jnp.float32, - ), - ) - for k in nn_input_arrays[net_id][ - input - ].keys() - ] - ), - ) - ] - ) + input_array = { + input: { + k: jnp.array( + arr[:], + dtype=jnp.float64 + if jax.config.jax_enable_x64 + else jnp.float32, + ) + for k, arr in nn_input_arrays[net_id][input].items() + } + for input in model.nns[net_id].inputs + } model = eqx.tree_at( lambda model: model.nns[net_id].inputs, model, input_array ) From 5aacad873252ca44f3bbc9da9a0a5b0970ab9a5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Wed, 15 Oct 2025 11:58:34 +0100 Subject: [PATCH 61/92] bump versions, fixup notebook --- .../example_jax_petab/ExampleJaxPEtab.ipynb | 14 ++++++++------ python/sdist/pyproject.toml | 7 +++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/doc/examples/example_jax_petab/ExampleJaxPEtab.ipynb b/doc/examples/example_jax_petab/ExampleJaxPEtab.ipynb index 04c57edbe1..102ea6e0a8 100644 --- a/doc/examples/example_jax_petab/ExampleJaxPEtab.ipynb +++ b/doc/examples/example_jax_petab/ExampleJaxPEtab.ipynb @@ -90,7 +90,6 @@ "cell_type": "code", "id": "596b86e45e18fe3d", "metadata": {}, - "outputs": [], "source": [ "# Define the simulation condition\n", "simulation_condition = (\"model1_data1\",)\n", @@ -99,7 +98,9 @@ "ic = results[\"simulation_conditions\"].index(simulation_condition)\n", "print(\"llh: \", results[\"llh\"][ic])\n", "print(\"state variables: \", results[\"x\"][ic, :])" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -356,10 +357,11 @@ "cell_type": "code", "id": "3badd4402cf6b8c6", "metadata": {}, - "outputs": [], "source": [ "grad._my[ic, :]" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -614,14 +616,14 @@ "execution_count": null }, { - "cell_type": "code", - "id": "b8382b0b2b68f49e", "metadata": {}, + "cell_type": "code", "source": [ "# Profile gradient computation using forward sensitivity analysis\n", "solver.set_sensitivity_order(amici.SensitivityOrder.first)\n", "solver.set_sensitivity_method(amici.SensitivityMethod.forward)" ], + "id": "81fe95a6e7f613f1", "outputs": [], "execution_count": null }, diff --git a/python/sdist/pyproject.toml b/python/sdist/pyproject.toml index de13544409..86bff09af2 100644 --- a/python/sdist/pyproject.toml +++ b/python/sdist/pyproject.toml @@ -87,13 +87,12 @@ examples = [ "scipy", ] jax = [ - "jax>=0.4.36", - "jaxlib>=0.4.36", + "jax>=0.7.2", "diffrax>=0.7.0", "jaxtyping>=0.2.34", - "equinox>=0.11.10", + "equinox>=0.13.2", "optimistix>=0.0.9", - "interpax>=0.3.3,<=0.3.6", + "interpax>=0.3.9", ] sciml = [ "h5py" From 9009075c727139e7e4490f03371b1f065d73b11a Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Wed, 15 Oct 2025 14:12:57 +0100 Subject: [PATCH 62/92] safety around nn_output_ids --- python/sdist/amici/jax/petab.py | 4 ++-- tests/petab_test_suite/test_petab_suite.py | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index 2db398899a..fbcae74902 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -880,7 +880,7 @@ def _map_model_parameter_value( condition_id: str, ) -> jt.Float[jt.Scalar, ""] | float: # noqa: F722 pval = mapping.map_sim_var[pname] - if pval in self.nn_output_ids: + if hasattr(self, "nn_output_ids") and pval in self.nn_output_ids: nn_output = self._eval_nn(pval, condition_id) if nn_output.size > 1: entityId = self._petab_problem.mapping_df.loc[ @@ -1018,7 +1018,7 @@ def load_reinitialisation( """ if not any( x_id in self._petab_problem.condition_df - or x_id in self.nn_output_ids + or hasattr(self, "nn_output_ids") and x_id in self.nn_output_ids for x_id in self.model.state_ids ): return jnp.array([]), jnp.array([]) diff --git a/tests/petab_test_suite/test_petab_suite.py b/tests/petab_test_suite/test_petab_suite.py index 0962f57215..cf60e7b04e 100755 --- a/tests/petab_test_suite/test_petab_suite.py +++ b/tests/petab_test_suite/test_petab_suite.py @@ -27,7 +27,8 @@ def test_case(case, model_type, version, jax): """Wrapper for _test_case for handling test outcomes""" try: - _test_case(case, model_type, version, jax) + if model_type == "pysb" and version == "v2.0.0": + _test_case(case, model_type, version, jax) except Exception as e: if isinstance( e, NotImplementedError @@ -143,11 +144,12 @@ def _test_case(case, model_type, version, jax): "display.width", 200, ): - logger.log( - logging.DEBUG, - f"x_ss: {model.get_state_ids()} " - f"{[rdata.x_ss for rdata in rdatas]}", - ) + if not jax: + logger.log( + logging.DEBUG, + f"x_ss: {model.state_ids} " + f"{[rdata.x_ss for rdata in rdatas]}", + ) logger.log( logging.ERROR, f"Expected simulations:\n{gt_simulation_dfs}" ) From 257e859e56124892e2e730a8b665dd8238ab5066 Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Fri, 24 Oct 2025 15:10:58 +0100 Subject: [PATCH 63/92] reinstate test --- tests/petab_test_suite/test_petab_suite.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/petab_test_suite/test_petab_suite.py b/tests/petab_test_suite/test_petab_suite.py index cf60e7b04e..0ee6b83a50 100755 --- a/tests/petab_test_suite/test_petab_suite.py +++ b/tests/petab_test_suite/test_petab_suite.py @@ -27,8 +27,7 @@ def test_case(case, model_type, version, jax): """Wrapper for _test_case for handling test outcomes""" try: - if model_type == "pysb" and version == "v2.0.0": - _test_case(case, model_type, version, jax) + _test_case(case, model_type, version, jax) except Exception as e: if isinstance( e, NotImplementedError From b10b6f1e6e47ae133f6ea9cc8d6e8da46b84b7d6 Mon Sep 17 00:00:00 2001 From: Branwen Snelling Date: Fri, 24 Oct 2025 16:22:56 +0100 Subject: [PATCH 64/92] fix imports from amici.jax.nn --- python/sdist/amici/de_model.py | 2 ++ python/sdist/amici/jax/__init__.py | 4 +++- python/sdist/amici/jax/nn.py | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/python/sdist/amici/de_model.py b/python/sdist/amici/de_model.py index 39ae598243..5c09b1e7b2 100644 --- a/python/sdist/amici/de_model.py +++ b/python/sdist/amici/de_model.py @@ -31,6 +31,7 @@ Event, EventObservable, Expression, + LogLikelihood, LogLikelihoodRZ, LogLikelihoodY, LogLikelihoodZ, @@ -39,6 +40,7 @@ Observable, ObservableParameter, Parameter, + Sigma, SigmaY, SigmaZ, State, diff --git a/python/sdist/amici/jax/__init__.py b/python/sdist/amici/jax/__init__.py index 8e20d274de..f989cfcd19 100644 --- a/python/sdist/amici/jax/__init__.py +++ b/python/sdist/amici/jax/__init__.py @@ -10,7 +10,7 @@ from warnings import warn from amici.jax.model import JAXModel -from amici.jax.nn import generate_equinox +from amici.jax.nn import Flatten, generate_equinox, tanhshrink from amici.jax.petab import ( JAXProblem, ReturnValue, @@ -27,8 +27,10 @@ __all__ = [ "JAXModel", "JAXProblem", + "Flatten", "generate_equinox", "run_simulations", "petab_simulate", "ReturnValue", + "tanhshrink", ] diff --git a/python/sdist/amici/jax/nn.py b/python/sdist/amici/jax/nn.py index f94a0dcca4..f4f77f60ec 100644 --- a/python/sdist/amici/jax/nn.py +++ b/python/sdist/amici/jax/nn.py @@ -95,7 +95,7 @@ def _generate_layer(layer: "Layer", indent: int, ilayer: int) -> str: # noqa: F layer_map = { "Dropout1d": "eqx.nn.Dropout", "Dropout2d": "eqx.nn.Dropout", - "Flatten": "amici.jax.nn.Flatten", + "Flatten": "amici.jax.Flatten", } if layer.layer_type.startswith( ("BatchNorm", "AlphaDropout", "InstanceNorm") @@ -187,7 +187,7 @@ def _generate_forward(node: "Node", indent, frozen_layers: dict = {}, layer_type "hardtanh": "jax.nn.hard_tanh", "hardsigmoid": "jax.nn.hard_sigmoid", "hardswish": "jax.nn.hard_swish", - "tanhshrink": "amici.jax.nn.tanhshrink", + "tanhshrink": "amici.jax.tanhshrink", "softsign": "jax.nn.soft_sign", } if node.target == "hardtanh": From 0b3bb2680be13907f882aed1786d53e764a7ea84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Tue, 28 Oct 2025 14:53:20 +0000 Subject: [PATCH 65/92] skip petab tests with mapping df --- tests/petab_test_suite/test_petab_suite.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/petab_test_suite/test_petab_suite.py b/tests/petab_test_suite/test_petab_suite.py index 0ee6b83a50..9b687afa13 100755 --- a/tests/petab_test_suite/test_petab_suite.py +++ b/tests/petab_test_suite/test_petab_suite.py @@ -52,6 +52,11 @@ def _test_case(case, model_type, version, jax): yaml_file = case_dir / petabtests.problem_yaml_name(case) problem = petab.Problem.from_yaml(yaml_file) + if problem.mapping_df is not None: + pytest.skip( + "PEtab test suite cases with mapping_df are not supported yet." + ) + # compile amici model if case.startswith("0006") and not jax: petab.flatten_timepoint_specific_output_overrides(problem) From f3f09ea4b4d3abf2572adab82c151a954aa1a18e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 30 Oct 2025 09:02:22 +0000 Subject: [PATCH 66/92] Apply suggestion from @dweindl Co-authored-by: Daniel Weindl --- python/sdist/amici/jax/petab.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index fbcae74902..72499efdeb 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -1291,10 +1291,7 @@ def run_simulations( [ jnp.array( [ - True - if p - in set(self._parameter_mappings[sc].map_sim_var.keys()) - else False + p in set(self._parameter_mappings[sc].map_sim_var.keys()) for p in self.model.state_ids ] ) From d30b710f65150c88662068cc6b1906e04fb82d4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 30 Oct 2025 09:03:10 +0000 Subject: [PATCH 67/92] Update python/sdist/amici/petab/petab_import.py Co-authored-by: Daniel Weindl --- python/sdist/amici/petab/petab_import.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/sdist/amici/petab/petab_import.py b/python/sdist/amici/petab/petab_import.py index 25cf3a85eb..89380e20d4 100644 --- a/python/sdist/amici/petab/petab_import.py +++ b/python/sdist/amici/petab/petab_import.py @@ -241,7 +241,7 @@ def import_petab_problem( } for net_id, net_config in config["neural_nets"].items() } - if not jax or petab_problem.model.type_id == MODEL_TYPE_PYSB: + if not jax or petab_problem.model.type_id != MODEL_TYPE_SBML: raise NotImplementedError( "petab_sciml extension is currently only supported for sbml models" ) From bfe1ad6f0a55bba94369155ccc8ffda0e9a03740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 30 Oct 2025 09:03:42 +0000 Subject: [PATCH 68/92] Update python/sdist/amici/petab/petab_import.py Co-authored-by: Daniel Weindl --- python/sdist/amici/petab/petab_import.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/python/sdist/amici/petab/petab_import.py b/python/sdist/amici/petab/petab_import.py index 89380e20d4..38be8cb223 100644 --- a/python/sdist/amici/petab/petab_import.py +++ b/python/sdist/amici/petab/petab_import.py @@ -304,7 +304,3 @@ def _get_frozen_layers(model_id): layer_id = layers[0] if len(layers) else None array_attr = array_attr if array_attr in ("weight", "bias") else None return layer_id, array_attr - - -# for backwards compatibility -import_model = import_model_sbml From 548f754998c0c2ed5486a11cea6f7516dac89f6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 30 Oct 2025 09:07:08 +0000 Subject: [PATCH 69/92] review comments --- pytest.ini | 1 - python/sdist/amici/sbml_import.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/pytest.ini b/pytest.ini index 486be863e6..a32b3badf3 100644 --- a/pytest.ini +++ b/pytest.ini @@ -13,7 +13,6 @@ filterwarnings = ignore:Conservation laws for non-constant species in models with RateRules are currently not supported and will be turned off.:UserWarning ignore:Conservation laws for non-constant species in models with Species-AssignmentRules are currently not supported and will be turned off.:UserWarning ignore:Conservation laws for non-constant species in combination with parameterized stoichiometric coefficients are not currently supported and will be turned off.:UserWarning - ignore:PEtab v2.0.0 mapping tables are only partially supported:UserWarning ignore:Support for PEtab2.0.*experimental:UserWarning ignore:The JAX module is experimental and the API may change in the future.:ImportWarning # hundreds of SBML <=5.17 warnings diff --git a/python/sdist/amici/sbml_import.py b/python/sdist/amici/sbml_import.py index 87d8ea0e78..b3738f4e35 100644 --- a/python/sdist/amici/sbml_import.py +++ b/python/sdist/amici/sbml_import.py @@ -215,7 +215,7 @@ def _process_document(self) -> None: log_execution_time("validating SBML", logger)( self.sbml_doc.validateSBML )() - # _check_lib_sbml_errors(self.sbml_doc, self.show_sbml_warnings) + _check_lib_sbml_errors(self.sbml_doc, self.show_sbml_warnings) # Flatten "comp" model? Do that before any other converters are run if any( @@ -262,7 +262,7 @@ def _process_document(self) -> None: # If any of the above calls produces an error, this will be added to # the SBMLError log in the sbml document. Thus, it is sufficient to # check the error log just once after all conversion/validation calls. - # _check_lib_sbml_errors(self.sbml_doc, self.show_sbml_warnings) + _check_lib_sbml_errors(self.sbml_doc, self.show_sbml_warnings) # need to reload the converted model self.sbml = self.sbml_doc.getModel() From b02507c3b03d17590be5fa306f5232a63e3cfd4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 30 Oct 2025 09:25:02 +0000 Subject: [PATCH 70/92] document hybridization table --- python/sdist/amici/de_export.py | 10 ++++++++-- python/sdist/amici/de_model.py | 7 +++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/python/sdist/amici/de_export.py b/python/sdist/amici/de_export.py index 23206d8d13..32347d47d2 100644 --- a/python/sdist/amici/de_export.py +++ b/python/sdist/amici/de_export.py @@ -165,7 +165,7 @@ def __init__( allow_reinit_fixpar_initcond: bool | None = True, generate_sensitivity_code: bool | None = True, model_name: str | None = "model", - hybridisation: dict | None = None, + hybridization: dict | None = None, ): """ Generate AMICI C++ files for the DE provided to the constructor. @@ -197,6 +197,12 @@ def __init__( :param model_name: name of the model to be used during code generation + + :param hybridization: + hybridization table for sciml models, briefly assigns model variables + to neural network inputs/outputs and vice versa. See + https://petab-sciml.readthedocs.io/latest/format.html#hybridization-table + for details. """ set_log_level(logger, verbose) @@ -238,7 +244,7 @@ def __init__( self.allow_reinit_fixpar_initcond: bool = allow_reinit_fixpar_initcond self._build_hints = set() self.generate_sensitivity_code: bool = generate_sensitivity_code - self.hybridisation = hybridisation + self.hybridisation = hybridization @log_execution_time("generating cpp code", logger) def generate_model_code(self) -> None: diff --git a/python/sdist/amici/de_model.py b/python/sdist/amici/de_model.py index 5c09b1e7b2..daf6dd93ee 100644 --- a/python/sdist/amici/de_model.py +++ b/python/sdist/amici/de_model.py @@ -2572,10 +2572,13 @@ def _components(self) -> list[ModelQuantity]: def _process_hybridization(self, hybridization: dict) -> None: """ - Parses the hybridisation information and updates the model accordingly + Parses the hybridization information and updates the model accordingly :param hybridization: - hybridization information + hybridization table for sciml models, briefly assigns model variables + to neural network inputs/outputs and vice versa. See + https://petab-sciml.readthedocs.io/latest/format.html#hybridization-table + for details. """ added_expressions = False orig_obs = tuple([s.get_id() for s in self._observables]) From 090dfa1e3b291f1d6e55e857a563022693635316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 30 Oct 2025 13:23:54 +0000 Subject: [PATCH 71/92] update sciml repo --- .github/workflows/test_petab_sciml.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_petab_sciml.yml b/.github/workflows/test_petab_sciml.yml index c01af88739..b5f0f9ef57 100644 --- a/.github/workflows/test_petab_sciml.yml +++ b/.github/workflows/test_petab_sciml.yml @@ -61,7 +61,7 @@ jobs: - name: Download and install PEtab SciML run: | source ./venv/bin/activate \ - && python -m pip install git+https://github.com/sebapersson/petab_sciml.git@main#subdirectory=src/python \ + && python -m pip install git+https://github.com/petab-dev/petab_sciml.git@main#subdirectory=src/python \ - name: Install petab From 2b761932715f3f83a6b37cfc6f0e174fbc10e718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 30 Oct 2025 13:27:38 +0000 Subject: [PATCH 72/92] refactor and add documentation to nn code --- python/sdist/amici/jax/nn.py | 270 +++++++++++++++++++++++++++-------- 1 file changed, 210 insertions(+), 60 deletions(-) diff --git a/python/sdist/amici/jax/nn.py b/python/sdist/amici/jax/nn.py index f4f77f60ec..5dc3e5f5e9 100644 --- a/python/sdist/amici/jax/nn.py +++ b/python/sdist/amici/jax/nn.py @@ -1,14 +1,15 @@ from pathlib import Path - import equinox as eqx import jax.numpy as jnp -from amici._codegen.template import apply_template from amici import amiciModulePath +from amici._codegen.template import apply_template class Flatten(eqx.Module): + """Custom implementation of a torch.flatten layer for Equinox.""" + start_dim: int end_dim: int @@ -27,13 +28,33 @@ def __call__(self, x): def tanhshrink(x: jnp.ndarray) -> jnp.ndarray: + """Custom implementation of the torch.nn.Tanhshrink activation function for JAX.""" return x - jnp.tanh(x) -def generate_equinox(nn_model: "NNModel", filename: Path | str, frozen_layers: dict = {}): # noqa: F821 +def generate_equinox( + nn_model: "NNModel", # noqa: F821 + filename: Path | str, + frozen_layers: dict[str, bool] | None = None, +) -> None: + """ + Generate Equinox model file from petab_sciml neural network object. + + :param nn_model: + Neural network model in petab_sciml format + :param filename: + output filename for generated Equinox model + :param frozen_layers: + list of layer names to freeze during training + :return: + + """ # TODO: move to top level import and replace forward type definitions from petab_sciml import Layer + if frozen_layers is None: + frozen_layers = {} + filename = Path(filename) layer_indent = 12 node_indent = 8 @@ -84,6 +105,9 @@ def generate_equinox(nn_model: "NNModel", filename: Path | str, frozen_layers: d def _process_argval(v): + """ + Process argument value for layer instantiation string + """ if isinstance(v, str): return f"'{v}'" if isinstance(v, bool): @@ -92,11 +116,19 @@ def _process_argval(v): def _generate_layer(layer: "Layer", indent: int, ilayer: int) -> str: # noqa: F821 - layer_map = { - "Dropout1d": "eqx.nn.Dropout", - "Dropout2d": "eqx.nn.Dropout", - "Flatten": "amici.jax.Flatten", - } + """ + Generate layer definition string for a given layer + + :param layer: + petab_sciml Layer object + :param indent: + indentation level for generated string + :param ilayer: + layer index for key generation + + :return: + string defining the layer in equinox syntax + """ if layer.layer_type.startswith( ("BatchNorm", "AlphaDropout", "InstanceNorm") ): @@ -110,6 +142,14 @@ def _generate_layer(layer: "Layer", indent: int, ilayer: int) -> str: # noqa: F if layer.layer_type == "Bilinear": raise NotImplementedError("Bilinear layers not supported") + # mapping of layer names in sciml yaml format to equinox/custom amici implementations + layer_map = { + "Dropout1d": "eqx.nn.Dropout", + "Dropout2d": "eqx.nn.Dropout", + "Flatten": "amici.jax.Flatten", + } + + # mapping of keyword argument names in sciml yaml format to equinox/custom amici implementations kwarg_map = { "Linear": { "bias": "use_bias", @@ -125,10 +165,12 @@ def _generate_layer(layer: "Layer", indent: int, ilayer: int) -> str: # noqa: F "normalized_shape": "shape", }, } + # list of keyword arguments to ignore when generating layer, as they are not supported in equinox (see above) kwarg_ignore = { "Dropout1d": ("inplace",), "Dropout2d": ("inplace",), } + # construct argument string for layer instantiation kwargs = [ f"{kwarg_map.get(layer.layer_type, {}).get(k, k)}={_process_argval(v)}" for k, v in layer.args.items() @@ -150,70 +192,178 @@ def _generate_layer(layer: "Layer", indent: int, ilayer: int) -> str: # noqa: F return f"{' ' * indent}'{layer.layer_id}': {layer_str}" -def _generate_forward(node: "Node", indent, frozen_layers: dict = {}, layer_type=str) -> str: # noqa: F821 +def _format_function_call( + var_name: str, fun_str: str, args: list, kwargs: list[str], indent: int +) -> str: + """ + Utility function to format a function call assignment string. + + :param var_name: + name of the variable to assign the result to + :param fun_str: + string representation of the function to call + :param args: + list of positional arguments + :param kwargs: + list of keyword arguments as strings + :param indent: + indentation level for generated string + + :return: + formatted string representing the function call assignment + """ + args_str = ", ".join([f"{arg}" for arg in args]) + kwargs_str = ", ".join(kwargs) + all_args = ", ".join(filter(None, [args_str, kwargs_str])) + return f"{' ' * indent}{var_name} = {fun_str}({all_args})" + + +def _process_layer_call( + node: "Node", # noqa: F821 + layer_type: str, + frozen_layers: dict[str, bool], +) -> tuple[str, str]: + """ + Process a layer (call_module) node and return function string and optional tree string. + + :param node: + petab sciml Node object representing a layer call + :param layer_type: + petab sciml layer type of the node + :param frozen_layers: + dict of layer names to boolean indicating whether layer is frozen + + :return: + tuple of (function_string, tree_string) where tree_string is empty if no tree is needed + """ + fun_str = f"self.layers['{node.target}']" + tree_string = "" + + # Handle frozen layers + if node.name in frozen_layers: + if frozen_layers[node.name]: + arr_attr = frozen_layers[node.name] + get_lambda = f"lambda layer: getattr(layer, '{arr_attr}')" + replacer = "replace_fn = lambda arr: jax.lax.stop_gradient(arr)" + tree_string = f"tree_{node.name} = eqx.tree_at({get_lambda}, {fun_str}, {replacer})" + fun_str = f"tree_{node.name}" + else: + fun_str = f"jax.lax.stop_gradient({fun_str})" + + # Handle vmap for certain layer types + if layer_type.startswith(("Conv", "Linear", "LayerNorm")): + if layer_type in ("LayerNorm",): + dims = f"len({fun_str}.shape)+1" + elif layer_type == "Linear": + dims = 2 + elif layer_type.endswith("1d"): + dims = 3 + elif layer_type.endswith("2d"): + dims = 4 + elif layer_type.endswith("3d"): + dims = 5 + fun_str = f"(jax.vmap({fun_str}) if len({node.args[0]}.shape) == {dims} else {fun_str})" + + return fun_str, tree_string + + +def _process_activation_call(node: "Node") -> str: # noqa: F821 + """ + Process an activation function (call_function/call_method) node and return function string. + + :param node: + petab sciml Node object representing an activation function call + + :return: + string representation of the activation function + """ + # Mapping of function names in sciml yaml format to equinox/custom amici implementations + activation_map = { + "hardtanh": "jax.nn.hard_tanh", + "hardsigmoid": "jax.nn.hard_sigmoid", + "hardswish": "jax.nn.hard_swish", + "tanhshrink": "amici.jax.tanhshrink", + "softsign": "jax.nn.soft_sign", + } + + # Validate hardtanh parameters + if node.target == "hardtanh": + if node.kwargs.pop("min_val", -1.0) != -1.0: + raise NotImplementedError( + "min_val != -1.0 not supported for hardtanh" + ) + if node.kwargs.pop("max_val", 1.0) != 1.0: + raise NotImplementedError( + "max_val != 1.0 not supported for hardtanh" + ) + + return activation_map.get(node.target, f"jax.nn.{node.target}") + + +def _generate_forward( + node: "Node", # noqa: F821 + indent, + frozen_layers: dict[str, bool] | None = None, + layer_type: str = "", +) -> str: + """ + Generate forward pass line for a given node + + :param node: + petab sciml Node object representing a step in the forward pass + :param indent: + indentation level for generated string + :param frozen_layers: + dict of layer names to boolean indicating whether layer is frozen + :param layer_type: + petab sciml layer type of the node (only relevant for call_module nodes) + + :return: + string defining the forward pass implementation for the given node in equinox syntax + """ + if frozen_layers is None: + frozen_layers = {} + + # Handle placeholder nodes if node.op == "placeholder": # TODO: inconsistent target vs name return f"{' ' * indent}{node.name} = input" + # Handle output nodes + if node.op == "output": + args_str = ", ".join([f"{arg}" for arg in node.args]) + return f"{' ' * indent}{node.target} = {args_str}" + + # Process layer calls + tree_string = "" if node.op == "call_module": - fun_str = f"self.layers['{node.target}']" - if node.name in frozen_layers: - if frozen_layers[node.name]: - arr_attr = frozen_layers[node.name] - get_lambda = f"lambda layer: getattr(layer, '{arr_attr}')" - replacer = ( - "replace_fn = lambda arr: jax.lax.stop_gradient(arr)" - ) - tree_string = f"tree_{node.name} = eqx.tree_at({get_lambda}, {fun_str}, {replacer})" - fun_str = f"tree_{node.name}" - else: - fun_str = f"jax.lax.stop_gradient({fun_str})" - tree_string = "" - if layer_type.startswith(("Conv", "Linear", "LayerNorm")): - if layer_type in ("LayerNorm",): - dims = f"len({fun_str}.shape)+1" - if layer_type == "Linear": - dims = 2 - if layer_type.endswith(("1d",)): - dims = 3 - elif layer_type.endswith(("2d",)): - dims = 4 - elif layer_type.endswith("3d"): - dims = 5 - fun_str = f"(jax.vmap({fun_str}) if len({node.args[0]}.shape) == {dims} else {fun_str})" + fun_str, tree_string = _process_layer_call( + node, layer_type, frozen_layers + ) + # Process activation function calls if node.op in ("call_function", "call_method"): - map_fun = { - "hardtanh": "jax.nn.hard_tanh", - "hardsigmoid": "jax.nn.hard_sigmoid", - "hardswish": "jax.nn.hard_swish", - "tanhshrink": "amici.jax.tanhshrink", - "softsign": "jax.nn.soft_sign", - } - if node.target == "hardtanh": - if node.kwargs.pop("min_val", -1.0) != -1.0: - raise NotImplementedError( - "min_val != -1.0 not supported for hardtanh" - ) - if node.kwargs.pop("max_val", 1.0) != 1.0: - raise NotImplementedError( - "max_val != 1.0 not supported for hardtanh" - ) - fun_str = map_fun.get(node.target, f"jax.nn.{node.target}") + fun_str = _process_activation_call(node) - args = ", ".join([f"{arg}" for arg in node.args]) + # Build kwargs list, filtering out unsupported arguments kwargs = [ f"{k}={item}" for k, item in node.kwargs.items() if k not in ("inplace",) ] - if layer_type.startswith(("Dropout",)): + + # Add key parameter for Dropout layers + if layer_type.startswith("Dropout"): kwargs += ["key=key"] - kwargs_str = ", ".join(kwargs) + + # Format the function call if node.op in ("call_module", "call_function", "call_method"): - if node.name in frozen_layers: - return f"{' ' * indent}{tree_string}\n{' ' * indent}{node.name} = {fun_str}({args + ', ' + kwargs_str})" - else: - return f"{' ' * indent}{node.name} = {fun_str}({args + ', ' + kwargs_str})" - if node.op == "output": - return f"{' ' * indent}{node.target} = {args}" + result = _format_function_call( + node.name, fun_str, node.args, kwargs, indent + ) + # Prepend tree_string if needed for frozen layers + if tree_string: + return f"{' ' * indent}{tree_string}\n{result}" + return result + + raise NotImplementedError(f"Operation {node.op} not supported") From 56cb5618e7d5d930595c18e5c82bc708c358c744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 30 Oct 2025 16:58:39 +0000 Subject: [PATCH 73/92] print missing components --- python/sdist/amici/de_model.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/python/sdist/amici/de_model.py b/python/sdist/amici/de_model.py index daf6dd93ee..9936443983 100644 --- a/python/sdist/amici/de_model.py +++ b/python/sdist/amici/de_model.py @@ -2596,8 +2596,11 @@ def _process_hybridization(self, hybridization: dict) -> None: key=lambda comp: net["input_vars"].index(str(comp.get_id())), ) if len(inputs) != len(net["input_vars"]): + found_vars = {str(comp.get_id()) for comp in inputs} + missing_vars = set(net["input_vars"]) - found_vars raise ValueError( - f"Could not find all input variables for neural network {net_id}" + f"Could not find all input variables for neural network {net_id}. " + f"Missing variables: {sorted(missing_vars)}" ) for inp in inputs: if isinstance( @@ -2621,8 +2624,11 @@ def _process_hybridization(self, hybridization: dict) -> None: in net["output_vars"] } if len(outputs.keys()) != len(net["output_vars"]): + found_vars = set(outputs.keys()) + missing_vars = set(net["output_vars"]) - found_vars raise ValueError( - f"Could not find all output variables for neural network {net_id}" + f"Could not find all output variables for neural network {net_id}. " + f"Missing variables: {sorted(missing_vars)}" ) for out_var, parts in outputs.items(): @@ -2671,8 +2677,11 @@ def _process_hybridization(self, hybridization: dict) -> None: # in net["observable_vars"] } if len(observables.keys()) != len(net["observable_vars"]): + found_vars = set(observables.keys()) + missing_vars = set(net["observable_vars"]) - found_vars raise ValueError( - f"Could not find all observable variables for neural network {net_id}" + f"Could not find all observable variables for neural network {net_id}. " + f"Missing variables: {sorted(missing_vars)}" ) for ob_var, parts in observables.items(): From 8e5952e95c0342a8ad125c165fcd9e1b6f94f113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 30 Oct 2025 17:15:15 +0000 Subject: [PATCH 74/92] add tests --- python/tests/test_sbml_sciml.py | 504 ++++++++++++++++++++++++++++++++ 1 file changed, 504 insertions(+) create mode 100644 python/tests/test_sbml_sciml.py diff --git a/python/tests/test_sbml_sciml.py b/python/tests/test_sbml_sciml.py new file mode 100644 index 0000000000..41fed2bcaf --- /dev/null +++ b/python/tests/test_sbml_sciml.py @@ -0,0 +1,504 @@ +"""Tests for SBML/SciML functionality, including JAX neural network code generation.""" + +import pytest + +pytest.importorskip("jax") +pytest.importorskip("equinox") + +from unittest.mock import Mock + +from amici.jax.nn import ( + _format_function_call, + _generate_forward, + _process_activation_call, + _process_layer_call, +) + + +class TestFormatFunctionCall: + """Test the utility function for formatting function calls.""" + + def test_format_with_args_only(self): + """Test formatting with only positional arguments.""" + result = _format_function_call( + var_name="output", + fun_str="my_function", + args=["x", "y"], + kwargs=[], + indent=4, + ) + assert result == " output = my_function(x, y)" + + def test_format_with_kwargs_only(self): + """Test formatting with only keyword arguments.""" + result = _format_function_call( + var_name="output", + fun_str="my_function", + args=[], + kwargs=["a=1", "b=2"], + indent=4, + ) + assert result == " output = my_function(a=1, b=2)" + + def test_format_with_args_and_kwargs(self): + """Test formatting with both positional and keyword arguments.""" + result = _format_function_call( + var_name="result", + fun_str="jax.nn.relu", + args=["input_tensor"], + kwargs=["axis=1"], + indent=8, + ) + assert result == " result = jax.nn.relu(input_tensor, axis=1)" + + def test_format_with_no_args(self): + """Test formatting with no arguments.""" + result = _format_function_call( + var_name="output", + fun_str="get_value", + args=[], + kwargs=[], + indent=0, + ) + assert result == "output = get_value()" + + def test_format_with_zero_indent(self): + """Test formatting with zero indentation.""" + result = _format_function_call( + var_name="x", + fun_str="func", + args=["a"], + kwargs=["b=2"], + indent=0, + ) + assert result == "x = func(a, b=2)" + + +class TestProcessLayerCall: + """Test layer-specific processing logic.""" + + def test_simple_layer_no_freezing(self): + """Test processing a simple layer without freezing.""" + node = Mock() + node.target = "layer1" + node.name = "conv1" + node.args = ["input"] + + fun_str, tree_string = _process_layer_call( + node, layer_type="Conv2d", frozen_layers={} + ) + + assert fun_str.startswith("(jax.vmap(self.layers['layer1'])") + assert tree_string == "" + + def test_frozen_layer_with_attribute(self): + """Test processing a frozen layer with specific attribute.""" + node = Mock() + node.target = "layer1" + node.name = "conv1" + node.args = ["input"] + + fun_str, tree_string = _process_layer_call( + node, layer_type="Conv2d", frozen_layers={"conv1": "weight"} + ) + + assert "tree_conv1" in fun_str + assert "tree_conv1 = eqx.tree_at(" in tree_string + assert "'weight'" in tree_string + + def test_frozen_layer_full_stop_gradient(self): + """Test processing a fully frozen layer.""" + node = Mock() + node.target = "layer1" + node.name = "linear1" + node.args = ["input"] + + fun_str, tree_string = _process_layer_call( + node, layer_type="Linear", frozen_layers={"linear1": False} + ) + + assert "jax.lax.stop_gradient(self.layers['layer1'])" in fun_str + assert tree_string == "" + + def test_linear_layer_vmap(self): + """Test that Linear layer gets vmap wrapper.""" + node = Mock() + node.target = "fc1" + node.name = "fc1" + node.args = ["x"] + + fun_str, tree_string = _process_layer_call( + node, layer_type="Linear", frozen_layers={} + ) + + assert "jax.vmap" in fun_str + assert "if len(x.shape) == 2" in fun_str + + def test_conv1d_layer_vmap(self): + """Test that Conv1d layer gets vmap wrapper with correct dimensions.""" + node = Mock() + node.target = "conv" + node.name = "conv" + node.args = ["x"] + + fun_str, tree_string = _process_layer_call( + node, layer_type="Conv1d", frozen_layers={} + ) + + assert "jax.vmap" in fun_str + assert "if len(x.shape) == 3" in fun_str + + def test_conv2d_layer_vmap(self): + """Test that Conv2d layer gets vmap wrapper with correct dimensions.""" + node = Mock() + node.target = "conv" + node.name = "conv" + node.args = ["x"] + + fun_str, tree_string = _process_layer_call( + node, layer_type="Conv2d", frozen_layers={} + ) + + assert "jax.vmap" in fun_str + assert "if len(x.shape) == 4" in fun_str + + def test_layernorm_vmap(self): + """Test that LayerNorm layer gets vmap wrapper.""" + node = Mock() + node.target = "norm" + node.name = "norm" + node.args = ["x"] + + fun_str, tree_string = _process_layer_call( + node, layer_type="LayerNorm", frozen_layers={} + ) + + assert "jax.vmap" in fun_str + assert "len(self.layers['norm'].shape)+1" in fun_str + + def test_non_vmap_layer(self): + """Test layer that doesn't require vmap.""" + node = Mock() + node.target = "dropout" + node.name = "dropout" + node.args = ["x"] + + fun_str, tree_string = _process_layer_call( + node, layer_type="Dropout", frozen_layers={} + ) + + assert "jax.vmap" not in fun_str + assert fun_str == "self.layers['dropout']" + + +class TestProcessActivationCall: + """Test activation function processing logic.""" + + def test_standard_activation(self): + """Test standard JAX activation function.""" + node = Mock() + node.target = "relu" + node.kwargs = {} + + fun_str = _process_activation_call(node) + assert fun_str == "jax.nn.relu" + + def test_mapped_activation_hardtanh(self): + """Test hardtanh activation with custom mapping.""" + node = Mock() + node.target = "hardtanh" + node.kwargs = {} + + fun_str = _process_activation_call(node) + assert fun_str == "jax.nn.hard_tanh" + + def test_mapped_activation_hardsigmoid(self): + """Test hardsigmoid activation with custom mapping.""" + node = Mock() + node.target = "hardsigmoid" + node.kwargs = {} + + fun_str = _process_activation_call(node) + assert fun_str == "jax.nn.hard_sigmoid" + + def test_mapped_activation_tanhshrink(self): + """Test tanhshrink activation with custom mapping.""" + node = Mock() + node.target = "tanhshrink" + node.kwargs = {} + + fun_str = _process_activation_call(node) + assert fun_str == "amici.jax.tanhshrink" + + def test_hardtanh_valid_params(self): + """Test hardtanh with valid default parameters.""" + node = Mock() + node.target = "hardtanh" + node.kwargs = {"min_val": -1.0, "max_val": 1.0} + + fun_str = _process_activation_call(node) + assert fun_str == "jax.nn.hard_tanh" + + def test_hardtanh_invalid_min_val(self): + """Test hardtanh raises error for non-default min_val.""" + node = Mock() + node.target = "hardtanh" + node.kwargs = {"min_val": -2.0} + + with pytest.raises(NotImplementedError, match="min_val != -1.0"): + _process_activation_call(node) + + def test_hardtanh_invalid_max_val(self): + """Test hardtanh raises error for non-default max_val.""" + node = Mock() + node.target = "hardtanh" + node.kwargs = {"max_val": 2.0} + + with pytest.raises(NotImplementedError, match="max_val != 1.0"): + _process_activation_call(node) + + +class TestGenerateForward: + """Test the main forward pass generation function.""" + + def test_placeholder_node(self): + """Test generation for placeholder nodes.""" + node = Mock() + node.op = "placeholder" + node.name = "input_x" + + result = _generate_forward(node, indent=4) + assert result == " input_x = input" + + def test_output_node(self): + """Test generation for output nodes.""" + node = Mock() + node.op = "output" + node.target = "output" + node.args = ["y1", "y2"] + + result = _generate_forward(node, indent=8) + assert result == " output = y1, y2" + + def test_call_module_simple(self): + """Test generation for simple module call.""" + node = Mock() + node.op = "call_module" + node.name = "x1" + node.target = "layer1" + node.args = ["input"] + node.kwargs = {} + + result = _generate_forward( + node, indent=4, frozen_layers={}, layer_type="Dropout" + ) + assert "x1 = self.layers['layer1'](input, key=key)" in result + + def test_call_function_activation(self): + """Test generation for activation function call.""" + node = Mock() + node.op = "call_function" + node.name = "act1" + node.target = "relu" + node.args = ["x"] + node.kwargs = {} + + result = _generate_forward( + node, indent=4, frozen_layers={}, layer_type="" + ) + assert result == " act1 = jax.nn.relu(x)" + + def test_call_module_with_frozen_layer(self): + """Test generation for frozen layer with tree_string.""" + node = Mock() + node.op = "call_module" + node.name = "conv1" + node.target = "layer1" + node.args = ["input"] + node.kwargs = {} + + result = _generate_forward( + node, + indent=4, + frozen_layers={"conv1": "weight"}, + layer_type="Conv2d", + ) + + assert "tree_conv1 = eqx.tree_at(" in result + assert "conv1 = " in result + assert "\n" in result # Should have tree_string on separate line + + def test_unsupported_operation(self): + """Test that unsupported operations raise NotImplementedError.""" + node = Mock() + node.op = "unknown_op" + node.kwargs = {} + + with pytest.raises( + NotImplementedError, match="Operation unknown_op not supported" + ): + _generate_forward(node, indent=4) + + def test_kwargs_filtering(self): + """Test that 'inplace' kwarg is filtered out.""" + node = Mock() + node.op = "call_function" + node.name = "act1" + node.target = "relu" + node.args = ["x"] + node.kwargs = {"inplace": True, "other": "value"} + + result = _generate_forward(node, indent=4, layer_type="") + assert "inplace" not in result + assert "other=value" in result + + def test_dropout_layer_adds_key(self): + """Test that Dropout layers get key parameter added.""" + node = Mock() + node.op = "call_module" + node.name = "drop1" + node.target = "dropout1" + node.args = ["x"] + node.kwargs = {} + + result = _generate_forward( + node, indent=4, frozen_layers={}, layer_type="Dropout1d" + ) + assert "key=key" in result + + +class TestProcessHybridizationErrors: + """Test the improved error messages in _process_hybridization.""" + + @pytest.fixture + def mock_de_model(self): + """Create a mock DEModel instance for testing.""" + import sympy as sp + from amici.de_model import DEModel + from amici.de_model_components import ( + DifferentialState, + Expression, + Observable, + Parameter, + ) + + model = DEModel() + + # Add some parameters + model._parameters = [ + Parameter(sp.Symbol("p1"), "param1", sp.Float(1.0)), + Parameter(sp.Symbol("p2"), "param2", sp.Float(2.0)), + ] + + # Add some expressions + model._expressions = [ + Expression(sp.Symbol("expr1"), "expression1", sp.Float(0.5)), + Expression(sp.Symbol("expr2"), "expression2", sp.Float(0.7)), + ] + + # Add some differential states + model._differential_states = [ + DifferentialState( + sp.Symbol("x1"), "state1", sp.Float(0.0), sp.Float(0.1) + ), + DifferentialState( + sp.Symbol("x2"), "state2", sp.Float(0.0), sp.Float(0.2) + ), + ] + + # Add some observables + model._observables = [ + Observable(sp.Symbol("obs1"), "observable1", sp.Symbol("x1")), + Observable(sp.Symbol("obs2"), "observable2", sp.Symbol("x2")), + ] + + return model + + def test_missing_input_variables(self, mock_de_model): + """Test error message for missing input variables.""" + hybridization = { + "neural_net1": { + "static": False, + "input_vars": [ + "p1", + "p2", + "p_missing", + ], # p_missing doesn't exist + "output_vars": {"expr1": 0}, + "observable_vars": {}, + } + } + + with pytest.raises(ValueError) as exc_info: + mock_de_model._process_hybridization(hybridization) + + error_msg = str(exc_info.value) + assert ( + "Could not find all input variables for neural network neural_net1" + in error_msg + ) + assert "Missing variables:" in error_msg + assert "p_missing" in error_msg + + def test_missing_output_variables(self, mock_de_model): + """Test error message for missing output variables.""" + hybridization = { + "neural_net2": { + "static": False, + "input_vars": ["p1", "p2"], + "output_vars": { + "expr1": 0, + "expr_missing": 1, + "expr_also_missing": 2, + }, + "observable_vars": {}, + } + } + + with pytest.raises(ValueError) as exc_info: + mock_de_model._process_hybridization(hybridization) + + error_msg = str(exc_info.value) + assert ( + "Could not find all output variables for neural network neural_net2" + in error_msg + ) + assert "Missing variables:" in error_msg + # Check that missing variables are in the message + assert "expr_missing" in error_msg or "expr_also_missing" in error_msg + + def test_missing_observable_variables(self, mock_de_model): + """Test error message for missing observable variables.""" + hybridization = { + "neural_net3": { + "static": False, + "input_vars": ["p1"], + "output_vars": {"expr1": 0}, + "observable_vars": {"obs1": 0, "obs_missing": 1}, + } + } + + with pytest.raises(ValueError) as exc_info: + mock_de_model._process_hybridization(hybridization) + + error_msg = str(exc_info.value) + assert ( + "Could not find all observable variables for neural network neural_net3" + in error_msg + ) + assert "Missing variables:" in error_msg + assert "obs_missing" in error_msg + + def test_valid_hybridization_no_error(self, mock_de_model): + """Test that valid hybridization doesn't raise errors.""" + hybridization = { + "valid_net": { + "static": False, + "input_vars": ["p1", "p2"], + "output_vars": {"expr1": 0}, + "observable_vars": {"obs1": 0}, + } + } + + # Should not raise any errors + mock_de_model._process_hybridization(hybridization) From c5e8b0b721ebb7f4154ae60b3c84e657d93dc342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Thu, 30 Oct 2025 17:34:02 +0000 Subject: [PATCH 75/92] refactor _initialize_model_with_nominal_values --- python/sdist/amici/jax/petab.py | 360 ++++++++++++------ .../{test_sbml_sciml.py => test_sciml.py} | 6 +- 2 files changed, 242 insertions(+), 124 deletions(-) rename python/tests/{test_sbml_sciml.py => test_sciml.py} (99%) diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index 72499efdeb..d17a519797 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -1,34 +1,32 @@ """PEtab wrappers for JAX models.""" "" import copy +import logging +import re import shutil from collections.abc import Callable, Iterable, Sized from numbers import Number from pathlib import Path -import logging - import diffrax import equinox as eqx +import h5py import jax.lax import jax.numpy as jnp -import jax.tree_util as jtu import jaxtyping as jt import numpy as np import optimistix import pandas as pd import petab.v1 as petab -import h5py -import re from optimistix import AbstractRootFinder from amici import _module_from_path from amici.jax.model import JAXModel, ReturnValue +from amici.logging import get_logger from amici.petab.parameter_mapping import ( ParameterMappingForCondition, create_parameter_mapping, ) -from amici.logging import get_logger DEFAULT_CONTROLLER_SETTINGS = { "atol": 1e-8, @@ -148,7 +146,9 @@ def __init__(self, model: JAXModel, petab_problem: petab.Problem): scs = petab_problem.get_simulation_conditions_from_measurement_df() self.simulation_conditions = tuple(tuple(sc) for sc in scs.values) self._petab_problem = _get_hybrid_petab_problem(petab_problem) - self.parameters, self.model = self._get_nominal_parameter_values(model) + self.parameters, self.model = ( + self._initialize_model_with_nominal_values(model) + ) self._parameter_mappings = self._get_parameter_mappings(scs) ( self._ts_dyn, @@ -527,18 +527,17 @@ def get_all_simulation_conditions(self) -> tuple[tuple[str, ...], ...]: ) return tuple(tuple(row) for _, row in simulation_conditions.iterrows()) - def _get_nominal_parameter_values( - self, model: JAXModel - ) -> tuple[jt.Float[jt.Array, "np"], JAXModel]: + def _initialize_model_parameters(self, model: JAXModel) -> dict: """ - Get the nominal parameter values for the model based on the nominal values in the PEtab problem. - Also set nominal values in the model (where applicable). + Initialize model parameter structure with zeros. + + :param model: + JAX model with neural networks :return: - jax array with nominal parameter values and model with nominal parameter values set. + Nested dictionary structure for model parameters """ - # initialize everything with zeros - model_pars = { + return { net_id: { layer_id: { attribute: jnp.zeros_like(getattr(layer, attribute)) @@ -549,94 +548,154 @@ def _get_nominal_parameter_values( } for net_id, nn in model.nns.items() } - # load nn parameters from file - par_arrays = ( - dict( - [ - ( - file_spec.split("_")[0], - h5py.File(file_spec, "r")["parameters"][ - file_spec.split("_")[0] - ], - ) - for file_spec in self._petab_problem.extensions_config[ - "sciml" - ]["array_files"] - if "parameters" in h5py.File(file_spec, "r").keys() - ] - ) - if self._petab_problem.extensions_config - else {} + + def _load_parameter_arrays_from_files(self) -> dict: + """ + Load neural network parameter arrays from HDF5 files. + + :return: + Dictionary mapping network IDs to parameter arrays + """ + if not self._petab_problem.extensions_config: + return {} + + array_files = self._petab_problem.extensions_config["sciml"].get( + "array_files", [] ) - nn_input_arrays = ( - dict( + return { + file_spec.split("_")[0]: h5py.File(file_spec, "r")["parameters"][ + file_spec.split("_")[0] + ] + for file_spec in array_files + if "parameters" in h5py.File(file_spec, "r").keys() + } + + def _load_input_arrays_from_files(self) -> dict: + """ + Load neural network input arrays from HDF5 files. + + :return: + Dictionary mapping network IDs to input arrays + """ + if not self._petab_problem.extensions_config: + return {} + + array_files = self._petab_problem.extensions_config["sciml"].get( + "array_files", [] + ) + + return { + file_spec.split("_")[0]: h5py.File(file_spec, "r")["inputs"] + for file_spec in array_files + if "inputs" in h5py.File(file_spec, "r").keys() + } + + def _parse_parameter_name( + self, pname: str, model_pars: dict + ) -> list[tuple[str, str]]: + """ + Parse parameter name to determine which layers and attributes to set. + + :param pname: + Parameter name from PEtab (format: net.layer.attribute) + :param model_pars: + Model parameters dictionary + + :return: + List of (layer_name, attribute_name) tuples to set + """ + net = pname.split("_")[0] + nn = model_pars[net] + to_set = [] + + name_parts = pname.split(".") + + if len(name_parts) > 1: + layer_name = name_parts[1] + layer = nn[layer_name] + if len(name_parts) > 2: + # Specific attribute specified + attribute_name = name_parts[2] + to_set.append((layer_name, attribute_name)) + else: + # All attributes of the layer + to_set.extend( + [(layer_name, attribute) for attribute in layer.keys()] + ) + else: + # All layers and attributes + to_set.extend( [ - ( - file_spec.split("_")[0], - h5py.File(file_spec, "r")["inputs"], - ) - for file_spec in self._petab_problem.extensions_config[ - "sciml" - ]["array_files"] - if "inputs" in h5py.File(file_spec, "r").keys() + (layer_name, attribute) + for layer_name, layer in nn.items() + for attribute in layer.keys() ] ) - if self._petab_problem.extensions_config - else {} - ) - # extract nominal values from petab problem + return to_set + + def _extract_nominal_values_from_petab( + self, model: JAXModel, model_pars: dict, par_arrays: dict + ) -> None: + """ + Extract nominal parameter values from PEtab problem and populate model_pars. + + :param model: + JAX model + :param model_pars: + Model parameters dictionary to populate (modified in place) + :param par_arrays: + Parameter arrays loaded from files + """ for pname, row in self._petab_problem.parameter_df.iterrows(): - if (net := pname.split("_")[0]) in model.nns: - to_set = [] - nn = model_pars[net] - scalar = True - - if np.isnan(row[petab.NOMINAL_VALUE]): - value = par_arrays[net] - scalar = False - else: - value = float(row[petab.NOMINAL_VALUE]) - - if len(pname.split(".")) > 1: - layer_name = pname.split(".")[1] - layer = nn[layer_name] - if len(pname.split(".")) > 2: - attribute_name = pname.split(".")[2] - to_set.append((layer_name, attribute_name)) - else: - to_set.extend( - [ - (layer_name, attribute) - for attribute in layer.keys() - ] - ) + net = pname.split("_")[0] + if net not in model.nns: + continue + + nn = model_pars[net] + scalar = True + + # Determine value source (scalar from PEtab or array from file) + if np.isnan(row[petab.NOMINAL_VALUE]): + value = par_arrays[net] + scalar = False + else: + value = float(row[petab.NOMINAL_VALUE]) + + # Parse parameter name and set values + to_set = self._parse_parameter_name(pname, model_pars) + + for layer, attribute in to_set: + if scalar: + nn[layer][attribute] = value * jnp.ones_like( + getattr(model.nns[net].layers[layer], attribute) + ) else: - to_set.extend( - [ - (layer_name, attribute) - for layer_name, layer in nn.items() - for attribute in layer.keys() - ] + nn[layer][attribute] = jnp.array( + value[layer][attribute][:] ) - for layer, attribute in to_set: - if scalar: - nn[layer][attribute] = value * jnp.ones_like( - getattr(model.nns[net].layers[layer], attribute) - ) - else: - nn[layer][attribute] = jnp.array( - value[layer][attribute][:] - ) + def _set_model_parameters( + self, model: JAXModel, model_pars: dict + ) -> JAXModel: + """ + Set parameter values in the model using equinox tree_at. + + :param model: + JAX model to update + :param model_pars: + Dictionary of parameter values to set - # set values in model + :return: + Updated JAX model + """ for net_id in model_pars: for layer_id in model_pars[net_id]: for attribute in model_pars[net_id][layer_id]: logger.debug( - f"Setting {attribute} of layer {layer_id} in network {net_id} to {model_pars[net_id][layer_id][attribute]}" + f"Setting {attribute} of layer {layer_id} in network " + f"{net_id} to {model_pars[net_id][layer_id][attribute]}" ) model = eqx.tree_at( lambda model: getattr( @@ -645,26 +704,53 @@ def _get_nominal_parameter_values( model, model_pars[net_id][layer_id][attribute], ) + return model - # set inputs in the model if provided - if len(nn_input_arrays) > 0: - for net_id in model_pars: - input_array = { - input: { - k: jnp.array( - arr[:], - dtype=jnp.float64 - if jax.config.jax_enable_x64 - else jnp.float32, - ) - for k, arr in nn_input_arrays[net_id][input].items() - } - for input in model.nns[net_id].inputs + def _set_input_arrays( + self, model: JAXModel, nn_input_arrays: dict, model_pars: dict + ) -> JAXModel: + """ + Set input arrays in the model if provided. + + :param model: + JAX model to update + :param nn_input_arrays: + Input arrays loaded from files + :param model_pars: + Model parameters dictionary (for network IDs) + + :return: + Updated JAX model + """ + if len(nn_input_arrays) == 0: + return model + + for net_id in model_pars: + input_array = { + input: { + k: jnp.array( + arr[:], + dtype=jnp.float64 + if jax.config.jax_enable_x64 + else jnp.float32, + ) + for k, arr in nn_input_arrays[net_id][input].items() } - model = eqx.tree_at( - lambda model: model.nns[net_id].inputs, model, input_array - ) + for input in model.nns[net_id].inputs + } + model = eqx.tree_at( + lambda model: model.nns[net_id].inputs, model, input_array + ) + + return model + + def _create_scaled_parameter_array(self) -> jt.Float[jt.Array, "np"]: + """ + Create array of scaled nominal parameter values for estimation. + :return: + JAX array of scaled parameter values + """ return jnp.array( [ petab.scale( @@ -679,7 +765,46 @@ def _get_nominal_parameter_values( ) for pval in self.parameter_ids ] - ), model + ) + + def _initialize_model_with_nominal_values( + self, model: JAXModel + ) -> tuple[jt.Float[jt.Array, "np"], JAXModel]: + """ + Initialize the model with nominal parameter values and inputs from the PEtab problem. + + This method: + - Initializes model parameter structure + - Loads parameter and input arrays from HDF5 files + - Extracts nominal values from PEtab problem + - Sets parameter values in the model + - Sets input arrays in the model + - Creates scaled parameter array to initialized to nominal values + + :param model: + JAX model to initialize + + :return: + Tuple of (scaled parameter array, initialized model) + """ + # Initialize model parameters structure + model_pars = self._initialize_model_parameters(model) + + # Load arrays from files (getters) + par_arrays = self._load_parameter_arrays_from_files() + nn_input_arrays = self._load_input_arrays_from_files() + + # Extract nominal values from PEtab problem + self._extract_nominal_values_from_petab(model, model_pars, par_arrays) + + # Set values in model (setters) + model = self._set_model_parameters(model, model_pars) + model = self._set_input_arrays(model, nn_input_arrays, model_pars) + + # Create scaled parameter array + parameter_array = self._create_scaled_parameter_array() + + return parameter_array, model def _get_inputs(self): if self._petab_problem.mapping_df is None: @@ -820,18 +945,13 @@ def _is_net_input(model_id): else {} ) - hybridization_parameter_map = dict( - [ - ( - petab_id, - self._petab_problem.hybridization_df.loc[ - petab_id, "targetValue" - ], - ) - for petab_id in model_id_map.values() - if petab_id in set(self._petab_problem.hybridization_df.index) + hybridization_parameter_map = { + petab_id: self._petab_problem.hybridization_df.loc[ + petab_id, "targetValue" ] - ) + for petab_id in model_id_map.values() + if petab_id in set(self._petab_problem.hybridization_df.index) + } # handle conditions if len(condition_input_map) > 0: @@ -1018,7 +1138,8 @@ def load_reinitialisation( """ if not any( x_id in self._petab_problem.condition_df - or hasattr(self, "nn_output_ids") and x_id in self.nn_output_ids + or hasattr(self, "nn_output_ids") + and x_id in self.nn_output_ids for x_id in self.model.state_ids ): return jnp.array([]), jnp.array([]) @@ -1291,7 +1412,8 @@ def run_simulations( [ jnp.array( [ - p in set(self._parameter_mappings[sc].map_sim_var.keys()) + p + in set(self._parameter_mappings[sc].map_sim_var.keys()) for p in self.model.state_ids ] ) diff --git a/python/tests/test_sbml_sciml.py b/python/tests/test_sciml.py similarity index 99% rename from python/tests/test_sbml_sciml.py rename to python/tests/test_sciml.py index 41fed2bcaf..13d36c0032 100644 --- a/python/tests/test_sbml_sciml.py +++ b/python/tests/test_sciml.py @@ -1,12 +1,8 @@ """Tests for SBML/SciML functionality, including JAX neural network code generation.""" -import pytest - -pytest.importorskip("jax") -pytest.importorskip("equinox") - from unittest.mock import Mock +import pytest from amici.jax.nn import ( _format_function_call, _generate_forward, From 3a3ec71711e5bf30349e4af526f74c4cffb99455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Fri, 31 Oct 2025 10:03:10 +0000 Subject: [PATCH 76/92] fix doc, canonical spelling --- python/sdist/amici/de_export.py | 6 ++---- python/sdist/amici/de_model.py | 6 ++---- python/sdist/amici/jax/ode_export.py | 14 +++++++++----- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/python/sdist/amici/de_export.py b/python/sdist/amici/de_export.py index 32347d47d2..d87638423d 100644 --- a/python/sdist/amici/de_export.py +++ b/python/sdist/amici/de_export.py @@ -199,10 +199,8 @@ def __init__( name of the model to be used during code generation :param hybridization: - hybridization table for sciml models, briefly assigns model variables - to neural network inputs/outputs and vice versa. See - https://petab-sciml.readthedocs.io/latest/format.html#hybridization-table - for details. + dict representation of the hybridization information in the PEtab YAML file, see + https://petab-sciml.readthedocs.io/latest/format.html#problem-yaml-file """ set_log_level(logger, verbose) diff --git a/python/sdist/amici/de_model.py b/python/sdist/amici/de_model.py index 9936443983..7d8508a658 100644 --- a/python/sdist/amici/de_model.py +++ b/python/sdist/amici/de_model.py @@ -2575,10 +2575,8 @@ def _process_hybridization(self, hybridization: dict) -> None: Parses the hybridization information and updates the model accordingly :param hybridization: - hybridization table for sciml models, briefly assigns model variables - to neural network inputs/outputs and vice versa. See - https://petab-sciml.readthedocs.io/latest/format.html#hybridization-table - for details. + dict representation of the hybridization information in the PEtab YAML file, see + https://petab-sciml.readthedocs.io/latest/format.html#problem-yaml-file """ added_expressions = False orig_obs = tuple([s.get_id() for s in self._observables]) diff --git a/python/sdist/amici/jax/ode_export.py b/python/sdist/amici/jax/ode_export.py index 785a3d4f45..fa8fa259d6 100644 --- a/python/sdist/amici/jax/ode_export.py +++ b/python/sdist/amici/jax/ode_export.py @@ -124,7 +124,7 @@ def __init__( outdir: Path | str | None = None, verbose: bool | int | None = False, model_name: str | None = "model", - hybridisation: dict[str, dict] = None, + hybridization: dict[str, dict] = None, ): """ Generate AMICI jax files for the ODE provided to the constructor. @@ -141,6 +141,10 @@ def __init__( :param model_name: name of the model to be used during code generation + + :param hybridization: + dict representation of the hybridization information in the PEtab YAML file, see + https://petab-sciml.readthedocs.io/latest/format.html#problem-yaml-file """ set_log_level(logger, verbose) @@ -163,7 +167,7 @@ def __init__( self.model: DEModel = ode_model - self.hybridisation = hybridisation if hybridisation is not None else {} + self.hybridization = hybridization if hybridization is not None else {} self._code_printer = AmiciJaxCodePrinter() @@ -268,11 +272,11 @@ def _generate_jax_code(self) -> None: }, "NET_IMPORTS": "\n".join( f"{net} = _module_from_path('{net}', Path(__file__).parent / '{net}.py')" - for net in self.hybridisation.keys() + for net in self.hybridization.keys() ), "NETS": ",\n".join( f'"{net}": {net}.net(jr.PRNGKey(0))' - for net in self.hybridisation.keys() + for net in self.hybridization.keys() ), } @@ -283,7 +287,7 @@ def _generate_jax_code(self) -> None: ) def _generate_nn_code(self) -> None: - for net_name, net in self.hybridisation.items(): + for net_name, net in self.hybridization.items(): generate_equinox( net["model"], self.model_path / f"{net_name}.py", From 775db533dcfdb06c902d58208c98c7a366423b3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Fri, 31 Oct 2025 10:04:03 +0000 Subject: [PATCH 77/92] Update python/sdist/amici/jax/model.py Co-authored-by: Daniel Weindl --- python/sdist/amici/jax/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/sdist/amici/jax/model.py b/python/sdist/amici/jax/model.py index 033092df5c..c2134bf9f6 100644 --- a/python/sdist/amici/jax/model.py +++ b/python/sdist/amici/jax/model.py @@ -593,7 +593,7 @@ def simulate_condition( :param init_override: override model input e.g. with neural net outputs. If not provided, the inputs are not overridden. :param init_override_mask: - mask for input override. If `True`, the corresponding input is replaced with value from init_override. + mask for input override. If `True`, the corresponding input is replaced with the corresponding value from `init_override`. :param ts_mask: mask to remove (padded) time points. If `True`, the corresponding time point is used for the evaluation of the output. Only applied if ret is ReturnValue.llh, ReturnValue.nllhs, ReturnValue.res, or ReturnValue.chi2. From f71f25f9d90c5c89c3d3cc1a9535aa13615cc6ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Fri, 31 Oct 2025 10:04:24 +0000 Subject: [PATCH 78/92] Update python/sdist/amici/jax/nn.py Co-authored-by: Daniel Weindl --- python/sdist/amici/jax/nn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/sdist/amici/jax/nn.py b/python/sdist/amici/jax/nn.py index 5dc3e5f5e9..1de0119fd3 100644 --- a/python/sdist/amici/jax/nn.py +++ b/python/sdist/amici/jax/nn.py @@ -8,7 +8,7 @@ class Flatten(eqx.Module): - """Custom implementation of a torch.flatten layer for Equinox.""" + """Custom implementation of a `torch.flatten` layer for Equinox.""" start_dim: int end_dim: int From 736de03e1b8de9bc7f5dd3cfbe26b991c7b9fac9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Fri, 31 Oct 2025 10:04:47 +0000 Subject: [PATCH 79/92] Update python/sdist/amici/jax/nn.py Co-authored-by: Daniel Weindl --- python/sdist/amici/jax/nn.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/python/sdist/amici/jax/nn.py b/python/sdist/amici/jax/nn.py index 1de0119fd3..7c3b795ae1 100644 --- a/python/sdist/amici/jax/nn.py +++ b/python/sdist/amici/jax/nn.py @@ -46,8 +46,6 @@ def generate_equinox( output filename for generated Equinox model :param frozen_layers: list of layer names to freeze during training - :return: - """ # TODO: move to top level import and replace forward type definitions from petab_sciml import Layer From 01d1f663a53e2fd8b3ff4824ab8f94402a09ac93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Fri, 31 Oct 2025 10:08:48 +0000 Subject: [PATCH 80/92] Update python/sdist/amici/jax/petab.py Co-authored-by: Daniel Weindl --- python/sdist/amici/jax/petab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/sdist/amici/jax/petab.py b/python/sdist/amici/jax/petab.py index d17a519797..bb4a732e16 100644 --- a/python/sdist/amici/jax/petab.py +++ b/python/sdist/amici/jax/petab.py @@ -806,7 +806,7 @@ def _initialize_model_with_nominal_values( return parameter_array, model - def _get_inputs(self): + def _get_inputs(self) -> dict: if self._petab_problem.mapping_df is None: return {} inputs = {net: {} for net in self.model.nns.keys()} From 59e8dce712f427953b53a31589c8a014aedf373d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Fri, 31 Oct 2025 10:09:08 +0000 Subject: [PATCH 81/92] Update python/sdist/amici/petab/petab_import.py Co-authored-by: Daniel Weindl --- python/sdist/amici/petab/petab_import.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/sdist/amici/petab/petab_import.py b/python/sdist/amici/petab/petab_import.py index 38be8cb223..4b2ccb444e 100644 --- a/python/sdist/amici/petab/petab_import.py +++ b/python/sdist/amici/petab/petab_import.py @@ -164,7 +164,7 @@ def import_petab_problem( hybridization = { net_id: { "model": NNModelStandard.load_data( - Path() / net_config["location"] + Path(net_config["location"]) ), "input_vars": [ input_mapping[petab_id] From f1bb8c5bc96be2de872f7a07bd2cc40759b95035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Fri, 31 Oct 2025 10:09:48 +0000 Subject: [PATCH 82/92] Apply suggestion from @dweindl Co-authored-by: Daniel Weindl --- python/sdist/amici/petab/parameter_mapping.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/python/sdist/amici/petab/parameter_mapping.py b/python/sdist/amici/petab/parameter_mapping.py index 67eddf9150..aa3d57f108 100644 --- a/python/sdist/amici/petab/parameter_mapping.py +++ b/python/sdist/amici/petab/parameter_mapping.py @@ -352,11 +352,7 @@ def create_parameter_mapping( if petab_problem.model.type_id == MODEL_TYPE_SBML: import libsbml - # v1 guard - if ( - isinstance(petab_problem, petab.Problem) - and petab_problem.sbml_document - ): + if petab_problem.model.sbml_document: converter_config = ( libsbml.SBMLLocalParameterConverter().getDefaultProperties() ) From b61273d3d72cfa09545e7b454b9ecb22bdbfc2c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Fri, 31 Oct 2025 10:10:38 +0000 Subject: [PATCH 83/92] Apply suggestion from @dweindl Co-authored-by: Daniel Weindl --- python/sdist/amici/petab/util.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/python/sdist/amici/petab/util.py b/python/sdist/amici/petab/util.py index db0d56cf3b..6b8b45844e 100644 --- a/python/sdist/amici/petab/util.py +++ b/python/sdist/amici/petab/util.py @@ -28,13 +28,7 @@ def get_states_in_condition_table( species_check_fun = { MODEL_TYPE_SBML: lambda x: _element_is_sbml_state( - petab_problem.sbml_model, - x, # v1 - ) - if isinstance(petab_problem, petab.Problem) - else lambda x: _element_is_sbml_state( - petab_problem.model.sbml_model, - x, # v2 + petab_problem.model.sbml_model, x ), MODEL_TYPE_PYSB: lambda x: _element_is_pysb_pattern( petab_problem.model.model, x From e9d1baf4f6b5013ed39f31382c7040d1bde1d766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Fri, 31 Oct 2025 10:11:15 +0000 Subject: [PATCH 84/92] Apply suggestion from @dweindl Co-authored-by: Daniel Weindl --- tests/sciml/test_sciml.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/sciml/test_sciml.py b/tests/sciml/test_sciml.py index 3f00a5747d..5af09ad86c 100644 --- a/tests/sciml/test_sciml.py +++ b/tests/sciml/test_sciml.py @@ -64,7 +64,7 @@ def _reshape_flat_array(array_flat): return array -@pytest.mark.parametrize("test", sorted([d.stem for d in net_cases_dir.glob("[0-9]*")])) +@pytest.mark.parametrize("test", sorted(d.stem for d in net_cases_dir.glob("[0-9]*"))) def test_net(test): test_dir = net_cases_dir / test with open(test_dir / "solutions.yaml") as f: From 8411dc6ad0749be408142b27a474a3845b722117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Fri, 31 Oct 2025 10:15:11 +0000 Subject: [PATCH 85/92] pre-commit fixes --- doc/conf.py | 3 +- .../example_jax_petab/ExampleJaxPEtab.ipynb | 8 +- python/sdist/amici/jax/jax.template.py | 2 +- python/sdist/amici/jax/jaxcodeprinter.py | 2 +- python/sdist/amici/jax/nn.template.py | 3 +- python/sdist/amici/petab/petab_import.py | 65 +++++++--------- tests/sciml/test_sciml.py | 77 ++++++++++++------- 7 files changed, 84 insertions(+), 76 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index d5a00ae04e..fa58b48cb0 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -31,10 +31,10 @@ import exhale_multiproject_monkeypatch # noqa: F401 # need to import before setting typing.TYPE_CHECKING=True, fails otherwise + import amici import pandas as pd # noqa: F401 import sympy as sp # noqa: F401 -import warnings def install_doxygen(): @@ -371,6 +371,7 @@ def install_doxygen(): "NNModel": "petab_sciml.NNModel", } + def process_docstring(app, what, name, obj, options, lines): # only apply in the amici.amici module if len(name.split(".")) < 2 or name.split(".")[1] != "amici": diff --git a/doc/examples/example_jax_petab/ExampleJaxPEtab.ipynb b/doc/examples/example_jax_petab/ExampleJaxPEtab.ipynb index 92488992d5..bfd26757d4 100644 --- a/doc/examples/example_jax_petab/ExampleJaxPEtab.ipynb +++ b/doc/examples/example_jax_petab/ExampleJaxPEtab.ipynb @@ -100,9 +100,7 @@ "ic = results[\"simulation_conditions\"].index(simulation_condition)\n", "print(\"llh: \", results[\"llh\"][ic])\n", "print(\"state variables: \", results[\"x\"][ic, :])" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -364,9 +362,7 @@ "outputs": [], "source": [ "grad._my[ic, :]" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", diff --git a/python/sdist/amici/jax/jax.template.py b/python/sdist/amici/jax/jax.template.py index 951963a4d0..b5247d2eab 100644 --- a/python/sdist/amici/jax/jax.template.py +++ b/python/sdist/amici/jax/jax.template.py @@ -9,8 +9,8 @@ from jax.numpy import inf as oo from jax.numpy import nan as nan -from amici.jax.model import JAXModel, safe_log, safe_div from amici import _module_from_path +from amici.jax.model import JAXModel, safe_div, safe_log TPL_NET_IMPORTS diff --git a/python/sdist/amici/jax/jaxcodeprinter.py b/python/sdist/amici/jax/jaxcodeprinter.py index ed022e9fd6..dc50faa3aa 100644 --- a/python/sdist/amici/jax/jaxcodeprinter.py +++ b/python/sdist/amici/jax/jaxcodeprinter.py @@ -5,8 +5,8 @@ from logging import warning import sympy as sp -from sympy.printing.numpy import NumPyPrinter from sympy.core.function import UndefinedFunction +from sympy.printing.numpy import NumPyPrinter def _jnp_array_str(array) -> str: diff --git a/python/sdist/amici/jax/nn.template.py b/python/sdist/amici/jax/nn.template.py index b07a251e64..3e58666a94 100644 --- a/python/sdist/amici/jax/nn.template.py +++ b/python/sdist/amici/jax/nn.template.py @@ -1,8 +1,9 @@ # ruff: noqa: F401, F821, F841 import equinox as eqx +import jax import jax.nn import jax.random as jr -import jax + import amici.jax.nn diff --git a/python/sdist/amici/petab/petab_import.py b/python/sdist/amici/petab/petab_import.py index 4b2ccb444e..32cefb0845 100644 --- a/python/sdist/amici/petab/petab_import.py +++ b/python/sdist/amici/petab/petab_import.py @@ -7,11 +7,10 @@ import logging import os +import re import shutil from pathlib import Path -import re -import amici import pandas as pd import petab.v1 as petab from petab.v1.models import MODEL_TYPE_PYSB, MODEL_TYPE_SBML @@ -180,44 +179,34 @@ def import_petab_problem( if model_id.split(".")[1].startswith("input") and petab_id in input_mapping.keys() ], - "output_vars": dict( - [ - ( - output_mapping[petab_id], - _get_net_index(model_id), - ) - for petab_id, model_id in petab_problem.mapping_df.loc[ - petab_problem.mapping_df[petab.MODEL_ENTITY_ID] - .str.split(".") - .str[0] - == net_id, - petab.MODEL_ENTITY_ID, - ] - .to_dict() - .items() - if model_id.split(".")[1].startswith("output") - and petab_id in output_mapping.keys() + "output_vars": { + output_mapping[petab_id]: _get_net_index(model_id) + for petab_id, model_id in petab_problem.mapping_df.loc[ + petab_problem.mapping_df[petab.MODEL_ENTITY_ID] + .str.split(".") + .str[0] + == net_id, + petab.MODEL_ENTITY_ID, ] - ), - "observable_vars": dict( - [ - ( - observable_mapping[petab_id], - _get_net_index(model_id), - ) - for petab_id, model_id in petab_problem.mapping_df.loc[ - petab_problem.mapping_df[petab.MODEL_ENTITY_ID] - .str.split(".") - .str[0] - == net_id, - petab.MODEL_ENTITY_ID, - ] - .to_dict() - .items() - if model_id.split(".")[1].startswith("output") - and petab_id in observable_mapping.keys() + .to_dict() + .items() + if model_id.split(".")[1].startswith("output") + and petab_id in output_mapping.keys() + }, + "observable_vars": { + observable_mapping[petab_id]: _get_net_index(model_id) + for petab_id, model_id in petab_problem.mapping_df.loc[ + petab_problem.mapping_df[petab.MODEL_ENTITY_ID] + .str.split(".") + .str[0] + == net_id, + petab.MODEL_ENTITY_ID, ] - ), + .to_dict() + .items() + if model_id.split(".")[1].startswith("output") + and petab_id in observable_mapping.keys() + }, "frozen_layers": dict( [ _get_frozen_layers(model_id) diff --git a/tests/sciml/test_sciml.py b/tests/sciml/test_sciml.py index 5af09ad86c..2fcc58532e 100644 --- a/tests/sciml/test_sciml.py +++ b/tests/sciml/test_sciml.py @@ -1,28 +1,27 @@ -from yaml import safe_load -import pytest - +import os +from contextlib import contextmanager from pathlib import Path + +import amici +import diffrax +import equinox as eqx +import h5py +import jax +import jax.numpy as jnp +import jax.random as jr +import numpy as np +import pandas as pd import petab.v1 as petab -from amici.petab import import_petab_problem +import pytest from amici.jax import ( JAXProblem, generate_equinox, - run_simulations, petab_simulate, + run_simulations, ) -import amici -import diffrax -import pandas as pd -import jax.numpy as jnp -import jax.random as jr -import jax -import numpy as np -import equinox as eqx -import os -import h5py -from contextlib import contextmanager - +from amici.petab import import_petab_problem from petab_sciml import NNModelStandard +from yaml import safe_load @contextmanager @@ -50,7 +49,9 @@ def change_directory(destination): def _reshape_flat_array(array_flat): array_flat["ix"] = array_flat["ix"].astype(str) - ix_cols = [f"ix_{i}" for i in range(len(array_flat["ix"].values[0].split(";")))] + ix_cols = [ + f"ix_{i}" for i in range(len(array_flat["ix"].values[0].split(";"))) + ] if len(ix_cols) == 1: array_flat[ix_cols[0]] = array_flat["ix"].apply(int) else: @@ -64,7 +65,9 @@ def _reshape_flat_array(array_flat): return array -@pytest.mark.parametrize("test", sorted(d.stem for d in net_cases_dir.glob("[0-9]*"))) +@pytest.mark.parametrize( + "test", sorted(d.stem for d in net_cases_dir.glob("[0-9]*")) +) def test_net(test): test_dir = net_cases_dir / test with open(test_dir / "solutions.yaml") as f: @@ -108,8 +111,12 @@ def test_net(test): solutions.get("net_ps", solutions["net_input"]), solutions["net_output"], ): - input = h5py.File(test_dir / input_file, "r")["inputs"]["input0"]["data"][:] - output = h5py.File(test_dir / output_file, "r")["outputs"]["output0"]["data"][:] + input = h5py.File(test_dir / input_file, "r")["inputs"]["input0"][ + "data" + ][:] + output = h5py.File(test_dir / output_file, "r")["outputs"]["output0"][ + "data" + ][:] if "net_ps" in solutions: par = h5py.File(test_dir / par_file, "r") @@ -120,10 +127,14 @@ def test_net(test): and hasattr(net.layers[layer], "weight") and net.layers[layer].weight is not None ): - w = par["parameters"][ml_model.nn_model_id][layer]["weight"][:] + w = par["parameters"][ml_model.nn_model_id][layer][ + "weight" + ][:] if isinstance(net.layers[layer], eqx.nn.ConvTranspose): # see FAQ in https://docs.kidger.site/equinox/api/nn/conv/#equinox.nn.ConvTranspose - w = np.flip(w, axis=tuple(range(2, w.ndim))).swapaxes(0, 1) + w = np.flip(w, axis=tuple(range(2, w.ndim))).swapaxes( + 0, 1 + ) assert w.shape == net.layers[layer].weight.shape net = eqx.tree_at( lambda x: x.layers[layer].weight, @@ -135,7 +146,9 @@ def test_net(test): and hasattr(net.layers[layer], "bias") and net.layers[layer].bias is not None ): - b = par["parameters"][ml_model.nn_model_id][layer]["bias"][:] + b = par["parameters"][ml_model.nn_model_id][layer]["bias"][ + : + ] if isinstance( net.layers[layer], eqx.nn.Conv | eqx.nn.ConvTranspose, @@ -168,7 +181,9 @@ def test_net(test): ) -@pytest.mark.parametrize("test", sorted([d.stem for d in ude_cases_dir.glob("[0-9]*")])) +@pytest.mark.parametrize( + "test", sorted([d.stem for d in ude_cases_dir.glob("[0-9]*")]) +) def test_ude(test): test_dir = ude_cases_dir / test with open(test_dir / "petab" / "problem.yaml") as f: @@ -244,9 +259,13 @@ def test_ude(test): ) else: expected = h5py.File(test_dir / file, "r") - for layer_name, layer in jax_problem.model.nns[component].layers.items(): + for layer_name, layer in jax_problem.model.nns[ + component + ].layers.items(): for attribute in dir(layer): - if not isinstance(getattr(layer, attribute), jax.numpy.ndarray): + if not isinstance( + getattr(layer, attribute), jax.numpy.ndarray + ): continue actual = getattr( sllh.model.nns[component].layers[layer_name], attribute @@ -261,7 +280,9 @@ def test_ude(test): ) if ( np.squeeze( - expected["parameters"][component][layer_name][attribute][:] + expected["parameters"][component][layer_name][ + attribute + ][:] ).size == 0 ): From a7bd6ea55ca03fefc1231f867b1b728bb6fad766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Fri, 31 Oct 2025 10:21:17 +0000 Subject: [PATCH 86/92] fixup --- python/sdist/amici/sbml_import.py | 8 ++++++-- python/tests/test_sciml.py | 5 +++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/python/sdist/amici/sbml_import.py b/python/sdist/amici/sbml_import.py index b3738f4e35..b694b0a5df 100644 --- a/python/sdist/amici/sbml_import.py +++ b/python/sdist/amici/sbml_import.py @@ -479,7 +479,11 @@ def sbml2jax( see :attr:`amici.DEModel._simplify` :param cache_simplify: - see :meth:`amici.DEModel.__init__` + see :meth:`amici.DEModel.__init__` + + :param hybridization: + dict representation of the hybridization information in the PEtab YAML file, see + https://petab-sciml.readthedocs.io/latest/format.html#problem-yaml-file """ set_log_level(logger, verbose) @@ -499,7 +503,7 @@ def sbml2jax( model_name=model_name, outdir=output_dir, verbose=verbose, - hybridisation=hybridization, + hybridization=hybridization, ) exporter.generate_model_code() diff --git a/python/tests/test_sciml.py b/python/tests/test_sciml.py index 13d36c0032..cc3c5038e2 100644 --- a/python/tests/test_sciml.py +++ b/python/tests/test_sciml.py @@ -2,6 +2,11 @@ from unittest.mock import Mock +import pytest + +pytest.importorskip("jax") +pytest.importorskip("equinox") + import pytest from amici.jax.nn import ( _format_function_call, From 69069ea8b96d792935a9d14cbdcc746cf914f702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Fri, 31 Oct 2025 10:42:04 +0000 Subject: [PATCH 87/92] update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82181838f8..aa0795adf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,8 @@ See also our [versioning policy](https://amici.readthedocs.io/en/latest/versioni This only works on shared file systems, as the solver state is stored in a temporary HDF5 file. * `amici.ExpData` is now picklable. +* Implemented support for the [PEtab SciML](https://github.com/PEtab-dev/petab_sciml) + extension for the JAX interface. ## v0.X Series From 36722141179625a6406a2ed5aaa4108e91c2ceb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Fri, 31 Oct 2025 11:33:20 +0000 Subject: [PATCH 88/92] update testsuite, add support for cat --- python/sdist/amici/jax/__init__.py | 3 +- python/sdist/amici/jax/nn.py | 86 +++++++++++++++++++++------ python/sdist/amici/jax/nn.template.py | 1 + tests/sciml/testsuite | 2 +- 4 files changed, 73 insertions(+), 19 deletions(-) diff --git a/python/sdist/amici/jax/__init__.py b/python/sdist/amici/jax/__init__.py index f989cfcd19..5c2e24fb31 100644 --- a/python/sdist/amici/jax/__init__.py +++ b/python/sdist/amici/jax/__init__.py @@ -10,7 +10,7 @@ from warnings import warn from amici.jax.model import JAXModel -from amici.jax.nn import Flatten, generate_equinox, tanhshrink +from amici.jax.nn import Flatten, cat, generate_equinox, tanhshrink from amici.jax.petab import ( JAXProblem, ReturnValue, @@ -33,4 +33,5 @@ "petab_simulate", "ReturnValue", "tanhshrink", + "cat", ] diff --git a/python/sdist/amici/jax/nn.py b/python/sdist/amici/jax/nn.py index 7c3b795ae1..0bd16b4655 100644 --- a/python/sdist/amici/jax/nn.py +++ b/python/sdist/amici/jax/nn.py @@ -32,6 +32,27 @@ def tanhshrink(x: jnp.ndarray) -> jnp.ndarray: return x - jnp.tanh(x) +def cat(tensors, axis: int = 0): + """Alias for torch.cat using JAX's concatenate/stack function. + + Handles both regular arrays and zero-dimensional (scalar) arrays by + using stack instead of concatenate for 0D arrays. + + :param tensors: + List of arrays to concatenate + :param axis: + Dimension along which to concatenate (default: 0) + + :return: + Concatenated array + """ + # Check if all tensors are 0-dimensional (scalars) + if all(jnp.ndim(t) == 0 for t in tensors): + # For 0D arrays, use stack instead of concatenate + return jnp.stack(tensors, axis=axis) + return jnp.concatenate(tensors, axis=axis) + + def generate_equinox( nn_model: "NNModel", # noqa: F821 filename: Path | str, @@ -59,6 +80,38 @@ def generate_equinox( layers = {layer.layer_id: layer for layer in nn_model.layers} + # Collect placeholder nodes to determine input handling + placeholder_nodes = [ + node for node in nn_model.forward if node.op == "placeholder" + ] + input_names = [node.name for node in placeholder_nodes] + + # Generate input unpacking line + if len(input_names) == 1: + input_unpack = f"{input_names[0]} = input" + else: + input_unpack = f"{', '.join(input_names)} = input" + + # Generate forward pass lines (excluding placeholder nodes) + forward_lines = [ + _generate_forward( + node, + node_indent, + frozen_layers, + layers.get( + node.target, + Layer(layer_id="dummy", layer_type="Linear"), + ).layer_type, + ) + for node in nn_model.forward + ] + # Filter out empty lines from placeholder processing + forward_lines = [line for line in forward_lines if line] + # Prepend input unpacking + forward_code = f"{' ' * node_indent}{input_unpack}\n" + "\n".join( + forward_lines + ) + tpl_data = { "MODEL_ID": nn_model.nn_model_id, "LAYERS": ",\n".join( @@ -67,20 +120,7 @@ def generate_equinox( for ilayer, layer in enumerate(nn_model.layers) ] )[layer_indent:], - "FORWARD": "\n".join( - [ - _generate_forward( - node, - node_indent, - frozen_layers, - layers.get( - node.target, - Layer(layer_id="dummy", layer_type="Linear"), - ).layer_type, - ) - for node in nn_model.forward - ] - )[node_indent:], + "FORWARD": forward_code[node_indent:], "INPUT": ", ".join([f"'{inp.input_id}'" for inp in nn_model.inputs]), "OUTPUT": ", ".join( [ @@ -282,6 +322,7 @@ def _process_activation_call(node: "Node") -> str: # noqa: F821 "hardswish": "jax.nn.hard_swish", "tanhshrink": "amici.jax.tanhshrink", "softsign": "jax.nn.soft_sign", + "cat": "amici.jax.cat", } # Validate hardtanh parameters @@ -295,6 +336,18 @@ def _process_activation_call(node: "Node") -> str: # noqa: F821 "max_val != 1.0 not supported for hardtanh" ) + # Handle kwarg aliasing for cat (dim -> axis) + if node.target == "cat": + if "dim" in node.kwargs: + node.kwargs["axis"] = node.kwargs.pop("dim") + # Convert list of variable names to proper bracket-enclosed list + if isinstance(node.args[0], list): + # node.args[0] is a list like ['net_input1', 'net_input2'] + # We need to convert it to a single string representing the list: [net_input1, net_input2] + node.args = tuple( + ["[" + ", ".join(node.args[0]) + "]"] + list(node.args[1:]) + ) + return activation_map.get(node.target, f"jax.nn.{node.target}") @@ -322,10 +375,9 @@ def _generate_forward( if frozen_layers is None: frozen_layers = {} - # Handle placeholder nodes + # Handle placeholder nodes - skip individual processing, handled collectively in generate_equinox if node.op == "placeholder": - # TODO: inconsistent target vs name - return f"{' ' * indent}{node.name} = input" + return "" # Handle output nodes if node.op == "output": diff --git a/python/sdist/amici/jax/nn.template.py b/python/sdist/amici/jax/nn.template.py index 3e58666a94..6b20a39f1b 100644 --- a/python/sdist/amici/jax/nn.template.py +++ b/python/sdist/amici/jax/nn.template.py @@ -2,6 +2,7 @@ import equinox as eqx import jax import jax.nn +import jax.numpy as jnp import jax.random as jr import amici.jax.nn diff --git a/tests/sciml/testsuite b/tests/sciml/testsuite index 596cf82a14..fe19aec533 160000 --- a/tests/sciml/testsuite +++ b/tests/sciml/testsuite @@ -1 +1 @@ -Subproject commit 596cf82a145093bb893420d79ea93be5ebfc725b +Subproject commit fe19aec533a4ca8f23a038f86e5e7e35054316ed From ed2bcd3dc30e4425375cc4777230f1d56ef050ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Fri, 31 Oct 2025 16:50:29 +0000 Subject: [PATCH 89/92] remove gitmodule --- .gitmodules | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitmodules b/.gitmodules index c67d8b0014..e69de29bb2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +0,0 @@ -[submodule "tests/sciml/testsuite"] - path = tests/sciml/testsuite - url = https://github.com/sebapersson/petab_sciml_testsuite From a1f1cab85f08723ba4ee466e0ac0f44f086c5307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Fri, 31 Oct 2025 16:51:05 +0000 Subject: [PATCH 90/92] refactor testsuite --- .github/workflows/test_petab_sciml.yml | 8 +- .gitignore | 3 + tests/sciml/test_sciml.py | 109 ++++++++++++++++++++++++- tests/sciml/testsuite | 1 - 4 files changed, 118 insertions(+), 3 deletions(-) delete mode 160000 tests/sciml/testsuite diff --git a/.github/workflows/test_petab_sciml.yml b/.github/workflows/test_petab_sciml.yml index b5f0f9ef57..adf04e2f05 100644 --- a/.github/workflows/test_petab_sciml.yml +++ b/.github/workflows/test_petab_sciml.yml @@ -34,7 +34,13 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 20 - submodules: recursive + + # todo, update after https://github.com/sebapersson/petab_sciml_testsuite/issues/14 is merged + - name: Download PEtab SciML test suite + run: | + git clone --depth 1 --branch main \ + https://github.com/FFroehlich/petab_sciml_testsuite \ + tests/sciml/testsuite - name: Install apt dependencies uses: ./.github/actions/install-apt-dependencies diff --git a/.gitignore b/.gitignore index f21a36f942..1499441dc4 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,9 @@ models/model_calvetti/build/* amici_models/ +# PEtab SciML test suite (downloaded dynamically) +tests/sciml/testsuite/ + simulate_model_*_hdf.m simulate_model_*.m diff --git a/tests/sciml/test_sciml.py b/tests/sciml/test_sciml.py index 2fcc58532e..e77219deed 100644 --- a/tests/sciml/test_sciml.py +++ b/tests/sciml/test_sciml.py @@ -44,7 +44,8 @@ def change_directory(destination): cases_dir = Path(__file__).parent / "testsuite" / "test_cases" net_cases_dir = cases_dir / "net_import" -ude_cases_dir = cases_dir / "hybrid" +ude_cases_dir = cases_dir / "sciml_problem_import" +initialization_cases_dir = cases_dir / "initialization" def _reshape_flat_array(array_flat): @@ -298,3 +299,109 @@ def test_ude(test): atol=solutions["tol_grad_llh"], rtol=solutions["tol_grad_llh"], ) + + +@pytest.mark.parametrize( + "test", sorted([d.stem for d in initialization_cases_dir.glob("[0-9]*")]) +) +def test_initialization(test): + """Test that nominal ML parameters are imported correctly. + + These tests verify that parameter initialization works properly, + especially when a subset of layers in a ML model are frozen. + """ + test_dir = initialization_cases_dir / test + with open(test_dir / "petab" / "problem.yaml") as f: + petab_yaml = safe_load(f) + with open(test_dir / "solutions.yaml") as f: + solutions = safe_load(f) + + with change_directory(test_dir / "petab"): + from petab.v2 import Problem + + petab_yaml["format_version"] = "2.0.0" # TODO: fixme + petab_problem = Problem.from_yaml(petab_yaml) + jax_model = import_petab_problem( + petab_problem, + model_output_dir=Path(__file__).parent + / "models" + / f"initialization_{test}", + compile_=True, + jax=True, + ) + jax_problem = JAXProblem(jax_model, petab_problem) + + # Check that parameters were initialized correctly + # The model is already initialized with nominal values during import_petab_problem + for net_id, param_file in solutions["parameter_files"].items(): + expected = h5py.File(test_dir / param_file, "r") + + # Iterate through all layers in the expected parameters + for layer_name in expected["parameters"][net_id].keys(): + layer = jax_problem.model.nns[net_id].layers[layer_name] + layer_group = expected["parameters"][net_id][layer_name] + + # Check weight parameters + if hasattr(layer, "weight") and layer.weight is not None: + actual_weight = layer.weight + + if "weight" in layer_group: + # Layer has expected weight values in the reference file + expected_weight = layer_group["weight"][:] + + if isinstance(layer, eqx.nn.ConvTranspose): + # see FAQ in https://docs.kidger.site/equinox/api/nn/conv/#equinox.nn.ConvTranspose + actual_weight = np.flip( + actual_weight.swapaxes(0, 1), + axis=tuple(range(2, actual_weight.ndim)), + ) + + np.testing.assert_allclose( + np.squeeze(actual_weight), + np.squeeze(expected_weight), + atol=solutions["tol"], + rtol=solutions["tol"], + err_msg=f"Weight mismatch in {net_id}.{layer_name}", + ) + else: + # Layer has no weight in reference file, should be initialized to 0 + np.testing.assert_allclose( + actual_weight, + 0.0, + atol=solutions["tol"], + rtol=0.0, + err_msg=f"Weight should be zero in {net_id}.{layer_name}", + ) + + # Check bias parameters + if hasattr(layer, "bias") and layer.bias is not None: + actual_bias = layer.bias + + if "bias" in layer_group: + # Layer has expected bias values in the reference file + expected_bias = layer_group["bias"][:] + + if isinstance(layer, eqx.nn.Conv | eqx.nn.ConvTranspose): + expected_bias = np.expand_dims( + expected_bias, + tuple(range(1, layer.num_spatial_dims + 1)), + ) + + np.testing.assert_allclose( + np.squeeze(actual_bias), + np.squeeze(expected_bias), + atol=solutions["tol"], + rtol=solutions["tol"], + err_msg=f"Bias mismatch in {net_id}.{layer_name}", + ) + else: + # Layer has no bias in reference file, should be initialized to 0 + np.testing.assert_allclose( + actual_bias, + 0.0, + atol=solutions["tol"], + rtol=0.0, + err_msg=f"Bias should be zero in {net_id}.{layer_name}", + ) + + expected.close() diff --git a/tests/sciml/testsuite b/tests/sciml/testsuite deleted file mode 160000 index fe19aec533..0000000000 --- a/tests/sciml/testsuite +++ /dev/null @@ -1 +0,0 @@ -Subproject commit fe19aec533a4ca8f23a038f86e5e7e35054316ed From f0b3c7e662394fc3decfdbf861435ff3dffe9155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Fri, 31 Oct 2025 20:30:32 +0000 Subject: [PATCH 91/92] remove initialization tests --- tests/sciml/test_sciml.py | 106 -------------------------------------- 1 file changed, 106 deletions(-) diff --git a/tests/sciml/test_sciml.py b/tests/sciml/test_sciml.py index e77219deed..ae541b0d36 100644 --- a/tests/sciml/test_sciml.py +++ b/tests/sciml/test_sciml.py @@ -299,109 +299,3 @@ def test_ude(test): atol=solutions["tol_grad_llh"], rtol=solutions["tol_grad_llh"], ) - - -@pytest.mark.parametrize( - "test", sorted([d.stem for d in initialization_cases_dir.glob("[0-9]*")]) -) -def test_initialization(test): - """Test that nominal ML parameters are imported correctly. - - These tests verify that parameter initialization works properly, - especially when a subset of layers in a ML model are frozen. - """ - test_dir = initialization_cases_dir / test - with open(test_dir / "petab" / "problem.yaml") as f: - petab_yaml = safe_load(f) - with open(test_dir / "solutions.yaml") as f: - solutions = safe_load(f) - - with change_directory(test_dir / "petab"): - from petab.v2 import Problem - - petab_yaml["format_version"] = "2.0.0" # TODO: fixme - petab_problem = Problem.from_yaml(petab_yaml) - jax_model = import_petab_problem( - petab_problem, - model_output_dir=Path(__file__).parent - / "models" - / f"initialization_{test}", - compile_=True, - jax=True, - ) - jax_problem = JAXProblem(jax_model, petab_problem) - - # Check that parameters were initialized correctly - # The model is already initialized with nominal values during import_petab_problem - for net_id, param_file in solutions["parameter_files"].items(): - expected = h5py.File(test_dir / param_file, "r") - - # Iterate through all layers in the expected parameters - for layer_name in expected["parameters"][net_id].keys(): - layer = jax_problem.model.nns[net_id].layers[layer_name] - layer_group = expected["parameters"][net_id][layer_name] - - # Check weight parameters - if hasattr(layer, "weight") and layer.weight is not None: - actual_weight = layer.weight - - if "weight" in layer_group: - # Layer has expected weight values in the reference file - expected_weight = layer_group["weight"][:] - - if isinstance(layer, eqx.nn.ConvTranspose): - # see FAQ in https://docs.kidger.site/equinox/api/nn/conv/#equinox.nn.ConvTranspose - actual_weight = np.flip( - actual_weight.swapaxes(0, 1), - axis=tuple(range(2, actual_weight.ndim)), - ) - - np.testing.assert_allclose( - np.squeeze(actual_weight), - np.squeeze(expected_weight), - atol=solutions["tol"], - rtol=solutions["tol"], - err_msg=f"Weight mismatch in {net_id}.{layer_name}", - ) - else: - # Layer has no weight in reference file, should be initialized to 0 - np.testing.assert_allclose( - actual_weight, - 0.0, - atol=solutions["tol"], - rtol=0.0, - err_msg=f"Weight should be zero in {net_id}.{layer_name}", - ) - - # Check bias parameters - if hasattr(layer, "bias") and layer.bias is not None: - actual_bias = layer.bias - - if "bias" in layer_group: - # Layer has expected bias values in the reference file - expected_bias = layer_group["bias"][:] - - if isinstance(layer, eqx.nn.Conv | eqx.nn.ConvTranspose): - expected_bias = np.expand_dims( - expected_bias, - tuple(range(1, layer.num_spatial_dims + 1)), - ) - - np.testing.assert_allclose( - np.squeeze(actual_bias), - np.squeeze(expected_bias), - atol=solutions["tol"], - rtol=solutions["tol"], - err_msg=f"Bias mismatch in {net_id}.{layer_name}", - ) - else: - # Layer has no bias in reference file, should be initialized to 0 - np.testing.assert_allclose( - actual_bias, - 0.0, - atol=solutions["tol"], - rtol=0.0, - err_msg=f"Bias should be zero in {net_id}.{layer_name}", - ) - - expected.close() From 675cee957b40bc05be48adb2a33bc155b0eb23ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Fri, 31 Oct 2025 20:32:56 +0000 Subject: [PATCH 92/92] Update test_sciml.py --- python/tests/test_sciml.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/tests/test_sciml.py b/python/tests/test_sciml.py index cc3c5038e2..5756abe1e9 100644 --- a/python/tests/test_sciml.py +++ b/python/tests/test_sciml.py @@ -269,7 +269,7 @@ def test_placeholder_node(self): node.name = "input_x" result = _generate_forward(node, indent=4) - assert result == " input_x = input" + assert result == "" def test_output_node(self): """Test generation for output nodes."""