AMICI Python example “splines”

This is an example showing how to add spline assignment rules to a pre-existing SBML model.

Utility functions

[1]:
import os
import sys
from importlib import import_module
from shutil import rmtree
from tempfile import TemporaryDirectory
from uuid import uuid1

import matplotlib as mpl
import numpy as np
import sympy as sp
from matplotlib import pyplot as plt

import amici

# Choose build directory
BUILD_PATH = None  # temporary folder
# BUILD_PATH = 'build' # specified folder for debugging
if BUILD_PATH is not None:
    # Remove previous models
    rmtree(BUILD_PATH, ignore_errors=True)
    os.mkdir(BUILD_PATH)


def simulate(sbml_model, parameters=None, *, model_name=None, **kwargs):
    if model_name is None:
        model_name = "model_" + uuid1().hex
    if BUILD_PATH is None:
        with TemporaryDirectory() as build_dir:
            return _simulate(
                sbml_model,
                parameters,
                build_dir=build_dir,
                model_name=model_name,
                **kwargs
            )
    else:
        build_dir = os.path.join(BUILD_PATH, model_name)
        rmtree(build_dir, ignore_errors=True)
        return _simulate(
            sbml_model,
            parameters,
            build_dir=build_dir,
            model_name=model_name,
            **kwargs
        )


def _simulate(
    sbml_model,
    parameters,
    *,
    build_dir,
    model_name,
    T=1,
    discard_annotations=False,
    plot=True
):
    if parameters is None:
        parameters = {}
    # Build the model module from the SBML file
    sbml_importer = amici.SbmlImporter(
        sbml_model, discard_annotations=discard_annotations
    )
    sbml_importer.sbml2amici(model_name, build_dir)
    # Import the model module
    sys.path.insert(0, os.path.abspath(build_dir))
    model_module = import_module(model_name)
    # Setup simulation timepoints and parameters
    model = model_module.getModel()
    for name, value in parameters.items():
        model.setParameterByName(name, value)
    if isinstance(T, (int, float)):
        T = np.linspace(0, T, 100)
    model.setTimepoints([float(t) for t in T])
    solver = model.getSolver()
    solver.setSensitivityOrder(amici.SensitivityOrder.first)
    solver.setSensitivityMethod(amici.SensitivityMethod.forward)
    # Simulate
    rdata = amici.runAmiciSimulation(model, solver)
    # Plot results
    if plot:
        fig, ax = plt.subplots()
        ax.plot(rdata["t"], rdata["x"])
        ax.set_xlabel("time")
        ax.set_ylabel("concentration")
    return model, rdata

A simple SBML model

Let us consider the following SBML model:

<?xml version="1.0" encoding="UTF-8"?>
<sbml xmlns="http://www.sbml.org/sbml/level2/version5" level="2" version="5">
  <model id="example_splines">
    <listOfCompartments>
      <compartment id="compartment" size="1"/>
    </listOfCompartments>
    <listOfSpecies>
      <species id="x" compartment="compartment" initialAmount="0"/>
    </listOfSpecies>
    <listOfParameters>
      <parameter id="f" constant="false"/>
    </listOfParameters>
    <listOfRules>
      <rateRule variable="x">
        <math xmlns="http://www.w3.org/1998/Math/MathML">
          <ci> f </ci>
        </math>
      </rateRule>
    </listOfRules>
  </model>
</sbml>

This model corresponds to the simple ODE \(\dot{x} = f\) for a species \(x\) and a parameter \(f\).

We can easily import and simulate this model in AMICI.

[2]:
simulate("example_splines.xml", dict(f=1));
_images/ExampleSplines_7_0.png

Adding a simple spline

Instead of using a constant parameter \(f\), we want to use a smooth time-dependent function \(f(t)\) whose value is known only at a finite number of time instants. The value of \(f(t)\) outside such grid points needs to be smoothly interpolated. Several methods have been developed for this problem over the years; AMICI at the moment supports only cubic Hermite splines.

