16 minute read

In this post, I cover how to fit a version of the two-species Levins metapopulation model in Stan, as well as how to use R to simulate data to fit the model to.

Acknowledgements and other resources

I relied on several resources when writing this post.

The first is Levins’ classic paper which introduced the single species metapopulation model. I also found this paper by Rampal Etienne to be very useful. The main resource that I used for the two-species metapopulation model was Theoretical Ecology: Principles and Applications, edited by Robert May and Angela McLean - chapter 4, metapopulations and their spatial dynamics by Sean Nee, is the relevant chapter.

I learned about Stan and Bayesian stats in general from Richard McElreath’s book and course Statistical Rethinking, Michael Betancourt’s introduction to Stan, the Stan manual, this post about (relatively) recent changes to the ODE interface and this tutorial by Mikhail Popov.

I also learned a lot about modeling from the book a biologist’s guide to mathematical modeling in ecology and evolution by Sarah Otto and Troy Day.

Disclaimer

I’m mainly writing this post because a) it’s fun and b) it helps me to learn more about the material - if people find it helpful, then that’s even better!

Please bear in mind that there might be mistakes lurking in this post - if you spot any, I’d appreciate if you let me know via email (my address is in the sidebar) or in the comments below. As usual, use of the model etc. is at your own risk.

Introducing the models

Levins’ single-species metapopulation model

Imagine a landscape where there are patches of habitat, each occupied by a population of a species. As some of these populations go extinct patches become empty, and empty patches are colonised by new populations which establish when individuals disperse in from occupied patches. We can study how the population of these populations - the metapopulation - rises and falls over time.

In Levins’ classic paper, he describes the changing metapopulation using the following equation:

\[\frac{dN}{dt} = MN\left(1 - \frac{N}{T} \right) - EN\]

where $N$ is the number of occupied patches, $M$ is the migration (or colonisation) rate, $E$ is the extinction rate, and $T$ is the total number of occupiable patches in the landscape.

Looking at the model, it seems oddly familiar… If we sub in a new parameter $r$ which represents the difference between the migration and extinction rates, i.e. $r = M - E$, we see why:

\[\frac{dN}{dt} = rN\left(1 - \frac{N}{T} \right)\]

This is just the logistic population growth model that we looked at in the last post! The only difference is that what used to be called the carrying capacity $K$ is now called the total number of occupiable patches $T$.

Since we’ve already looked at this model, we’re going to move on to look at one of the ways that the classic metapopulation model can be extended to two species.

Two-species metapopulation model - mutualism version

There are a variety of ways that the single-species metapopulation model can be extended to two species - several different extensions are described by Sean Nee in chapter 4 of Theoretical Ecology: Principles and Applications. In one version, the two species are mutualists: one species (the “plant”) can exist alone on a patch, but must be dispersed to new patches by the other species (the “disperser”). The disperser cannot survive on a patch by itself, and can only inhabit patches which are occupied by the plant.

Here is the two-species mutualism model as described in ch. 4 of Theoretical Ecology:

\[\frac{dx}{dt} = e_{p}y + e_{d}z - c_{p}zx\\ \frac{dy}{dt} = c_{p}zx - c_{d}zy - e_{p}y\\ \frac{dz}{dt} = c_{d}zy - e{d}z\]

Although it’s a two species model, we’re actually tracking the changes in three variables - the proportion of empty sites ($x$), sites with only the plant ($y$), and sites with both the plant and disperser ($z$). We also have two colonisation rate parameters ($c_p$ and $c_d$) and two extinction rate parameters ($e_p$ and $e_d$) which control the shifts between the system’s states.

I am a fan of Otto & Day’s technique of representing ODE’s as flow diagrams, where each variable is represented as a node (circle) and the flows between them are represented as arrows. This is the flow diagram corresponding to the equations above:

I find that this diagram makes some of the model’s assumptions much clearer. First, there is no arrow going directly from the “empty” node to the “plant & disperser” node. This means that empty patches have to be colonised by the plant before the disperser is able to colonise too - a patch can’t go from empty to containing both species in a single leap.

Second, the flow of patches from empty to the plant-only state is $c_{p}zx$. Notice that $y$ isn’t involved, meaning that if there are no patches with both the plant and disperser, then no new patches can be colonised by the plant - without the disperser, the plant will go extinct!

Finally, there isn’t an arrow from the “plant & disperser” node back to the “plant only” node - this means that once patches contain both species, they never lose only the disperser and return to a plant-only state. Thus, $e_{d}$ really represents the extinction rate of patches with the plant-disperser pair.

