Lotka-Volterra System#

BingKan Xue, PHZ4710 - Introduction to Biological Physics, University of Florida

Consider an ecological system involving a predator species and a prey species. We would like to study the population dynamics of this ecosystem. In particular, we are interested in whether one species goes extinct, or both species can coexist at some equilibrium population sizes, or something else.

Let us first model the dynamics of the system. Imagine that the prey species lives on external food sources, such as plants, which are not explicitly modeled. We will assume that the prey population grows at a constant per capita growth rate, \(r\). This will be the net growth rate, i.e., the difference between birth and death rates, like in the birth-death process we learned before. In addition, the prey are being consumed by the predators, which results in population loss. Since a predation event requires an encounter between a predator and prey, we may assume that the overall consumption rate of prey is proportional to both the predator and prey abundances, with a rate constant given by a “feeding rate”, \(f\). On the other hand, the predator population will grow at a rate proportional to the consumption rate of prey; we will assume a proportionality constant \(\eta\), which represents the efficiency of converting the biomass of prey to predators. Finally, we assume a constant per capita death rate of the predator, \(d\).

We may represent the processes described above by chemical reactions like we did before in class. Denote the predator and prey by P and Q respectively, we have:

(16)#\[\begin{align} Q &\xrightarrow{\beta} 2 Q \\ Q &\xrightarrow{\delta} \emptyset \\ Q + P &\xrightarrow{f} (1 + \eta) P \\ P &\xrightarrow{d} \emptyset \end{align}\]

The third reaction is central — it describes the trophic interaction (predation) between the two species. Here \(\eta\) is not really a stoichiometric coefficient as it does not have to be an integer. So we will not make stochastic simulations, but only study the rate equations. You should be able to derive:

(17)#\[\begin{align} \dot{N}_Q &= (\beta - \delta) N_Q - f N_Q N_P \\ \dot{N}_P &= \eta f N_Q N_P - d N_P \end{align}\]

For simplicity of notations, let us denote the abundances of the predator and prey by \(Y\) and \(X\) instead of \(N_P\) and \(N_Q\), and also let \(r \equiv (\beta - \delta)\) and \(g \equiv \eta f\). Then our equations become:

(18)#\[\begin{align} \dot{X} &= r X - f X Y \\ \dot{Y} &= g X Y - d Y \end{align}\]

These are known as the “Lotka-Volterra equations”.

Steady states and stability#

Let us first find the steady states of the dynamical equations above. They would be solutions to the equations:

(19)#\[\begin{align} \dot{X} &= X (r - f Y) = 0 \\ \dot{Y} &= Y (g X - d) = 0 \end{align}\]

We have two such states: \((X^*, Y^*) = (0, 0)\) and \((d/g, r/f)\). The first steady state simply means both species go extinct. The second steady state is where they coexist in equilibrium.

To check the stability of these steady states, we expand \(X, Y\) around their equilibrium values, \(X = X^* + \delta X\) and \(Y = Y^* + \delta Y\). Inserting these into the original dynamical equations, you should find:

(20)#\[\begin{equation} \frac{d}{dt} \left( \begin{array}{c} \delta X \\ \delta Y \end{array} \right) = \left( \begin{array}{cc} r - f Y^* & -f X^* \\ g Y^* & g X^* -d \end{array} \right) \left( \begin{array}{c} \delta X \\ \delta Y \end{array} \right) \equiv \mathbf{M} \cdot \left( \begin{array}{c} \delta X \\ \delta Y \end{array} \right) \end{equation}\]

The eigenvalues of the Jacobian matrix \(\mathbf{M}\) will determine the stability of the steady states.

We will evaluate the matrix at both steady states and find their eigenvalues. Eigenvalues of a matrix can be numerically computed using the linear algebra module numpy.linalg, as follows.

import numpy as np
import scipy.integrate as intgr
import matplotlib.pyplot as plt
r = f = g = d = 1.    # simple values just for illustration

def mat(X, Y):    # calculate Jacobian matrix
    M = np.array([[r-f*Y, -f*X],
                  [g*Y, g*X-d]])
    return M

X0, Y0 = 0, 0    # steady state at (0, 0)
X1, Y1 = d/g, r/f    # the other steady state

for (X,Y) in [(X0,Y0), (X1,Y1)]:
    w, v = np.linalg.eig(mat(X,Y))    # this function calculates all eigenvalues and eigenvectors
    print(f'steady state at {(X, Y)}: eigenvalues = {w}')
steady state at (0, 0): eigenvalues = [ 1. -1.]
steady state at (1.0, 1.0): eigenvalues = [0.+1.j 0.-1.j]

