Writing a model

This example shows you how to write models (or model wrappers) that can be used with Pints.

Pints is intended to work with a wide range of models, and assumes as little as possible about the model's form. Specifically, a "model" in Pints is anything that implements the ForwardModel interface. In other words, it's anything that can take a parameter vector $(\boldsymbol{\theta})$ and a sequence of times $(\mathbf{t})$ as an input, and then return a vector of simulated values $(\mathbf{y})$:

$$f(\boldsymbol{\theta}, \mathbf{t}) \rightarrow \mathbf{y}$$

This might not match well with other ideas of a "model". For example, if you're modelling something using ODEs, Pints will regard the combination of the ODEs and an ODE solver as a "forward model".

In the example below, we define a system of ODEs (modelling a simple chemical reaction) and use SciPy to solve it. We then wrap everything in a pints.ForwardModel class, and use a Pints optimisation to find the best matching parameters.

Solving a system of ODEs

First, we define an ODE. In this example we'll use a model of a reversible chemical reaction:

$$\dot{y}(t) = k_1 (1 - y) - k_2 y,$$

where $k_1$ represents a forward reaction rate, $k_2$ is the backward reaction rate, and $y$ represents the concentration of a chemical solute.

Next, we write a Python function r that represents the right-hand side of this ODE, choose a set of parameters and initial conditions, and solve using SciPy.


In [5]:
from __future__ import print_function
import numpy as np
import matplotlib.pyplot as plt
from scipy.integrate import odeint

# Define the right-hand side of a system of ODEs
def r(y, t, p):
    k1 = p[0] # Forward reaction rate
    k2 = p[1] # Backward reaction rate
    dydt = k1 * (1 - y) - k2 * y
    return dydt

# Run an example simulation
p = [5, 3]    # parameters
y0 = 0.1      # initial conditions

# Call odeint, with the parameters wrapped in a tuple
times = np.linspace(0, 1, 1000)
values = odeint(r, y0, times, (p,))

# Reshape odeint's (1000x1) output to (1000,)
values = values.reshape((1000,))

# Plot the results
plt.figure()
plt.xlabel('Time')
plt.ylabel('Concentration')
plt.plot(times, values)
plt.show()


Writing a wrapper class for Pints

Now we'll wrap the model in a class that extends pints.ForwardModel.

It should have two methods: one that runs simulations, and one that tells Pints what the dimension of the model's parameter vector is.


In [2]:
import pints

class ExampleModel(pints.ForwardModel):
    
    def simulate(self, parameters, times):
        # Run a simulation with the given parameters for the
        # given times and return the simulated values
        y0 = 0.1
        def r(y, t, p):
            dydt = (1 - y) * p[0] - y * p[1]
            return dydt
        return odeint(r, y0, times, (parameters,)).reshape(times.shape)
    
    def n_parameters(self):
        # Return the dimension of the parameter vector
        return 2


# Then create an instance of our new model class
model = ExampleModel()

We can now use this model class to run simulations:


In [3]:
# Run the same simulation using our new model wrapper
values = model.simulate([5, 3], times)

# Plot the results
plt.figure()
plt.xlabel('Time')
plt.ylabel('Concentration')
plt.plot(times, values)
plt.show()


Running an optimisation problem

Now that our model implements the pints.ForwardModel interface, we can use it with Pints tools such as optimisers or MCMC.

In the example below, we use the model to generate test data, add some noise, and then define a score function that characterises the mismatch between model predictions and data. We then use the SNES optimiser to estimate the model parameters from the data.


In [4]:
# Define the 'true' parameters
true_parameters = [5, 3]

# Run a simulation to get test data
values = model.simulate(true_parameters, times)

# Add some noise
values += np.random.normal(0, 0.02, values.shape)

# Create an object with links to the model and time series
problem = pints.SingleOutputProblem(model, times, values)

# Select a score function
score = pints.SumOfSquaresError(problem)

# Select some boundaries
boundaries = pints.RectangularBoundaries([0.1, 0.1], [10, 10])

# Select a starting point
x0 = [1, 1]

# Perform an optimization using SNES (see docs linked above). 
found_parameters, found_value = pints.optimise(score, x0, boundaries=boundaries, method=pints.SNES)
print('Score at true solution:')
print(score(true_parameters))

print('Found solution:          True parameters:' )
for k, x in enumerate(found_parameters):
    print(pints.strfloat(x) + '    ' + pints.strfloat(true_parameters[k]))

# Plot the results
plt.figure()
plt.xlabel('Time')
plt.ylabel('Concentration')
plt.plot(times, values, alpha=0.5, label='noisy signal')
plt.plot(times, problem.evaluate(found_parameters), label='recovered signal')
plt.legend()
plt.show()


Minimising error measure
using Seperable Natural Evolution Strategy (SNES)
Running in sequential mode.
Iter. Eval. Best      Time m:s
0     6      11.12974   0:00.0
1     12     1.777777   0:00.0
2     18     1.777777   0:00.0
3     24     0.923      0:00.0
20    126    0.412      0:00.1
40    246    0.408      0:00.2
60    366    0.408      0:00.4
80    486    0.408      0:00.5
100   606    0.408      0:00.6
120   726    0.408      0:00.7
140   846    0.408      0:00.8
160   966    0.408      0:01.0
180   1086   0.408      0:01.1
200   1206   0.408      0:01.2
220   1326   0.408      0:01.3
240   1446   0.408      0:01.4
260   1566   0.408      0:01.6
280   1686   0.408      0:01.7
300   1806   0.408      0:01.8
309   1854   0.408      0:01.9
Halting: No significant change for 200 iterations.
Score at true solution:
0.408147155296
Found solution:          True parameters:
 4.97787481295077772e+00     5.00000000000000000e+00
 2.97398166176784784e+00     3.00000000000000000e+00