Building Your Own model

Foreword

This section is a guide for writing a Dynamic Causal Model for your own application. Be warned that the code is currently under development, and that these instructions are likely to change significantly as time goes on.

Overview

Creating your model

The dcEmb Dynamic Causal Model library provides support for creating arbitary DCM models through inheritance. The core idea is that the only things you should need to write for your own application are:

  1. Your forward (generative) model, and

  2. Estimates of parameter/hyperparameter distributions.

To write your own DCM you simply need to create a new class that inherits from the dynamic_model class. The Dynamic Model class contains two virtual methods that you will be forced to overwrite. This are:

virtual Eigen::VectorXd get_observed_outcomes();

A function that returns a vector of all observations you are training the model based on, subject to any suitable transformations.

virtual std::function<Eigen::VectorXd (Eigen::VectorXd)> get_forward_model_function();

A function that return a wrapped link to your generative function. The wrapped function must take a vector of parameters, and return a vector of predicted observations.

Once you have overwritten these methods in your class, the only thing you need to do is create an instance of your class, populate the parameters specifying parameter/hyperparameter distributions (plus a few others) and call new_model.invert(). The current parameters that are mandatory are:

1. Eigen::VectorXd prior_parameter_expectations;, a vector of the prior parameter expectations

2. Eigen::MatrixXd prior_parameter_covariances;, the covariance Matrix containing the prior parameter covariances

3. Eigen::MatrixXd prior_hyper_covariances;, a vector of the prior “hyperparameter” expectations

4. Eigen::MatrixXd prior_hyper_covariances;, the covariance Matrix containing the prior “hyperparameter” covariances

5. Eigen::MatrixXd response_vars;, the matrix of observations over (potentially serveral variables) to train the model on

6. int num_samples;, the number of timesteps in the observation matrix

7. int num_response_vars;, the number of different variables in the observation matrix

8. Eigen::VectorXi select_response_vars;, a vector enumerating which columns of the outcome matrix generated by our generative model correspond to the columns of the observation matrix we are training on.

Advanced functions

There are several functions, for example Bayesian Model Reduction (BMR), or Parametric Empirical Bayes (PEB) which are called on dynamic causal models themselves. These functions have been made generic and should be able to be called over any DCM-like class through the use of templating. For example, to create a PEB class in the COVID example, we do:

peb_model<dynamic_COVID_model> PEB;

Some of these classes may require additional parameters to be specified before they can be run. PEB, for example will require a matrix specifying random effects, as well as a between-subject design matrix.

A worked example for these functions is still a work in progress. We suggest anyone interested in pursuing this further references the documentation for these functions.

Worked Example: dcm_3body

In this section, we work through the steps we’ve just described in an example creating our three body problem DCM example.

The goal of this example is to use the DCM method to recover a known set of results from a physical system. The system in question is the three body problem, the problem of finding the trajectories of three planetary bodies in space. There exist several known stable orbits for the three body problem, which are periodic. In this example, we will take one of these stable solutions (a “figure of 8” orbit), and the positions, masses and velocities of one of the planets in this orbit, and use the DCM method to (hopefully accurately) estimate the positions, masses and velocities of the other two.

Creating the base model

In this section, we walk through creating a new DCM, and placing an appropriate three body problem forward model inside it.

Chronologically, the first thing we need to do following the instructions above, is to create a new class that inherits from the dynamic_model class. To do this we create a new folder in the include subdirectory of the project, called “3body”, and inside this create a new file called “dynamic_3body_model.hh”, with the following content:

include/3body/dynamic_3body_model.hh

#include "dynamic_model.hh"
#pragma once

class dynamic_3body_model : public dynamic_model {
  dynamic_3body_model(); // Class Constructor
};

Continuing to follow the above instructions, we also need to populate this class with at least the following functions: get_observed_outcomes() and get_forward_model_function().

include/3body/dynamic_3body_model.hh

#include "dynamic_model.hh"
#pragma once

class dynamic_3body_model : public dynamic_model {
 public:
  Eigen::VectorXd get_observed_outcomes();
  std::function<Eigen::VectorXd(Eigen::VectorXd)>get_forward_model_function();
  dynamic_3body_model();  // Class Constructor
};