In fact, one can calculate analytically that, for \((X^*, Y^*) = (0, 0)\), the eigenvalues of \(\mathbf{M}\) are \(\lambda_1 = r > 0\) and \(\lambda_2 = -d < 0\). The presence of the positive eigenvalue means this steady state is unstable. Indeed, if both species are absent, then adding a few preys would allow them to start growing.

On the other hand, for \((X^*, Y^*) = (d/g, r/f)\), the eigenvalues are \(\lambda = \pm i \sqrt{r d}\). It may look strange that the eigenvalues are imaginary; they actually imply that the flow around the steady state is rotating around this point. The absence of a real part in the eigenvalues implies that this point is neutrally stable, such that \((X,Y)\) will circle around this point indefinitely (see below). Were there a positive real part, the flow would be spiraling out; or if the real part were negative, then the flow would be spiraling in. We can plot the flow using streamplot() as before.

x_points = np.arange(0.1, 2., 0.2)    # grid lines for x-axis
y_points = np.arange(0.1, 2., 0.2)    # grid lines for y-axis
x_grid, y_grid = np.meshgrid(x_points, y_points)    # generate a grid of x, y values

x_flow = x_grid * (r - f * y_grid)
y_flow = y_grid * (g * x_grid - d)

plt.figure()
plt.streamplot(x_grid, y_grid, x_flow, y_flow)
plt.xlim(0, 2)
plt.ylim(0, 2)
plt.xlabel(r'$X$')
plt.ylabel(r'$Y$')
plt.show()
../_images/Lotka-Volterra_10_0.png

Closed orbits and oscillations#

Let us visualize the population dynamics by plotting the trajectories in the \(X\)-\(Y\) plane. As before, we will have to numerically solve the dynamical equations. We will define a Python class for the simulation, so that we can easily change parameters and initial values later. You will notice that the class below is written very similarly to the RateEquations class we had before. The main difference is that the equations() are different.

class LotkaVolterra:
    """
    simulation the Lotka-Volterra system.
    """
    
    def __init__(self, param, init, record=True):
        """
        initialize by assigning parameter values and initial values.
        inputs:
        param: list, parameters of the model: r, f, c, d (in this order)
        init: list, initial abundance of prey and predator species (in this order)
        record: boolean, whether to record history of abundances at time points
        """
        self.param = param    # list of parameters
        self.abundance = np.asarray(init)    # current abundance of each species
        self.time = 0.                  # time since beginning of simulation
        self.record = record            # whether to record time series
        if self.record:
            self.time_hist = [0.]                  # list of time points
            self.abundance_hist = [self.abundance.copy()]     # list of abundances at time points
    
    def equations(self, x, t):
        """
        calculate time derivatives of abundances in the Lotka-Volterra system.
        inputs:
        x: 1-d array, current abundances of both species.
        t: float, current time.
        outputs:
        dxdt: 1-d array, time derivatives of abundances.
        """
        X, Y = x    # parse variables, X is prey and Y is predator
        dXdt = self.param[0] * X - self.param[1] * X * Y
        dYdt = self.param[2] * X * Y - self.param[3] * Y
        return [dXdt, dYdt]
    
    def run(self, tmax, dt):
        """
        solve equations until time `tmax` since the beginning of the simulation.
        inputs:
        tmax: float, time since the beginning of the simulation.
        dt: float, time step by which solution is calculated
        """
        T = tmax - self.time    # time remaining to be solved
        new_times = np.arange(0, T+dt, dt)    # new time points at every step dt
        x0 = self.abundance    # current abundances as initial values to the solver
        sol = intgr.odeint(self.equations, x0, new_times)    # solve equations using integrator
        if self.record:
            self.time_hist.extend(self.time + new_times[1:])    # save time points
            self.abundance_hist.extend(sol[1:])    # save abundances at given time points
        self.time += new_times[-1]    # update time to latest
        self.abundance = sol[-1]    # update abundances to latest

Let us choose some appropriate parameter values. Without loss of generality, we can set \(d = 1\) by rescaling time. Similarly, by using \(X^* = d/g\) and \(Y^* = r/f\) as units for the population sizes \(X\) and \(Y\), we effectively set \(X^* = Y^* = 1\), which then means \(g = 1\) and \(f = r\). Therefore, we are left with only one free parameter, \(r\), which controls the growth rate of the prey relative to the lifespan of the predator, i.e., the “turnover” rate. Consider the case of a large \(r\), e.g., \(r=5\), so that the prey grows fast and there is plenty food for the predator.

r = 5    # growth rate of the prey
f = r    # feeding rate of the predator
g = 1    # growth rate of the predator per prey available
d = 1    # death rate of the predator

num = 10    # number of trajectories to simulate
lv_list = []    # list of simulations with different initial values

T = 10.    # total time to integrate the trajectories
dt = 0.01   # time steps to evaluate the trajectories at