We can add a spline function to an existing SBML model with the following code. The resulting time-dependent parameter \(f(t)\) will assume values \((1, -0.5, 2)\) at the equally spaced points \((0, 0.5, 1)\) and smoothly vary elsewhere.

AMICI encodes the spline as a SBML assignment rule for the parameter \(f\). Such a rule consists of a piecewise-polynomial formula which can be interpreted in any SBML-compliant software. However, such very complex formulas are computationally inefficient; e.g., in AMICI they lead to very long model creation times. To solve such problem the code below adds AMICI-specific SBML annotations to the assignment rule which can be used by AMICI to recreate the correct interpolant without reading the inefficient piecewise formula.

[3]:
# Create a spline object
spline = amici.splines.CubicHermiteSpline(
    sbml_id="f",
    evaluate_at=amici.sbml_utils.amici_time_symbol,  # the spline function is evaluated at the current time point
    nodes=amici.splines.UniformGrid(0, 1, number_of_nodes=3),
    values_at_nodes=[1, -1, 2],
)
[4]:
# This spline object can be evaluated at any point
# and so can its derivative/integral
print(f"spline value at 0.3 = {spline.evaluate(0.3)}")
print(f"spline derivative at 0.3 = {spline.derivative(0.3)}")
print(f"spline integral between 0 and 1 = {spline.integrate(0.0, 1.0)}")
spline value at 0.3 = -0.560000000000000
spline derivative at 0.3 = -4.60000000000000
spline integral between 0 and 1 = 0.0416666666666672
[5]:
# Plot the spline
spline.plot(xlabel="time");
_images/ExampleSplines_14_0.png
[6]:
# Load SBML model using libsbml
import libsbml

sbml_doc = libsbml.SBMLReader().readSBML("example_splines.xml")
sbml_model = sbml_doc.getModel()
# We can add the spline assignment rule to the SBML model
spline.add_to_sbml_model(sbml_model)
[7]:
# Finally, we can simulate it in AMICI
model, rdata = simulate(sbml_model);
_images/ExampleSplines_16_0.png
[8]:
# Final value should be equal to the integral computed above
assert np.allclose(rdata["x"][-1], float(spline.integrate(0.0, 1.0)))

The following is the SBML code for the above model

<?xml version="1.0" encoding="UTF-8"?>
<sbml xmlns="http://www.sbml.org/sbml/level2/version5" level="2" version="5">
  <model id="example_splines">
    <listOfCompartments>
      <compartment id="compartment" size="1"/>
    </listOfCompartments>
    <listOfSpecies>
      <species id="x" compartment="compartment" initialAmount="0"/>
    </listOfSpecies>
    <listOfParameters>
      <parameter id="f" constant="false"/>
    </listOfParameters>
    <listOfRules>
      <rateRule variable="x">
        <math xmlns="http://www.w3.org/1998/Math/MathML">
          <ci> f </ci>
        </math>
      </rateRule>
      <assignmentRule variable="f">
        <annotation>
          <amici:spline xmlns:amici="https://github.com/AMICI-dev/AMICI" amici:spline_method="cubic_hermite">
            <amici:spline_evaluation_point>
              <math xmlns="http://www.w3.org/1998/Math/MathML">
                <csymbol encoding="text" definitionURL="http://www.sbml.org/sbml/symbols/time"> time </csymbol>
              </math>
            </amici:spline_evaluation_point>
            <amici:spline_uniform_grid>
              <math xmlns="http://www.w3.org/1998/Math/MathML">
                <cn>0</cn>
              </math>
              <math xmlns="http://www.w3.org/1998/Math/MathML">
                <cn>1</cn>
              </math>
              <math xmlns="http://www.w3.org/1998/Math/MathML">
                <apply>
                  <divide/>
                  <cn>1</cn>
                  <cn>2</cn>
                </apply>
              </math>
            </amici:spline_uniform_grid>
            <amici:spline_values>
              <math xmlns="http://www.w3.org/1998/Math/MathML">
                <cn>1</cn>
              </math>
              <math xmlns="http://www.w3.org/1998/Math/MathML">
                <cn>-1</cn>
              </math>
              <math xmlns="http://www.w3.org/1998/Math/MathML">
                <cn>2</cn>
              </math>
            </amici:spline_values>
          </amici:spline>
        </annotation>
        <math xmlns="http://www.w3.org/1998/Math/MathML">
          <piecewise>
            ... piecewise representation of the spline ...
          </piecewise>
        </math>
      </assignmentRule>
    </listOfRules>
  </model>