Simulating the two-species model in R

Let’s start by loading a couple of packages and setting the seed for the random number generator:

library(rethinking)
library(deSolve)
library(plotly) # only needed for 3d phase diagram plot
set.seed(946)

Now we’re going to write the function which contains our differential equations:

twosp_mutual <- function(times,y,parms){
  X <- y[1]
  Y <- y[2]
  Z <- y[3]
  
  with(as.list(p),{
    dx.dt <- ep*Y + ed*Z - cp*Z*X
    dy.dt <- cp*Z*X - cd*Z*Y - ep*Y
    dz.dt <- cd*Z*Y - ed*Z
    
    return(list(c(dx.dt, dy.dt, dz.dt)))
  })
}

After this, we have to pick values for our extinction and colonisation rate parameters - there’s no particular reason for choosing the ones that I did, and you can play around with other values if you like. We also have to pick values for our initial conditions - I went for 90% empty patches, no patches with plants only, and 10% patches with both species. Finally, we have to pick a time sequence for our simulation:

# Extinction and colonisation parameters
ep <- 1 # Plant extinction rate
ed <- 0.5 # Disperser extinction rate
cp <- 4 # Plant colonisation rate
cd <- 5 # Disperser colonisation rate

p <- c(ep=ep, ed=ed, cp=cp, cd=cd) # Make a vector of all of the parameters

# Initial conditions - most patches empty, small proportion have both species
y0 <- c(X = 0.9, 
        Y = 0, 
        Z = 0.1)

# Time sequence
time <- seq(0,10,by=0.1)

Now we’ve chosen our values, we can use the ode function in the deSolve package to get our metapopulation’s true state at each time step:

state_true <- ode(y=y0, times=time, func = twosp_mutual, parms = p)

We can now visualise how the proportion of patches which are in each state changes over time:

par(mfrow=c(1,3))
plot(X ~ time, data=state_true, type="l", ylim=c(0,1), ylab="Proportion of patches", main="Empty")
plot(Y ~ time, data=state_true, type="l", ylim=c(0,1), ylab="Proportion of patches", main="Plant only")
plot(Z ~ time, data=state_true, type="l", ylim=c(0,1), ylab="Proportion of patches", main="Plant & Disperser")
par(mfrow=c(1,1))

We can see that the system eventually reaches an equilibrium where the majority of patches are occupied by both the plant and disperser, and a smaller proportion are either empty or contain only the plant.

Just for fun, we can plot the state of the whole system over time as a 3D phase-space diagram - this code will produce an interactive plot which you can click and drag to rotate in the plot viewer:

fig <- plot_ly(as.data.frame(state_true), x = ~X, y= ~Y, z= ~Z, type = "scatter3d", mode="lines",
               line = list(width = 6, color = ~time, reverscale = FALSE, colorscale = "Viridis"))
fig

We can see the system track a path from the initial conditions in the bottom left, to the final equilibrium in the top right.

Now that we’ve simulated the system’s true underlying state, we can simulate the observation processes which will produce the actual data that we’re going to fit the model to. Since my main focus here was on learning about coding a model with more than one ODE, I went for a relatively simple observation process. The idea is we observe a finite number of sites (I went for 1000) and the number of sites in each state at a given time follows a multinomial distribution where the probabilities of each state are the true proportions contained in state_true. The code goes like this:

  n_sites <- 1000 # Number of sites observed
  
  counts_obs <- matrix(NA, nrow = length(time), ncol = 3)
  for(i in 1:length(time)){
    counts_obs[i,] <- rmultinom(n = 1, size = n_sites, prob = state_true[i, 2:4])
  }

The result is a matrix, counts_obs, which holds the number of sites in each state (empty, plant only, plant & disperser) at each time step.

We can plot the observed data on top of our true state lines from above like this:

par(mfrow=c(1,3))
plot(X ~ time, data=state_true, type="l", ylim=c(0,1), ylab="Proportion of patches", main="Empty")
points(x = time, y=counts_obs[,1]/n_sites)
plot(Y ~ time, data=state_true, type="l", ylim=c(0,1), ylab="Proportion of patches", main="Plant only")
points(x = time, y=counts_obs[,2]/n_sites)
plot(Z ~ time, data=state_true, type="l", ylim=c(0,1), ylab="Proportion of patches", main="Plant & Disperser")
points(x = time, y=counts_obs[,3]/n_sites)
par(mfrow=c(1,1))