for i in range(num):
    X0, Y0 = np.random.rand(2)    # random initial values between 0 and 1
    lv = LotkaVolterra([r, f, g, d], [X0, Y0])
    lv.run(T, dt)
    print(f'current time = {lv.time}, current populations = {lv.abundance}')
    lv_list.append(lv)
current time = 10.0, current populations = [0.87452859 1.2575456 ]
current time = 10.0, current populations = [0.02309464 2.21439176]
current time = 10.0, current populations = [2.88186798 0.7367846 ]
current time = 10.0, current populations = [0.00853583 1.36676582]
current time = 10.0, current populations = [0.19213035 0.51763606]
current time = 10.0, current populations = [0.24346762 0.44547826]
current time = 10.0, current populations = [0.35949967 0.42082286]
current time = 10.0, current populations = [0.99448009 0.51700546]
current time = 10.0, current populations = [1.17020256 0.38491816]
current time = 10.0, current populations = [1.07941808 1.44391077]
plt.figure()
for lv in lv_list:
    time_hist = lv.time_hist    # time history, excluding t=0
    num_hist = np.array(lv.abundance_hist)    # abundance history of all species
    plt.plot(num_hist[:,0], num_hist[:,1])    # plot prey vs predator abundances
plt.xlabel(r'$X$')
plt.ylabel(r'$Y$')
plt.show()
../_images/Lotka-Volterra_16_0.png

We see a collection of closed orbits going around the neutrally stable point \((X^*, Y^*)\). If we plot \(X\) and \(Y\) as functions of time, we will see that they undergo some kind of “nonlinear oscillations”.

plt.figure()
plt.plot(time_hist, num_hist[:,0], label='X')    # plot prey abundance vs time
plt.plot(time_hist, num_hist[:,1], label='Y')    # plot predator abundance vs time
plt.xlabel('time')
plt.ylabel('abundance')
plt.legend(loc='upper right')
plt.show()
../_images/Lotka-Volterra_18_0.png

Notice that the phase of \(Y(t)\) is behind that of \(X(t)\), which means that the closed orbits in the previous plot go in the counterclockwise direction. This can be explained by the intuitive argument that goes like: first the prey grows in number, then the predator has more food to eat and grows in number, then the prey is consumed fast and reduces in number, then the predator runs out of food and reduces in number, and finally the prey gets the chance to grow again, etc. Indeed, a main point that the predator-prey model was used to illustrate is that, in nature, the dynamics of populations (and many other things in biology) do not have to reach an equilibrium state.

The existence of closed orbits in a dynamical system is a nontrivial result. Here we even have a family of closed orbits as the distance from the neutrally stable point varies. This is in fact because the particular equations of the Lotka-Volterra system has a symmetry, which leads to a conservation law, like in classical mechanics. The conserved quantity (or “integral of motion”) here is \(I = g x - d \log(x) + f y - r \log(y)\).

Exercise: Check that \(dI/dt = 0\) using the dynamical equations.

Limit Cycle#

The closed orbits found above share one property in common with the steady states. Once the system is on a particular orbit, it never leaves the orbit. This is similar to a steady state, as once the system is at a particular steady state, it never leaves there. For steady states, we studied their stability, i.e., if there is a small perturbation away from the steady state, whether the system will return to it. We can ask a similar question for the closed orbits, i.e., if the system is perturbed away from an orbit, will it settle back on it.

This is not true for the simple model above. As we have seen, there is a family of closed orbits around the steady state \((X^*, Y^*)\), distinguished by their distance from the steady state. If we push the system a little bit off one orbit to a nearby point, it will simply settle on another orbit that passes through the new point. Therefore, under perturbations, the system will drift from one orbit to another, instead of staying close to a particular orbit.

However, small modifications of the original Lotka-Volterra equations would lead to a different behavior. To motivate the modifications, notice that in the original model, the constant per capita growth rate of the prey means that the prey abundance can grow exponentially and indefinitely in the absence of the predator. In reality, there is limited amount of resources that can only support a finite population size. To address this problem, we may introduce competition between the prey individuals (recall the birth-death-competition process in homework3). This will amount to changing the overall growth rate of the prey abundance from \(r X\) to \(r X \cdot (1-X/C)\), where the new parameter \(C\) represents the “carrying capacity” for the prey. The factor \((1-X/C)\) guarantees that the growth rate reduces to \(0\) when \(X\) approaches \(C\), so that the prey abundance can never exceed the carrying capacity.