</sbml>

The spline annotation on its own can be accessed by

[9]:
print(spline.amici_annotation)
<amici:spline xmlns:amici="https://github.com/AMICI-dev/AMICI" amici:spline_method="cubic_hermite">
        <amici:spline_evaluation_point>
                <math xmlns="http://www.w3.org/1998/Math/MathML">
                        <csymbol encoding="text" definitionURL="http://www.sbml.org/sbml/symbols/time"> time </csymbol>
                </math>
        </amici:spline_evaluation_point>
        <amici:spline_uniform_grid>
                <math xmlns="http://www.w3.org/1998/Math/MathML">
                        <cn>0</cn>
                </math>
                <math xmlns="http://www.w3.org/1998/Math/MathML">
                        <cn>1</cn>
                </math>
                <math xmlns="http://www.w3.org/1998/Math/MathML">
                        <apply>
                                <divide/>
                                <cn>1</cn>
                                <cn>2</cn>
                        </apply>
                </math>
        </amici:spline_uniform_grid>
        <amici:spline_values>
                <math xmlns="http://www.w3.org/1998/Math/MathML">
                        <cn>1</cn>
                </math>
                <math xmlns="http://www.w3.org/1998/Math/MathML">
                        <cn>-1</cn>
                </math>
                <math xmlns="http://www.w3.org/1998/Math/MathML">
                        <cn>2</cn>
                </math>
        </amici:spline_values>
</amici:spline>

Splines can be parametrized

Instead of constant values, SBML parameters can be used as spline values. These can also be automatically added to the model when adding the assignment rule.

[10]:
spline = amici.splines.CubicHermiteSpline(
    sbml_id="f",
    evaluate_at=amici.sbml_utils.amici_time_symbol,
    nodes=amici.splines.UniformGrid(0, 1, number_of_nodes=3),
    values_at_nodes=sp.symbols("f0:3"),
)
[11]:
sbml_doc = libsbml.SBMLReader().readSBML("example_splines.xml")
sbml_model = sbml_doc.getModel()
spline.add_to_sbml_model(
    sbml_model,
    auto_add=True,
    y_nominal=[1, -0.5, 2],
)
[12]:
parameters = dict(f0=-2, f1=1, f2=-2)
[13]:
spline.plot(parameters, xlabel="time");
_images/ExampleSplines_26_0.png
[14]:
model, rdata = simulate(sbml_model, parameters)
_images/ExampleSplines_27_0.png
[15]:
# Sensitivities with respect to the spline values can be computed
fig, ax = plt.subplots()
ax.plot(rdata["t"], rdata.sx[:, 0], label=model.getParameterNames()[0])
ax.plot(rdata["t"], rdata.sx[:, 1], label=model.getParameterNames()[1])
ax.plot(rdata["t"], rdata.sx[:, 2], label=model.getParameterNames()[2])
ax.set_xlabel("time")
ax.set_ylabel("sensitivity")
ax.legend();
_images/ExampleSplines_28_0.png

Specifying derivatives, boundary conditions and extrapolation methods

When derivatives are not specified in the CubicHermiteSpline constructor, they are computed automatically using finite differences and according to the boundary conditions. If their form is known a priori (e.g., they are known constants or functions of parameters), they can be passed explicitly to the spline constructor.