The above code realizes the class definition for dynamic_3body_model. We now need to go away and create the implementations of these functions we have just defined.

In this case, our forward model will take the initial state of our three planets (positions, masses, and velocities) and produce a time series of the state over a given period. We can do this by using newtons law of gravitation to create equations of motion dictating the planets movements over time (see here for a simple walkthrough). These equations of motion lack an analytical solution, so we solve them using numerical methods. We suggest using the runge-kutta method, of which an implementation is provided in dCEmbs utility module.

Putting this together speaks to the creation of two functions, an “equation of motion” function evaluating the rate of change of the state, given the current state, and a main “generative model” function that iteratively applies the runge-kutta method to this to produce a time series. In the current version of the code, we also implement a forward model() function that explicitly wraps the entire forward model. We should implement these functions as member functions of our dynamic_3body_model class. To do this, we will first need to change our dynamic_3body_model.hh header file to include definitions for three new functions eval_generative(), forward model(), and differential_eq():

include/3body/dynamic_3body_model.hh

class dynamic_3body_model : public dynamic_model {
public:
  parameter_location_3body parameter_locations;
  int num_bodies;
  Eigen::VectorXd get_observed_outcomes();
  std::function<Eigen::VectorXd(Eigen::VectorXd)> get_forward_model_function();
  Eigen::VectorXd forward_model(
      const Eigen::VectorXd& parameters,
      const int& timeseries_length,
      const Eigen::VectorXi& select_response_vars);
  Eigen::MatrixXd eval_generative(
      const Eigen::VectorXd& parameters,
      const int& timeseries_length);
  Eigen::MatrixXd eval_generative(
      const Eigen::VectorXd& parameters,
      const int& timeseries_length,
      const Eigen::VectorXi& select_response_vars);
  Eigen::VectorXd differential_eq(const Eigen::VectorXd& state,
                                  const int& num_bodies);
  dynamic_3body_model();
};

As well as the parameters() and timeseries_length() parameters, which we use in our function definitions for the parameters our generative model will use, and the length of the timeseries it produces, we also pass it select_response_vars(). This is a variable that we will use later in order to match up our generative models outputs to our training data.

We also need to create implementations for each of these functions. To do this, we need to create a new folder in the src subdirectory of our project, called 3body, and create a new file in it called dynamic_3body_model.cc. This file should have each of the following blocks of content in it:

src/3body/dynamic_3body_model.cc

includes

#include "dynamic_3body_model.hh"
#include "utility.hh"

#include "Eigen/Core"
#include "Eigen/Dense"
#include "Eigen/SVD"

#include <chrono>
#include <fstream>
#include <functional>
#include <iostream>
#include <list>
#include <vector>

We first specify our includes. As well as several standard library functions, and the matrix library Eigen, we’re using the class definition in dynamic_3body_model.hh, and several utility functions defined in utility.hh.

src/3body/dynamic_3body_model.cc

get_observed_outcomes

#include "dynamic_model.hh"
#pragma once

Eigen::VectorXd dynamic_3body_model::get_observed_outcomes() {
  Eigen::Map<Eigen::VectorXd> observed_outcomes(
      this->response_vars.data(),
      this->response_vars.rows() * this->response_vars.cols());
  return observed_outcomes;
}

Our get_observed_outcomes() function is fairly simple, and simply returns response_vars (our true observed outcomes that we are training on) as a vector if it wasn’t already one.

src/3body/dynamic_3body_model.cc

get_forward_model_function

#include "dynamic_model.hh"
#pragma once

std::function<Eigen::VectorXd(Eigen::VectorXd)>
dynamic_3body_model::get_forward_model_function() {
  std::function<Eigen::VectorXd(Eigen::VectorXd)> forward_model = std::bind(
      &dynamic_3body_model::forward_model, this, std::placeholders::_1,
      this->num_samples, this->select_response_vars);
  return forward_model;
}

get_forward_model_function() function is also simple, and returns a wrapped version of our forward model function, with the timeseries_length bound to the num_samples parameter of our dynamic_3body_model class.

src/3body/dynamic_3body_model.cc

forward_model_function