Similarly, consider the consumption rate of the prey by the predator. We assumed above that the overall consumption rate is proportional to both the predator and prey abundances. That means if the prey abundance becomes very large somehow, the consumption rate may in principle increase without bound (besides the limit coming from the carrying capacity above). This is not realistic, because beyond a certain point the predators would be “satiated”, unable to consume the prey any faster. Therefore, let us modify the consumption rate from \(f X Y\) to \(f X Y \cdot K/(X+K)\). The new factor \(K/(X+K)\) makes sure that, when \(X\) is large, the consumption rate saturates at a level \(f K Y\); on the other hand, when \(X\) is small, the factor goes to \(1\) and we recover \(f X Y\). This modified consumption rate is called a “type II functional response” in ecology, whereas the original mass-action form is called “type I”. The new parameter \(K\) can be viewed as the “consumption capacity” for the prey by the predator.

After these modifications, our dynamical equations become:

(21)#\[\begin{align} \dot{X} &= r X \Big( 1 - \frac{X}{C} \Big) - f X Y \Big( \frac{K}{X + K} \Big) \\ \dot{Y} &= g X Y \Big( \frac{K}{X + K} \Big) - d Y \end{align}\]

To simulate the modified model, we can define a derived class of the original LotkaVolterra class. All we need is to decorate the equations() method.

class ModifiedLotkaVolterra(LotkaVolterra):
    """
    modify the predator-prey model by adding prey carrying capacity and predator satiation.
    """
    
    def equations(self, x, t):
        """
        calculate time derivatives of abundances in modified Lotka-Volterra equations.
        inputs:
        x: 1-d array, current abundances of both species.
        t: float, current time.
        outputs:
        dxdt: 1-d array, time derivatives of abundances.
        """
        X, Y = x    # parse variables, X is prey and Y is predator
        factor1 = (1 - X / self.param[4])    # param[4] is C
        factor2 = (self.param[5] / (X + self.param[5]))    # param[5] is K
        dXdt = self.param[0] * X * factor1 - self.param[1] * X * Y * factor2
        dYdt = self.param[2] * X * Y * factor2 - self.param[3] * Y
        return [dXdt, dYdt]

Note that we now have two new parameters \(C\) and \(K\). The new behavior that we will look for happens when \(K > d/g\) and \(C > K(gK+d)/(gK-d)\).

r = 5    # growth rate of the prey
f = r    # feeding rate of the predator
g = 1    # growth rate of the predator per prey available
d = 1    # death rate of the predator
C = 8    # carrying capacity
K = 3    # consumption capacity

num = 10    # number of trajectories to simulate
mlv_list = []    # list of simulations with different initial values

T = 20.    # total time to integrate the trajectories
dt = 0.01   # time steps to evaluate the trajectories at

for i in range(num):
    X0, Y0 = np.random.rand(2)    # random initial values between 0 and 1
    mlv = ModifiedLotkaVolterra([r, f, g, d, C, K], [X0, Y0])
    mlv.run(T, dt)
    print(f'current time = {mlv.time}, current populations = {mlv.abundance}')
    mlv_list.append(mlv)
current time = 20.01, current populations = [4.40982046 1.4849132 ]
current time = 20.01, current populations = [4.97191148 1.26379635]
current time = 20.01, current populations = [3.70453114 0.53408085]
current time = 20.01, current populations = [4.34069083 0.58346939]
current time = 20.01, current populations = [5.46204342 0.88250225]
current time = 20.01, current populations = [1.53938635 0.45896019]
current time = 20.01, current populations = [5.14479312 0.70098578]
current time = 20.01, current populations = [4.80874892 0.63805994]
current time = 20.01, current populations = [1.38262881 0.45931313]
current time = 20.01, current populations = [5.40494704 1.00357746]
plt.figure()
for mlv in mlv_list:
    time_hist = mlv.time_hist    # time history, excluding t=0
    num_hist = np.array(mlv.abundance_hist)    # abundance history of all species
    plt.plot(num_hist[:,0], num_hist[:,1])    # plot prey vs predator abundances
    plt.plot([num_hist[0,0]], [num_hist[0,1]], 'b^')    # blue trianble labels starting point of each trajectory
    plt.plot(num_hist[-500:,0], num_hist[-500:,1], 'k')    # black line labels last period of each trajectory
plt.xlabel(r'$X$')
plt.ylabel(r'$Y$')
plt.show()
../_images/Lotka-Volterra_28_0.png

We see that all trajectories converge to a single closed orbit, marked black in the above figure. If a trajectory starts outside that orbit, it will spiral inward and asymptotically approach the orbit from outside. And if a trajectory starts inside that orbit, it will spiral outward and asymptotically approach the orbit from inside. Such an isolated closed orbit (i.e., having no other closed orbits in its neighborhood) is called a “limit cycle”. What we have here is a stable limit cycle because it “attracts” nearby trajectories. (One could also have unstable limit cycles from which a small perturbation leads to the trajectory spiraling away.)

Steady states and limit cycles are common features of dynamical systems. They share the property that once the system is on such a state or trajectory, it will remain so indefinitely. Stable steady states and limit cycles both belong to a class of objects called “attractors” in dynamical systems.