Finally, we can prepare our data list for Stan. Just like in the post on exponential and logistic population growth, we have to separate the initial time step from the rest of the time series - this is because of the way that Stan indexes time steps. The code is:

dlist <- list(
  n_times = length(time)-1L,
  y0_obs = counts_obs[1,],
  y = counts_obs[-1,],
  t0 = 0,
  ts = time[-1],
  n_sites = as.integer(n_sites)
)

Coding the model in Stan

Now that we’ve simulated the data, we can code the Stan model!

Here is the full model:

functions{
  vector exp_growth(real t, // Time
                    vector y, // State
                    real ep, // Plant extinction rate
                    real ed, // Disperser extinction rate
                    real cp, // Plant colonisation rate
                    real cd){ // Disperser colonisation rate
    vector[3] dndt;
    dndt[1] = ep*y[2] + ed*y[3] - cp*y[3]*y[1]; // Empty patches
    dndt[2] = cp*y[3]*y[1] - cd*y[3]*y[2] - ep*y[2]; // Plant-only patches
    dndt[3] = cd*y[3]*y[2] - ed*y[3]; // Both species patches
    return dndt;
  }
}
data {
  int<lower=1> n_times; // Number of time steps minus one
  array[n_times, 3] int<lower=0> y; // Observed data, minus initial state
  array[3] int<lower=0> y0_obs; // Observed initial state
  real t0; // First time step
  array[n_times] real ts; // Time steps
  int<lower=0> n_sites; // Number of sites
}
parameters {
  simplex[3] y0; // Initial state (must sum to 1)
  real<lower=0> ep; // Plant extinction rate 
  real<lower=0> ed; // Disperser extinction rate
  real<lower=0> cp; // Plant colonisation rate
  real<lower=0> cd; // Disperser colonisation rate
}
transformed parameters{
    // ODE solver
  simplex[3] theta[n_times] = ode_rk45(exp_growth, y0, t0, ts, ep, ed, cp, cd);
}
model {
  // Priors
  ep ~ exponential(0.5); // Plant extinction rate
  ed ~ exponential(0.5); // Disperser extinction rate
  cp ~ exponential(0.5); // Plant colonisation rate
  cd ~ exponential(0.5); // Disperser colonisation rate

  y0 ~ dirichlet([1,1,1]); // Initial state

  // Observation model
  y0_obs ~ multinomial(y0); 
  for (t in 1:n_times) {
    y[t,] ~ multinomial(theta[t]); 
  }
}
generated quantities{
  array[n_times+1,3] int n_sim;
  n_sim[1,] = multinomial_rng(y0, n_sites);
  for(t in 1:n_times){
    n_sim[t+1,] = multinomial_rng(theta[t], n_sites);
  }
}

Now let’s break it down block by block:

The functions block

functions{
  vector exp_growth(real t, // Time
                    vector y, // State
                    real ep, // Plant extinction rate
                    real ed, // Disperser extinction rate
                    real cp, // Plant colonisation rate
                    real cd){ // Disperser colonisation rate
    vector[3] dndt;
    dndt[1] = ep*y[2] + ed*y[3] - cp*y[3]*y[1]; // Empty patches (X)
    dndt[2] = cp*y[3]*y[1] - cd*y[3]*y[2] - ep*y[2]; // Plant-only patches (Y)
    dndt[3] = cd*y[3]*y[2] - ed*y[3]; // Both species patches (Z)
    return dndt;
  }
}

This is one of the key bits of our model - it’s where we actually code the differential equations for our model. In contrast to my last post looking at the exponential and logistic models of population growth, we don’t just have a single equation - we have three, describing how the proportion of empty / plant only / plant and disperser patches changes over time.

The great news is that it’s very simple to extend our code two include these extra equations - we just have to make dndt a vector[3] (rather than vector[1]), and add the extra lines for $\frac{dy}{dt}$ and $\frac{dz}{dt}$ (dndt[2] and dndt[3] respectively). We also have to make sure to put the extinction and colonisation rate parameters in the brackets at the top.

The data block

data {
  int<lower=1> n_times; // Number of time steps minus one
  array[n_times, 3] int<lower=0> y; // Observed data, minus initial state
  array[3] int<lower=0> y0_obs; // Observed initial state
  real t0; // First time step
  array[n_times] real ts; // Time steps
  int<lower=0> n_sites; // Number of sites
}

In this block, we tell Stan about the observed data that we put in dlist previously. Remember that we’ve had to separate the initial time step from the rest of the time series because of the way that Stan handles indexing.