Eigen::VectorXd dynamic_3body_model::forward_model(
    const Eigen::VectorXd& parameters,
    const int& timeseries_length,
    const Eigen::VectorXi& select_response_vars) {
  Eigen::MatrixXd gen =
      eval_generative(parameters, timeseries_length, select_response_vars);
  Eigen::Map<Eigen::VectorXd> output(gen.data(), gen.rows() * gen.cols());
  return output;
}

forward_model_function() calls our generative model, and converts the output to a vector.

src/3body/dynamic_3body_model.cc

eval_generative

Eigen::MatrixXd eval_generative(
    const Eigen::VectorXd& parameters,
    const int& timeseries_length) {

  // Initialize output matrix
  Eigen::MatrixXd output =
      Eigen::MatrixXd::Zero(timeseries_length, parameters.size());
  double h = 0.001; // Step Size
  // Initial state = input parameters
  Eigen::VectorXd state = parameters;
  // First row of output matrix is initial state
  output.row(0) = state;
  // Wrap the "differential eq" function, so it can be passed around
  std::function<Eigen::VectorXd(Eigen::VectorXd)> dfdt =
      std::bind(&differential_eq, this,
                std::placeholders::_1);
  // Iterate over the time series length
  for (int i = 1; i < timeseries_length; i++) {
    // Do 10 runge-kutta steps for each time step, for smoother output
    for (int j = 0; j < 10; j++) {
      // Update the state using runge-kutta
      Eigen::VectorXd state_delta = utility::rungekutta(dfdt, state, h);
      state = state + state_delta;
    }
    // Add current state to relevant row in output
    output.row(i) = state;
  }
  return output;
}
Eigen::MatrixXd dynamic_3body_model::eval_generative(
    const Eigen::VectorXd& parameters,
    const int& timeseries_length,
    const Eigen::VectorXi& select_response_vars) {
Eigen::MatrixXd output = eval_generative(parameters, parameter_locations,
                                        timeseries_length, num_bodies);

return output(Eigen::all, select_response_vars);
}

We provide two implementations of eval_generative here, one which uses the select_response_vars() variable to return only certain columns (useful for when we wish to compare to a restricted set of training data), and one which simply returns all columns.

eval_generative() Repeatedly calls our equations of motion for the 3body problem, using the runge-kutta method to produce updates for each timestep. This implementation envisages that while the state is input as a vector, said vector can be converted to a matrix in which each column corresponds to a planet. We’ll discuss a sensible strategy for creating such a vector further down.

src/3body/dynamic_3body_model.cc

differential_eq

Eigen::VectorXd differential_eq(
    const Eigen::VectorXd& state_in) {
  Eigen::VectorXd state_var = state_in;
  // Gravitational Constant
  double G = 1;
  // Convert our state vector to a matrix where each col is a planet
  Eigen::Map<Eigen::MatrixXd> state(state_var.data(), 7, 3);
  // Initialise return matrix
  Eigen::MatrixXd return_matrix =
      Eigen::MatrixXd::Zero(state.rows(), state.cols());
  for (int i = 0; i < state.cols(); i++) {    // For each planet in turn
    return_matrix(1, i) = state(4, i);        // Update position x by velocity x
    return_matrix(2, i) = state(5, i);        // Update position y by velocity y
    return_matrix(3, i) = state(6, i);        // Update position z by velocity z
    for (int j = 0; j < state.cols(); j++) {  // For each other planet
      if (i == j) {
        continue; // Skip if comparing a planet to itself
      }
      double distancex = state(1, j) - state(1, i);  // x distance
      double distancey = state(2, j) - state(2, i);  // y distance
      double distancez = state(3, j) - state(3, i);  // z distance
      double distance_euclidian =
          sqrt((distancex * distancex) + (distancey * distancey) +
               (distancez * distancez));  // euclidian distance
      // Update the x velocity by the x acceleration calculated at this point
      return_matrix(4, i) +=
          (G * state(0, j) * distancex) / pow(distance_euclidian, 3);
      // Update the y velocity by the y acceleration calculated at this point
      return_matrix(5, i) +=
          (G * state(0, j) * distancey) / pow(distance_euclidian, 3);
      // Update the z velocity by the z acceleration calculated at this point
      return_matrix(6, i) +=
          (G * state(0, j) * distancez) / pow(distance_euclidian, 3);
    }
  }
  // Convert return matrix to vector
  Eigen::Map<Eigen::VectorXd> return_state(
      return_matrix.data(), return_matrix.rows() * return_matrix.cols());
  return return_state;
}