[16]:
# A simple spline for which finite differencing would give a different result
spline = amici.splines.CubicHermiteSpline(
    sbml_id="f",
    evaluate_at=amici.sbml_utils.amici_time_symbol,
    nodes=amici.splines.UniformGrid(0, 1, number_of_nodes=3),
    values_at_nodes=[1.0, -1.0, 1.0],
    derivatives_at_nodes=[10.0, -10.0, -10.0],
)
[17]:
spline.plot(xlabel="time");
_images/ExampleSplines_32_0.png
[18]:
# Simulation
sbml_doc = libsbml.SBMLReader().readSBML("example_splines.xml")
sbml_model = sbml_doc.getModel()
spline.add_to_sbml_model(sbml_model)
simulate(sbml_model, T=1);
_images/ExampleSplines_33_0.png

The spline annotation in this case is

<amici:spline xmlns:amici="https://github.com/AMICI-dev/AMICI" amici:spline_method="cubic_hermite">
    <amici:spline_evaluation_point> ... </amici:spline_evaluation_point>
    <amici:spline_uniform_grid> ... </amici:spline_uniform_grid>
    <amici:spline_values> ... </amici:spline_values>
    <amici:spline_derivatives>
        <math xmlns="http://www.w3.org/1998/Math/MathML">
            <cn>10</cn>
        </math>
        <math xmlns="http://www.w3.org/1998/Math/MathML">
            <cn>-10</cn>
        </math>
        <math xmlns="http://www.w3.org/1998/Math/MathML">
            <cn>-10</cn>
        </math>
    </amici:spline_derivatives>
</amici:spline>

The default boundary conditions depend on the extrapolation method (which defaults to no extrapolation). For example, below we have a spline with constant extrapolation.

[19]:
spline = amici.splines.CubicHermiteSpline(
    sbml_id="f",
    evaluate_at=amici.sbml_utils.amici_time_symbol,
    nodes=amici.splines.UniformGrid(0, 1, number_of_nodes=3),
    values_at_nodes=[-2, 1, -1],
    extrapolate=(
        None,
        "constant",
    ),  # no extrapolation required on the left side
)
[20]:
spline.plot(xlabel="time", xlim=(0, 1.5));
_images/ExampleSplines_37_0.png
[21]:
sbml_doc = libsbml.SBMLReader().readSBML("example_splines.xml")
sbml_model = sbml_doc.getModel()
spline.add_to_sbml_model(sbml_model)
simulate(sbml_model, T=1.5);
_images/ExampleSplines_38_0.png

The spline annotation in this case is

<amici:spline xmlns:amici="https://github.com/AMICI-dev/AMICI" amici:spline_method="cubic_hermite" amici:spline_bc="(no_bc, zeroderivative)" amici:spline_extrapolate="(no_extrapolation, constant)">
    <amici:spline_evaluation_point> ... </amici:spline_evaluation_point>
    <amici:spline_uniform_grid> ... </amici:spline_uniform_grid>
    <amici:spline_values> ... </amici:spline_values>
</amici:spline>

And here we have a periodic spline.

[22]:
spline = amici.splines.CubicHermiteSpline(
    sbml_id="f",
    evaluate_at=amici.sbml_utils.amici_time_symbol,
    nodes=amici.splines.UniformGrid(0, 1, number_of_nodes=3),
    values_at_nodes=[-2, 1, -2],  # first and last node must coincide
    extrapolate="periodic",
)
[23]:
spline.plot(xlabel="time", xlim=(0, 3));
_images/ExampleSplines_43_0.png
[24]:
sbml_doc = libsbml.SBMLReader().readSBML("example_splines.xml")
sbml_model = sbml_doc.getModel()
spline.add_to_sbml_model(sbml_model)
simulate(sbml_model, T=3);
_images/ExampleSplines_44_0.png

The spline annotation in this case is

<amici:spline xmlns:amici="https://github.com/AMICI-dev/AMICI" amici:spline_method="cubic_hermite" amici:spline_bc="periodic" amici:spline_extrapolate="periodic">
    <amici:spline_evaluation_point> ... </amici:spline_evaluation_point>
    <amici:spline_uniform_grid> ... </amici:spline_uniform_grid>
    <amici:spline_values> ... </amici:spline_values>