The parameters block

parameters {
  simplex[3] y0; // Initial state (must sum to 1)
  real<lower=0> ep; // Plant extinction rate 
  real<lower=0> ed; // Disperser extinction rate
  real<lower=0> cp; // Plant colonisation rate
  real<lower=0> cd; // Disperser colonisation rate
}

In this block, we tell Stan about the model’s parameters. First we have the initial state y0, which is a simplex with three values (as there are 3 states a patch can be in). y0 is a simplex because the proportion of sites in each state must sum to 1.

After this, we just have the four extinction and colonisation rate parameters.

The transformed parameters block

transformed parameters{
    // ODE solver
  simplex[3] theta[n_times] = ode_rk45(exp_growth, y0, t0, ts, ep, ed, cp, cd);
}

Here is the bit where we actually implement the ODE solver - we’re using the ode_rk45 solver here as we did for the exponential / logistic growth post, but there are other options which you can learn about here.

The model block

model {
  // Priors
  ep ~ exponential(0.5); // Plant extinction rate
  ed ~ exponential(0.5); // Disperser extinction rate
  cp ~ exponential(0.5); // Plant colonisation rate
  cd ~ exponential(0.5); // Disperser colonisation rate

  y0[1] ~ dirichlet([1,1,1]); // Initial state

  // Observation model
  y0_obs ~ multinomial(y0); 
  for (t in 1:n_times) {
    y[t,] ~ multinomial(theta[t]); 
  }
}

In this block, we have two main things going on. First, we define the priors for our parameters. We know that our extinction and colonisation rate parameters have to be positive, so I’ve given them exponential priors. In a real example, you might want to think about whether you have any more information that you can use to improve these, but they’ll do for this example.

For the initial state y0 I’ve used a dirichlet prior - by using the values [1,1,1] I’ve made it completely flat (uniform), meaning that the prior probability of each of the three initial states is the same. I could have used this prior to incorporate some of the information that we used when setting up the simulation, for example that most of the sites were empty at the initial time - but decided not to, just to see how well the model coped without this information.

After we’ve coded the priors, we code the observation model. We first do this for the observed initial state y0_obs, and then loop over the rest of the time steps.

The generated quantities block

generated quantities{
  array[n_times+1,3] int n_sim;
  n_sim[1,] = multinomial_rng(y0, n_sites);
  for(t in 1:n_times){
    n_sim[t+1,] = multinomial_rng(theta[t], n_sites);
  }
}

Just like in the exponential / logistic growth post, we’re using the generated quantities block to perform posterior predictive simulations - at each time step, we simulate the proportion of sites in each state and store this in n_sim. We do this using the multinomial distribution’s random number generator, multinomial_rng. We have to use y0 for the initial time step, and the approproate value of theta for the rest of the time steps - there’s a bit of fun with the indexing to make this work.

Running the model and exploring the output

After saving the model somewhere sensible (I’ve named mine twosp_mutual.stan) we can run it using the cstan function in the rethinking package:

m_twosp_mutual <- cstan(file = "C:/Stan_code/twosp_mutual.stan",
               data = dlist,
               chains = 4,
               cores = 4,
               warmup = 2500,
               iter = 3500,
               seed = 889)

Once it’s compiled and sampled, we can check the summary table and view some useful diagnostic plots:

precis(m_twosp_mutual, depth =3)
dashboard(m_twosp_mutual)

We see no divergent transitions, which is good! The number of effective samples, n_eff is also pretty good for each parameter. We have had one parameter where the Gelman-Rubin convergence diagnostic Rhat isn’t 1, meaning the chains haven’t quite converged - if you look at the summary table, you’ll see it’s actually y0[2], the initial proportion of plant-only patches. However, the problem isn’t too severe, so we’re just going to ignore it in this example.

We can also inspect traceplots for our key parameters:

traceplot(m_twosp_mutual, pars=c("y0[1]","y0[2]","y0[3]","ep","ed","cp","cd"))

Now that we’re satisfied with our model’s diagnostics, we’re free to extract the posterior samples:

post <- extract.samples(m_twosp_mutual)

One of the first things we can do is compare our posterior distribution for each of our key parameters with its true value (which we know because we simulated the data!). one way of doing so is with density plots like this:

par(mfrow=c(2,2))
dens(post$ep, main="Plant extinction rate"); abline(v=ep, lty=2)
dens(post$ed, main="Disperser extinction rate"); abline(v=ed, lty=2)
dens(post$cp, main="Plant colonisation rate"); abline(v=cp, lty=2)
dens(post$cd, main="Disperser colonisation rate"); abline(v=cd, lty=2)
par(mfrow=c(1,1))

