Lotka-Volterra Predator Prey Model
In this post, I’ll explore using R to analyze dynamical systems. Using the Lotka-Volterra predator prey model as a simple case-study, I use the R packages deSolve
to solve a system of differential equations and FME
to perform a sensitivity analysis.
library(tidyverse)
library(deSolve)
library(FME)
Lotka-Volterra model
The Lotka-Volterra model describes the dynamics of a two-species system in which one is a predator and the other is its prey. The equations governing the dynamics of the prey (with density \(x\) and predator (with density \(y\)) are:
\[ \begin{aligned} \frac{dx}{dt} & = \alpha x - \beta xy \\ \frac{dy}{dt} & = \delta \beta xy - \gamma y \end{aligned} \]
where \(\alpha\) is the (exponential) growth rate of the prey in the absence of predation, \(\beta\) is the predation rate or predator search efficiency, \(\delta\) describes the predator food conversion efficiency, and \(\gamma\) is the predator mortality.
Solving the ODE
Given a set of initial conditions and parameter estimates, deSolve::ode()
can be used to evolve a dynamical system described by a set of ODEs. I start by defining parameters, as a named list, and the initial state, as a vector. For the initial state, it is the order that matters not the names.
# parameters
pars <- c(alpha = 1, beta = 0.2, delta = 0.5, gamma = 0.2)
# initial state
init <- c(x = 1, y = 2)
# times
times <- seq(0, 100, by = 1)
Next, I need to define a function that computes the derivatives in the ODE system at a given point in time.
deriv <- function(t, state, pars) {
with(as.list(c(state, pars)), {
d_x <- alpha * x - beta * x * y
d_y <- delta * beta * x * y - gamma * y
return(list(c(x = d_x, y = d_y)))
})
}
lv_results <- ode(init, times, deriv, pars)
The vignette for FME
suggets rolling all this into a function as follows. This function will become the input for the FME
sensitivity analysis.
lv_model <- function(pars, times = seq(0, 50, by = 1)) {
# initial state
state <- c(x = 1, y = 2)
# derivative
deriv <- function(t, state, pars) {
with(as.list(c(state, pars)), {
d_x <- alpha * x - beta * x * y
d_y <- delta * beta * x * y - gamma * y
return(list(c(x = d_x, y = d_y)))
})
}
# solve
ode(y = state, times = times, func = deriv, parms = pars)
}
lv_results <- lv_model(pars = pars, times = seq(0, 50, by = 0.25))
The ouput of ode()
is a matrix with one column for each state variable. I convert this to a data frame and plot the evolution of the system over time.
lv_results %>%
data.frame() %>%
gather(var, pop, -time) %>%
mutate(var = if_else(var == "x", "Prey", "Predator")) %>%
ggplot(aes(x = time, y = pop)) +
geom_line(aes(color = var)) +
scale_color_brewer(NULL, palette = "Set1") +
labs(title = "Lotka-Volterra predator prey model",
subtitle = paste(names(pars), pars, sep = " = ", collapse = "; "),
x = "Time", y = "Population density")
This choice of paramters leads to periodic dynamics in which the prey population initially increases, leading to an abundance of food for the predators. The predators increase in response (lagging the prey population), eventually overwhelming the prey population, which crashes. This in turn causes the predators to crash, and the cycle repeats. The period of these dynamics is about 15 seconds, with the predators lagging the prey by about a second.
Sensitivity analysis
A sensitivity analysis examines how changes in the parameters underlying a model affect the behaviour of the system. It can help us understand the impact of uncertainty in parameter estimates. The FME
vignette covers two types of sensitivity analyses: global and local.
Global sensitivity
According to the FME
vingette, in a global sensitivity analysis certain parameters (the sensitivity parameters) are varied over a large range, and the effect on model output variables (the sensitivity variables) is measured. To accomplish this, a distribution is defined for each sensitivity parameter and the model is run multiple times, each time drawing values for the sensistivity parameters from their distribution. The sensitivity variables are recorded for each iteration over a range of times. The function sensRange()
carries out global sensitivity analyses.
I’ll look at the sensitivity of the populations to the growth rate (\(\alpha\)) and the predation rate (\(\beta\)). Defining the sensitivity parameter distributions requires providing a data frame in which the first column is the minimum value, the second column the maximum, and the row names are the parameter names.
par_ranges <- data.frame(min = c(0.75, 0.15),
max = c(1.25, 0.25),
row.names = c("alpha", "beta"))
par_ranges
#> min max
#> alpha 0.75 1.25
#> beta 0.15 0.25
Now I use sensRange()
to solve the models over the range of parameters. The argument dist = "grid"
sets the sensitivity parameter distribution to a regular grid of values, sensvar
defines which variables are the sensitivity variables (i.e. the ones whose time series will be returned in the output), num
is the number of iterations and therefore the number of different sensistivity parameter values (note that if there are \(k\) sensitivity parameters, num
must have an integer \(k\)th root), and times is the time series over which to evaluate the model.
The output of this function is a data frame with rows corresponding to the different sensitivity parameter values and columns corresponding to the combination of time steps and sensitivity variables. So, for n
time steps, there are n
columns for each sensitivity variable. First I run a simple sensitivity analysis to aid examination of the output.
lv_glob_sens <- sensRange(func = lv_model, parms = pars, dist = "grid",
sensvar = c("x", "y"), parRange = par_ranges,
num = 4, times = seq(0, 1, by = 0.5))
lv_glob_sens
#> alpha beta x0 x0.5 x1 y0 y0.5 y1
#> 1 0.75 0.15 1 1.26 1.59 2 1.89 1.80
#> 2 1.25 0.15 1 1.61 2.62 2 1.90 1.86
#> 3 0.75 0.25 1 1.14 1.30 2 1.93 1.89
#> 4 1.25 0.25 1 1.46 2.14 2 1.95 1.97
Here variables such as x0.5
refer to the values of \(x\) at \(t=0.5\). FME
provides a plot()
method for sensRange
objects, which adds envelopes to the variables showing the range and mean ± standard deviation. Now I run a more realistic sensitivity analysis and produce the plots. Note that summary()
must be called before plot()
to get the desired plots.
lv_glob_sens <- sensRange(func = lv_model, parms = pars, dist = "grid",
sensvar = c("x", "y"), parRange = par_ranges,
num = 100, times = seq(0, 50, by = 0.25))
lv_glob_sens %>%
summary() %>%
plot(main = c("Prey", "Predator"),
xlab = "Time", ylab = "Population density",
col = c("lightblue", "darkblue"))
mtext("Sensitivity to alpha and beta", outer = TRUE, line = -1.5, side = 3,
cex = 1.25)
To actually work with these data, I’ll transform the data frame from wide to long format using tidyr
. gather()
converts from wide to long format, then separate()
splits column names x1.5
into two fields: one identifying the variable (x
) and one specifying the time step (1.5
).
lv_sens_long <- lv_glob_sens %>%
gather(key, abundance, -alpha, -beta) %>%
separate(key, into = c("species", "t"), sep = 1) %>%
mutate(t = parse_number(t)) %>%
select(species, t, alpha, beta, abundance)
head(lv_sens_long)
#> species t alpha beta abundance
#> 1 x 0 0.750 0.15 1
#> 2 x 0 0.806 0.15 1
#> 3 x 0 0.861 0.15 1
#> 4 x 0 0.917 0.15 1
#> 5 x 0 0.972 0.15 1
#> 6 x 0 1.028 0.15 1
glimpse(lv_sens_long)
#> Observations: 40,200
#> Variables: 5
#> $ species <chr> "x", "x", "x", "x", "x", "x", "x", "x", "x", "x", "x", "x",…
#> $ t <dbl> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
#> $ alpha <dbl> 0.750, 0.806, 0.861, 0.917, 0.972, 1.028, 1.083, 1.139, 1.1…
#> $ beta <dbl> 0.150, 0.150, 0.150, 0.150, 0.150, 0.150, 0.150, 0.150, 0.1…
#> $ abundance <dbl> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,…
Now, I can, for example, recreate the above plot with ggplot2
. First, I summarize the data to calculate the envelopes, then I plot.
lv_sens_summ <- lv_sens_long %>%
group_by(species, t) %>%
summarize(a_mean = mean(abundance),
a_min = min(abundance), a_max = max(abundance),
a_sd = sd(abundance)) %>%
ungroup() %>%
mutate(a_psd = a_mean + a_sd, a_msd = a_mean - a_sd,
species = if_else(species == "x", "Prey", "Predator"),
species = factor(species, levels = c("Prey", "Predator")))
ggplot(lv_sens_summ, aes(x = t, group = species)) +
# mean+-sd
geom_ribbon(aes(ymin = a_msd, ymax = a_psd, fill = species), alpha = 0.2) +
# mean
geom_line(aes(y = a_mean, color = species)) +
labs(title = "Sensitivity to alpha and beta (mean ± sd)",
subtitle = "alpha = [0.75, 1.25]; beta = [0.15, 0.25]",
x = "Time", y = "Population density") +
scale_color_brewer(NULL, palette = "Set1") +
scale_fill_brewer(NULL, palette = "Set1")
In this format, it’s also easy to fix one of the values for a sensitivity parameter and only vary the other one.
lv_sens_summ <- lv_sens_long %>%
# fix beta at 0.15
filter(beta == 0.15) %>%
group_by(species, t) %>%
summarize(a_mean = mean(abundance),
a_min = min(abundance), a_max = max(abundance),
a_sd = sd(abundance)) %>%
ungroup() %>%
mutate(a_psd = a_mean + a_sd, a_msd = a_mean - a_sd,
species = if_else(species == "x", "Prey", "Predator"),
species = factor(species, levels = c("Prey", "Predator")))
ggplot(lv_sens_summ, aes(x = t, group = species)) +
# mean+-sd
geom_ribbon(aes(ymin = a_msd, ymax = a_psd, fill = species), alpha = 0.2) +
# mean
geom_line(aes(y = a_mean, color = species)) +
labs(title = "Sensitivity to alpha at fixed beta (mean ± sd)",
subtitle = "alpha = [0.75, 1.25]; beta = 0.15",
x = "Time", y = "Population density") +
scale_color_brewer(NULL, palette = "Set1") +
scale_fill_brewer(NULL, palette = "Set1")
Local sensitivity analysis
According to the FME
vingette, in a local sensitivity analysis, “the effect of a parameter value in a very small region near its nominal value is estimated”. The method used by FME
is to calculate a matrix of sensitivity functions, \(S_{i,j}\), defined by:
\[ S_{i,j} = f(v_i, p_i) = \frac{dv_i}{dp_j} \frac{s_{p_j}}{s_{v_i}} \]
where \(v_i\) is a sensitivity variable \(i\) (which is dependent on time \(t\)), \(p_j\) is sensitivity parameter \(j\), and \(s_{v_i}\) and \(s_{p_j}\) are scaling factors for variables and parameters, respectively. By default, FME
takes the scaling values to be equal to the underlying quantities, in which case the above equation simplifies to:
\[ S_{i,j} = f(v_i, p_i) = \frac{dv_i}{dp_j} \frac{p_j}{v_i} \]
The function sensFun()
is used to numerically estimate these sensitivity functions at a series of time steps. The arguments sensvar
and senspar
are used to define which variables and parameters, respectively, will be investigated in the sensitivity analysis. By default, all variables are parameters are included. The arguments varscale
and parscale
define the scaling factors; however, for now, I’ll leave them blank, which sets them to the underlying quantities as in the above equation.
In practice, sensFun()
works by applying a small perturbation, \(\delta_j\), to parameter \(j\), solving the model for a range of time steps to determine \(v_i\), then taking the ratio of the changes to the parameters and variables. The perturbation is defined by the argument tiny
as \(\delta_j = \text{max}(tiny, tiny * p_j)\). tiny
defaults to \(10^{-8}\).
To test that sensFun()
is doing what I think it is, I’ll implement a version of it. For simplicity, I’ll only consider the variable \(x\) (prey density):
sen_fun <- function(fun, pars, times, tiny = 1e-8) {
# the unperturbed values, just x
v_unpert <- fun(pars, times)[, "x"]
# loop over parameters, pertuburbing each in turn
s_ij <- matrix(NA, nrow = length(times), ncol = (1 + length(pars)))
s_ij[, 1] <- times
colnames(s_ij) <- c("t", names(pars))
for (j in seq_along(pars)) {
# perturb the ith parameter
delta <- max(tiny, abs(tiny * pars[j]))
p_pert <- pars
p_pert[j] <- p_pert[j] + delta
# solve model
v_pert <- fun(pars = p_pert, times = times)[, "x"]
# calculate the resulting difference in variables at each timestep, just x
delta_v <- (v_pert - v_unpert)
# finally, calculate the sensitivity function at each time step
s_ij[, j + 1] <- (delta_v / delta) * (pars[j] / v_unpert)
}
return(s_ij)
}
Now I compare this implementation to the actual results.
test_pars <- c(alpha = 1.5, beta = 0.2, delta = 0.5, gamma = 0.2)
sen_fun(lv_model, pars = test_pars, times = 0:2)
#> t alpha beta delta gamma
#> [1,] 0 0.00 0.000 0.000 0.0000
#> [2,] 1 1.48 -0.415 -0.029 0.0386
#> [3,] 2 2.69 -0.966 -0.210 0.1655
sensFun(lv_model, parms = test_pars, sensvar = "x", times = 0:2)
#> x var alpha beta delta gamma
#> 1 0 x 0.00 0.000 0.000 0.0000
#> 2 1 x 1.48 -0.415 -0.029 0.0386
#> 3 2 x 2.69 -0.966 -0.210 0.1655
A perfect match! Now that I know what sensFun()
is actually doing, I’ll put it to use to solve the original LV model. One difference here is that I’ll consider both variables as sensitivity variables and the results for each variable will be stacked rowwise. In addition, in the FME
documentation, \(s_{v_i}\) is set to 1, which is on the order of the actual variable values, but has the benefit of being constant over time. I’ll do the same here.
lv_loc_sens <- sensFun(lv_model, parms = pars, varscale = 1,
times = seq(0, 50, by = 0.25))
head(lv_loc_sens)
#> x var alpha beta delta gamma
#> 1 0.00 x 0.000 0.000 0.00000 0.00000
#> 2 0.25 x 0.291 -0.116 -0.00151 0.00286
#> 3 0.50 x 0.677 -0.272 -0.00729 0.01316
#> 4 0.75 x 1.182 -0.478 -0.01997 0.03413
#> 5 1.00 x 1.833 -0.749 -0.04341 0.07015
#> 6 1.25 x 2.663 -1.104 -0.08330 0.12706
tail(lv_loc_sens)
#> x var alpha beta delta gamma
#> 397 48.8 y -1.95 -5.45 -0.933 -9.07
#> 398 49.0 y -1.81 -4.95 -0.831 -8.38
#> 399 49.2 y -1.66 -4.43 -0.725 -7.64
#> 400 49.5 y -1.49 -3.90 -0.614 -6.87
#> 401 49.8 y -1.30 -3.34 -0.495 -6.04
#> 402 50.0 y -1.08 -2.75 -0.367 -5.15
summary()
can be used to summarize these results over the time series, for example, to see which parameters the model is most sensitive too.
summary(lv_loc_sens)
#> value scale L1 L2 Mean Min Max N
#> alpha 1.0 1.0 6.5 11.2 2.2 -35 44 402
#> beta 0.2 0.2 8.3 12.5 -4.1 -56 33 402
#> delta 0.5 0.5 2.5 4.3 -1.1 -19 11 402
#> gamma 0.2 0.2 10.8 18.2 -0.1 -78 74 402
\(\gamma\) (predator mortality rate) and \(\beta\) (predator search efficiency) have the largest values for the sensitivity function, on average, suggesting that this model is most sensitive to these parameters. There is also plot method for the output of sensFun()
, which plots the sensitivity functions as time series.
plot(lv_loc_sens)
However, it’s also possible to use ggplot2
provided I transpose the data to long format first.
lv_loc_long <- lv_loc_sens %>%
gather(parameter, sensitivity, -x, -var) %>%
mutate(var = if_else(var == "x", "Prey", "Predator"))
ggplot(lv_loc_long, aes(x = x, y = sensitivity)) +
geom_line(aes(colour = parameter, linetype = var)) +
scale_color_brewer("Parameter", palette = "Set1") +
scale_linetype_discrete("Variable") +
labs(title = "Lotka-Volterra parameter sensitivity functions",
subtitle = paste(names(pars), pars, sep = " = ", collapse = "; "),
x = "Time", y = "Sensitivity") +
theme(legend.position = "bottom")
Clearly this model is particularly sensitive to \(\gamma\). Furthermore, this sensitivity shows peaks every 16 seconds or so, which is the periodicity of the original dynamics. To see what’s going on here, I’ll take a look at what happens to the two species when I increase \(\gamma\) by 1%:
# original model
lv_results <- lv_model(pars, times = seq(0, 50, by = 0.25)) %>%
data.frame() %>%
gather(var, pop, -time) %>%
mutate(var = if_else(var == "x", "Prey", "Predator"),
par = as.character(pars["gamma"]))
# perturbed model
new_pars <- pars
new_pars["gamma"] <- new_pars["gamma"] * 1.1
lv_results_gamma <- lv_model(new_pars, times = seq(0, 50, by = 0.25)) %>%
data.frame() %>%
gather(var, pop, -time) %>%
mutate(var = if_else(var == "x", "Prey", "Predator"),
par = as.character(new_pars["gamma"]))
# plot
ggplot(bind_rows(lv_results, lv_results_gamma), aes(x = time, y = pop)) +
geom_line(aes(color = var, linetype = par)) +
scale_color_brewer("Species", palette = "Set1") +
scale_linetype_discrete("gamma") +
labs(title = "Lotka-Volterra predator prey model",
subtitle = "Increasing gamma leads to period increasing with time",
x = "Time", y = "Population density") +
theme(legend.position = "bottom")
Increasing \(\gamma\) leads to a time dependent period to the dynamics. As a result, the two models initially overlap, but they become increasingly out of sync over time. This explains both the periodicity of the sensitivity function and the increasing amplitude.