</amici:spline>

We can modify the spline’s boundary conditions, for example requiring that the derivatives is zero.

[25]:
spline = amici.splines.CubicHermiteSpline(
    sbml_id="f",
    evaluate_at=amici.sbml_utils.amici_time_symbol,
    nodes=amici.splines.UniformGrid(0, 1, number_of_nodes=4),
    values_at_nodes=[-1, 2, 4, 2],
    bc="zeroderivative",
)
[26]:
spline.plot(xlabel="time");
_images/ExampleSplines_49_0.png
<amici:spline xmlns:amici="https://github.com/AMICI-dev/AMICI" amici:spline_method="cubic_hermite" amici:spline_bc="zeroderivative">
    <amici:spline_evaluation_point> ... </amici:spline_evaluation_point>
    <amici:spline_uniform_grid> ... </amici:spline_uniform_grid>
    <amici:spline_values> ... </amici:spline_values>
</amici:spline>

Or we can impose natural boundary conditions.

[27]:
spline = amici.splines.CubicHermiteSpline(
    sbml_id="f",
    evaluate_at=amici.sbml_utils.amici_time_symbol,
    nodes=amici.splines.UniformGrid(0, 1, number_of_nodes=4),
    values_at_nodes=[-1, 2, 4, 2],
    bc="natural",
)
[28]:
spline.plot(xlabel="time");
_images/ExampleSplines_53_0.png
<amici:spline xmlns:amici="https://github.com/AMICI-dev/AMICI" amici:spline_method="cubic_hermite" amici:spline_bc="natural">
    <amici:spline_evaluation_point> ... </amici:spline_evaluation_point>
    <amici:spline_uniform_grid> ... </amici:spline_uniform_grid>
    <amici:spline_values> ... </amici:spline_values>
</amici:spline>

Even if all node values are positive, due to under-shooting a cubic Hermite spline can assume negative values. In certain settings (e.g., when the spline represents a chemical reaction rate) this should be avoided. A possible solution is to carry out the interpolation in log-space (the resulting function is no longer a spline, but it is still a smooth interpolant).

[29]:
spline = amici.splines.CubicHermiteSpline(
    sbml_id="f",
    evaluate_at=amici.sbml_utils.amici_time_symbol,
    nodes=amici.splines.UniformGrid(0, 1, number_of_nodes=5),
    values_at_nodes=[2, 0.05, 0.1, 2, 1],
)
[30]:
# This spline assumes negative values!
spline.plot(xlabel="time");
_images/ExampleSplines_58_0.png
[31]:
spline = amici.splines.CubicHermiteSpline(
    sbml_id="f",
    evaluate_at=amici.sbml_utils.amici_time_symbol,
    nodes=amici.splines.UniformGrid(0, 1, number_of_nodes=5),
    values_at_nodes=[2, 0.05, 0.1, 2, 1],
    logarithmic_parametrization=True,
)
[33]:
# Instead of under-shooting we now have over-shooting,
# but at least the "spline" is always positive
spline.plot(xlabel="time");
_images/ExampleSplines_60_0.png

The spline annotation in this case is

<amici:spline xmlns:amici="https://github.com/AMICI-dev/AMICI" amici:spline_method="cubic_hermite" amici:spline_logarithmic_parametrization="true">
    <amici:spline_evaluation_point> ... </amici:spline_evaluation_point>
    <amici:spline_uniform_grid> ... </amici:spline_uniform_grid>
    <amici:spline_values> ... </amici:spline_values>
</amici:spline>

Comparing model import time for the SBML-native piecewise implementation and the AMICI spline implementation