differential_eq() Implements the equations of motion. Given a state (positions, masses and velocities of each of the three planets), describes the rate of change of that state.

With all these pieces in place, we have implemented our dynamic_3body_model class, and populated it with a forward model that will produce the trajectories of 3 bodies in space. We now move on to how to test this.

Testing the Forward Model

In this section, we write a short piece of code to test the forward model that we have just created.

The above code will need a few minor modifications before we can perform model inversion on it, but we can (and should) check first that the forward model that we have created is valid. To do this, we’ll need to create function that creates a new dynamic_3body_model object and calls it’s generative model function, and a main function (so that we can run out C++ program) . Following the convention that the main function should easily found, we suggest placing the main function in it’s own file (src/3body/run_3body_dcm.cc), and the code implementing our forward model test in a separate file (src/3body/DEM_3body.cc). For the DEM_3body file, we will also need a header file (include/3body/DEM_3body.hh). For run_dcm_3body, as long as the only function it contains is main, we will not.

Creating the file with our main function in is easy. The following is sufficient:

src/3body/run_3body_dcm.cc

#include <DEM_3body.hh>

int main() {
  int test = run_3body_test();
  exit(2);
  return (0);
}

run_3body_test() will be a function we define in DEM_3body for running our forward model test.

Creating the files for our demo functions will requires us to at least define the run_3body_test() function . We also suggest defining a function to return a vector of our “true” figure-of-8 stable parameters in order to keep the code tidy. In this example, we call this true_prior_expectations(). This gives us our header file (include/3body/DEM_3body.hh) as:

include/3body/DEM_3body.hh

#include "Eigen/Dense"
#include "parameter_location_3body.hh"
#pragma once

int run_3body_test();
Eigen::VectorXd true_prior_expectations();

The implementations of these functions might look like:

src/3body/DEM_3body.cc

run_3body_test

int run_3body_test() {
  dynamic_3body_model model;

  Eigen::MatrixXd out1 =
      model.eval_generative(true_prior_expectations(), 1000);
  utility::print_matrix("../visualisation/true_generative.csv", out1);
}

return 0;

Our function simply creates a dynamic_3body_model object, and calls the eval_generative() function we defined earlier. This will return a matrix in which each column is an item in the state (position, mass, velocity of a planet), and each row is a timestep. The print matrix function puts these values in a file. Our true_prior_expectations() will look like:

src/3body/DEM_3body.cc

true_prior_expectations

Eigen::VectorXd true_prior_expectations() {
  Eigen::MatrixXd default_prior_expectation = Eigen::MatrixXd::Zero(7, 3);
  default_prior_expectation.row(0) << 1, 1, 1; // Masses
  default_prior_expectation.row(1) << 0.97000436, -0.97000436, 0; // Pos X
  default_prior_expectation.row(2) << -0.24308753, 0.24308753, 0; // Pos Y
  default_prior_expectation.row(3) << 0, 0, 0; //Pos Z
  default_prior_expectation.row(4) << 0.93240737 / 2, 0.93240737 / 2,
      -0.93240737; // Velocity X
  default_prior_expectation.row(5) << 0.86473146 / 2, 0.86473146 / 2,
      -0.86473146; // Velocity Y
  default_prior_expectation.row(6) << 0, 0, 0; // Velocity Z
  Eigen::Map<Eigen::VectorXd> return_default_prior_expectation(
      default_prior_expectation.data(),
      default_prior_expectation.rows() * default_prior_expectation.cols());
  return return_default_prior_expectation;
}

Our strategy here is to define each row to be a parameter, and each column to be a planet. This makes our earlier generative function very easy to write. Unfortunately, since we need to pass parameters in a vector in, we will need to vectorize the resulting matrix, then unvectorize it when we wish to use it.