The model has done a good job - all of the dashed vertical lines are contained within their respective posterior distribution!

We can also do this for the initial states:

par(mfrow=c(1,3))
dens(post$y0[,1], main="Initial empty proportion"); abline(v=y0[1], lty=2)
dens(post$y0[,2], main="Initial plant only proportion"); abline(v=y0[2], lty=2)
dens(post$y0[,3], main="Initial both species proportion"); abline(v=y0[3], lty=2)
par(mfrow=c(1,1))

Again, we see that the model has done a good job - if you look at the values on the x-axes, you can see that the estimates are very precise as well.

We can also plot the model’s posterior predictions (as median and compatability intervals) against the observed data, like this:

x_seq <- time

x_sim_mu <- apply(post$n_sim[,,1], 2, median)
x_sim_99CI <- apply(post$n_sim[,,1], 2, HPDI, prob=0.99)
x_sim_95CI <- apply(post$n_sim[,,1], 2, HPDI, prob=0.95)
x_sim_89CI <- apply(post$n_sim[,,1], 2, HPDI, prob=0.89)
x_sim_80CI <- apply(post$n_sim[,,1], 2, HPDI, prob=0.80)
x_sim_70CI <- apply(post$n_sim[,,1], 2, HPDI, prob=0.70)
x_sim_60CI <- apply(post$n_sim[,,1], 2, HPDI, prob=0.60)

y_sim_mu <- apply(post$n_sim[,,2], 2, median)
y_sim_99CI <- apply(post$n_sim[,,2], 2, HPDI, prob=0.99)
y_sim_95CI <- apply(post$n_sim[,,2], 2, HPDI, prob=0.95)
y_sim_89CI <- apply(post$n_sim[,,2], 2, HPDI, prob=0.89)
y_sim_80CI <- apply(post$n_sim[,,2], 2, HPDI, prob=0.80)
y_sim_70CI <- apply(post$n_sim[,,2], 2, HPDI, prob=0.70)
y_sim_60CI <- apply(post$n_sim[,,2], 2, HPDI, prob=0.60)

z_sim_mu <- apply(post$n_sim[,,3], 2, median)
z_sim_99CI <- apply(post$n_sim[,,3], 2, HPDI, prob=0.99)
z_sim_95CI <- apply(post$n_sim[,,3], 2, HPDI, prob=0.95)
z_sim_89CI <- apply(post$n_sim[,,3], 2, HPDI, prob=0.89)
z_sim_80CI <- apply(post$n_sim[,,3], 2, HPDI, prob=0.80)
z_sim_70CI <- apply(post$n_sim[,,3], 2, HPDI, prob=0.70)
z_sim_60CI <- apply(post$n_sim[,,3], 2, HPDI, prob=0.60)

par(mfrow=c(1,3))
plot(NULL, xlim=c(0,10), ylim=c(0,1000), xlab="Time", ylab="Number of patches", main="Empty")
lines(x_seq, x_sim_mu, lwd=2)
shade(x_sim_99CI, x_seq)
shade(x_sim_95CI, x_seq)
shade(x_sim_89CI, x_seq)
shade(x_sim_80CI, x_seq)
shade(x_sim_70CI, x_seq)
shade(x_sim_60CI, x_seq)
points(x = time, y = counts_obs[,1])

plot(NULL, xlim=c(0,10), ylim=c(0,1000), xlab="Time", ylab="Number of patches", main="Plant only")
lines(x_seq, y_sim_mu, lwd=2)
shade(y_sim_99CI, x_seq)
shade(y_sim_95CI, x_seq)
shade(y_sim_89CI, x_seq)
shade(y_sim_80CI, x_seq)
shade(y_sim_70CI, x_seq)
shade(y_sim_60CI, x_seq)
points(x = time, y = counts_obs[,2])

plot(NULL, xlim=c(0,10), ylim=c(0,1000), xlab="Time", ylab="Number of patches", main="Plant & Disperser")
lines(x_seq, z_sim_mu, lwd=2)
shade(z_sim_99CI, x_seq)
shade(z_sim_95CI, x_seq)
shade(z_sim_89CI, x_seq)
shade(z_sim_80CI, x_seq)
shade(z_sim_70CI, x_seq)
shade(z_sim_60CI, x_seq)
points(x = time, y = counts_obs[,3])

We see a great fit!

Comments