[33]:
import pandas as pd
import tempfile
import time
[34]:
nruns = 6  # number of replicates
num_nodes = [
    5,
    10,
    15,
    20,
    25,
    30,
    40,
]  # benchmark model import for these node numbers
amici_only_nodes = [
    50,
    75,
    100,
    125,
    150,
    175,
    200,
    225,
    250,
]  # for these node numbers, only benchmark the annotation-based implementation
[35]:
# If running as a GitHub action, just do the minimal amount of work required to check whether the code is working
if os.getenv("GITHUB_ACTIONS") is not None:
    nruns = 1
    num_nodes = [4]
    amici_only_nodes = [5]
[36]:
df = None
for n in num_nodes + amici_only_nodes:
    # Create model
    spline = amici.splines.CubicHermiteSpline(
        sbml_id="f",
        evaluate_at=amici.sbml_utils.amici_time_symbol,
        nodes=amici.splines.UniformGrid(0, 1, number_of_nodes=n),
        values_at_nodes=np.random.rand(n),
    )
    sbml_doc = libsbml.SBMLReader().readSBML("example_splines.xml")
    sbml_model = sbml_doc.getModel()
    spline.add_to_sbml_model(sbml_model)
    # Benchmark model creation
    timings_amici = []
    timings_piecewise = []
    for _ in range(nruns):
        with tempfile.TemporaryDirectory() as tmpdir:
            t0 = time.perf_counter_ns()
            amici.SbmlImporter(sbml_model).sbml2amici("benchmark", tmpdir)
            dt = time.perf_counter_ns() - t0
            timings_amici.append(dt / 1e9)
        if n in num_nodes:
            with tempfile.TemporaryDirectory() as tmpdir:
                t0 = time.perf_counter_ns()
                amici.SbmlImporter(
                    sbml_model, discard_annotations=True
                ).sbml2amici("benchmark", tmpdir)
                dt = time.perf_counter_ns() - t0
                timings_piecewise.append(dt / 1e9)
    # Append benchmark data to dataframe
    df_amici = pd.DataFrame(
        dict(num_nodes=n, time=timings_amici, use_annotations=True)
    )
    df_piecewise = pd.DataFrame(
        dict(num_nodes=n, time=timings_piecewise, use_annotations=False)
    )
    if df is None:
        df = pd.concat(
            [df_amici, df_piecewise], ignore_index=True, verify_integrity=True
        )
    else:
        df = pd.concat(
            [df, df_amici, df_piecewise],
            ignore_index=True,
            verify_integrity=True,
        )
[88]:
kwargs = dict(markersize=7.5)
df_avg = df.groupby(["use_annotations", "num_nodes"]).mean().reset_index()
fig, ax = plt.subplots(1, 1, figsize=(6.5, 3.5))
ax.plot(
    df_avg[np.logical_not(df_avg["use_annotations"])]["num_nodes"],
    df_avg[np.logical_not(df_avg["use_annotations"])]["time"],
    ".",
    label="MathML piecewise",
    **kwargs,
)
ax.plot(
    df_avg[df_avg["use_annotations"]]["num_nodes"],
    df_avg[df_avg["use_annotations"]]["time"],
    ".",
    label="AMICI annotations",
    **kwargs,
)
ax.set_ylabel("model import time (s)")
ax.set_xlabel("number of spline nodes")
ax.set_yscale("log")
ax.yaxis.set_major_formatter(
    mpl.ticker.FuncFormatter(lambda x, pos: f"{x:.0f}")
)
ax.xaxis.set_ticks(
    [
        10,
        20,
        30,
        40,
        60,
        70,
        80,
        90,
        110,
        120,
        130,
        140,
        160,
        170,
        180,
        190,
        210,
        220,
        230,
        240,
        260,
    ],
    minor=True,
)
ax.yaxis.set_ticks(
    [20, 30, 40, 50, 60, 70, 80, 90, 200, 300, 400],
    ["20", "30", "40", "50", None, None, None, None, "200", "300", "400"],
    minor=True,
)
ax.legend()
ax.figure.tight_layout()
# ax.figure.savefig('benchmark_import.pdf')
_images/ExampleSplines_67_0.png