To compile the code, you can either build the code on the command line or (we suggest) piggyback on the CMake system we use to build the other examples. To avoid duplicating effort, we won’t rediscuss this here, but simply link to the relevant section.

You can visualize these with the visualise_3body.py script in the visualization subdirectlry (note: WIP - script is not user friendly) or your favorite graphing software.

Inverting a model

In the previous sections, we defined and tested our forward model. Before we can invert the model, we need to define several extra parameters. We need to define our priors, the data we’re going to train on, and how this data relates to our generative model.

Defining the priors is fairly easy, we just need to set the following variables.

  • Eigen::VectorXd prior_parameter_expectations;

  • Eigen::MatrixXd prior_parameter_covariances;

  • Eigen::VectorXd prior_hyper_expectations;

  • Eigen::MatrixXd prior_hyper_covariances;

We suggest creating functions to populate these values. Sensible values would be:

Eigen::VectorXd default_prior_expectations() {
Eigen::MatrixXd default_prior_expectation = Eigen::MatrixXd::Zero(7, 3);
default_prior_expectation.row(0) << 0.95, 1.05, 1.05;
default_prior_expectation.row(1) << 0.97000436 + 0.05, -0.97000436 - 0.05, 0;
default_prior_expectation.row(2) << -0.24308753 + 0.05, 0.24308753 + 0.05, 0;
default_prior_expectation.row(3) << 0.05, 0.05, -0.05;
default_prior_expectation.row(4) << 0.93240737 / 2 + 0.05,
    0.93240737 / 2 - 0.05, -0.93240737 + 0.05;
default_prior_expectation.row(5) << 0.86473146 / 2 + 0.05,
    0.86473146 / 2 - 0.05, -0.86473146 - 0.05;
default_prior_expectation.row(6) << 0.05, -0.05, 0.05;
Eigen::Map<Eigen::VectorXd> return_default_prior_expectation(
    default_prior_expectation.data(),
    default_prior_expectation.rows() * default_prior_expectation.cols());
return return_default_prior_expectation;
}

Eigen::MatrixXd default_prior_covariances() {
double flat = 1.0;                    // flat priors
double informative = 1 / (double)16;  // informative priors
double precise = 1 / (double)256;     // precise priors
double fixed = 1 / (double)2048;      // precise priors
Eigen::MatrixXd default_prior_covariance = Eigen::MatrixXd::Zero(7, 3);
default_prior_covariance.row(0) = Eigen::VectorXd::Constant(3, informative);
default_prior_covariance.row(1) = Eigen::VectorXd::Constant(3, informative);
default_prior_covariance.row(2) = Eigen::VectorXd::Constant(3, informative);
default_prior_covariance.row(3) = Eigen::VectorXd::Constant(3, informative);
default_prior_covariance.row(4) = Eigen::VectorXd::Constant(3, informative);
default_prior_covariance.row(5) = Eigen::VectorXd::Constant(3, informative);
default_prior_covariance.row(6) = Eigen::VectorXd::Constant(3, informative);
Eigen::Map<Eigen::VectorXd> default_prior_covariance_diag(
    default_prior_covariance.data(),
    default_prior_covariance.rows() * default_prior_covariance.cols());
Eigen::MatrixXd return_default_prior_covariance =
    Eigen::MatrixXd::Zero(21, 21);
return_default_prior_covariance.diagonal() = default_prior_covariance_diag;
return return_default_prior_covariance;
}

Eigen::VectorXd default_hyper_expectations() {
Eigen::VectorXd default_hyper_expectation = Eigen::VectorXd::Zero(3);
return default_hyper_expectation;
}

Eigen::MatrixXd default_hyper_covariances() {
Eigen::MatrixXd default_hyper_covariance = Eigen::MatrixXd::Zero(3, 3);
default_hyper_covariance.diagonal() << 1.0 / 256.0, 1.0 / 256.0, 1.0 / 256.0;
return default_hyper_covariance;
}

To define the data we train on a sensible approach is to run the generative model with the “true” values, but then only keep one of the planets data to train on. We can use the select_response_vars variable that we defined for out generative model earlier to do this. We can also set this same parameter for out dynamic_3body_model object to indicate to the model that these are the relevant data to train on.

Putting this together, we end up with:

int run_3body_test() {
dynamic_3body_model model; // Create new model object
// Set priors
model.prior_parameter_expectations = default_prior_expectations();
model.prior_parameter_covariances = default_prior_covariances();
model.prior_hyper_expectations = default_hyper_expectations();
model.prior_hyper_covariances = default_hyper_covariances();
model.parameter_locations = default_parameter_locations();
model.num_samples = 1000; // Set number of timesteps to train
model.num_response_vars = 3; // Select number of parameters in training data

// Run the Generative Model over the parameters of a true stable orbit
Eigen::MatrixXd out1 =
    model.eval_generative(true_prior_expectations(),
                            model.parameter_locations, model.num_samples, 3);
utility::print_matrix("../visualisation/true_generative.csv", out1);

// Initialise training data variables
Eigen::MatrixXd response_vars =
    Eigen::MatrixXd::Zero(model.num_samples, model.num_response_vars);
// Initialise training data location variables
Eigen::VectorXi select_response_vars =
    Eigen::VectorXi::Zero(model.num_response_vars);
select_response_vars << 1, 2, 3;
response_vars = out1(Eigen::all, select_response_vars);
// Set locations of parameters in training data
model.select_response_vars = select_response_vars;
// Set training data
model.response_vars = response_vars;

// Invert Model
model.invert_model();
// Print generative model evaluated over posterior means to file
Eigen::MatrixXd out2 =
    model.eval_generative(model.conditional_parameter_expectations,
                            model.parameter_locations, model.num_samples, 3);
utility::print_matrix("../visualisation/deriv_generative.csv", out2);
// Print generative model evaluated over prior means to file (for comparison)
Eigen::MatrixXd out3 =
    model.eval_generative(default_prior_expectations(),
                            model.parameter_locations, model.num_samples, 3);
utility::print_matrix("../visualisation/org_generative.csv", out3);

return 0;
}

Running this code should result in the creation of three files:

  • true_generative.csv, the true state over time of a stable orbit.

  • deriv_generative.csv, the state over time, based on the mean of the posterior estimates of parameters

  • org_generative.csv, the state over time, based on the mean of the prior estimates of parameters

Visualizing this code, either with visualisation/visualise_3body.py, or your tool of choice, you should observe that the posterior estimates and true solution are almost identical, while the prior estimates quickly degenerate into chaos.

Building your project using CMake

dcEmb uses CMake as its build system. Here, we cover very quickly the basic steps you’d need to take in order to add the above 3body example code to the existing build script. For instructions on running the build script to build the project, see installation instructions.

There are three things we need to add to the build script in order to get our choice example to build. We need to point CMake toward the header files, we need to define all the sources of code our executable will need, and we need to define the parameters by which it is compiler.

CMake does almost all of the heavy lifting with the header files, we simply need to add an entry in the include_directories pointing toward the folder containing our include files.

include_directories(
    ${CMAKE_SOURCE_DIR}/lib/cereal/include
    ${CMAKE_SOURCE_DIR}/lib/eigen
    ${CMAKE_SOURCE_DIR}/include
    ${CMAKE_SOURCE_DIR}/include/COVID
    ${CMAKE_SOURCE_DIR}/include/3body # e.g. here
    ${CMAKE_SOURCE_DIR}/include/tests
    ${gtest_SOURCE_DIR}/include
    ${gtest_SOURCE_DIR}
)

Defining source files is most cleanly done with the set function.

set(SOURCES_3BODY
    src/dynamic_model.cc
    src/3body/run_3body_dcm.cc
    src/3body/DEM_3body.cc
    src/3body/dynamic_3body_model.cc
    src/utility.cc
)

Default compilation flags are set elsewhere in the CMake file, so unless we want to specify any further changes, we only need to add a new executable and (optionally, but strongly suggested) link against the OpenMP multithreading library.

add_executable(dcm_3body ${SOURCES_3BODY})
if(OpenMP_FOUND)
    target_link_libraries(dcm_3body PUBLIC OpenMP::OpenMP_CXX)
endif(OpenMP_FOUND)
set_target_properties(dcm_3body PROPERTIES COMPILE_FLAGS "")