# Import project package
import Antibiotic
# Import basic math libraries
import StatsBase
import LinearAlgebra
import Random
import Distributions
# Load CairoMakie for plotting
using CairoMakie
import ColorSchemes
# Activate backend
activate!()
CairoMakie.
# Set custom plotting style
theme_makie!() Antibiotic.viz.
Energy-based evolutionary dynamics
evolutionary dynamics, fitness landscape, genotype-phenotype density, Metropolis-Hastings
(c) This work is licensed under a Creative Commons Attribution License CC-BY 4.0. All code contained herein is licensed under an MIT license.
Evolutionary dynamics on a fitness and genotype-phenotype density landscape
In this notebook we will explore a simple model of evolution on a fitness landscape with an explicit genotype-phenotype density function.
The setting is the following:
Let \(\underline{x}(t) = (x_1(t), x_2(t), \cdots, x_N(t))\) be an \(N\) dimensional vector of phenotypes at time \(t\). The fitness for a given environment \(E\) is a scalar function \(F_E(\underline{x})\) such that \[ F_E: \mathbb{R}^N \to \mathbb{R}, \tag{1}\]
i.e, the coordinates in phenotype space map to a fitness value. As a particular fitness function we will consider a series of Gaussian fitness peaks,
\[ F_E(\underline{x}) = \sum_{p=1}^P A_p \exp\left( -\frac{1}{2} (\underline{x} - \underline{\hat{x}}^{(p)})^T \Sigma_p^{-1} (\underline{x} - \underline{\hat{x}}^{(p)}) \right), \tag{2}\]
where \(P\) is the number of peaks, \(A_p\) is the amplitude of the \(p\)-th peak, \(\underline{\hat{x}}^{(p)}\) is the \(N\)-dimensional vector of optimal phenotypes for the \(p\)-th peak, and \(\Sigma_p\) is the \(N \times N\) covariance matrix for the \(p\)-th peak. This formulation allows for multiple peaks in the fitness landscape, each potentially having different heights, widths, and covariance structures between dimensions. The covariance matrix \(\Sigma_p\) captures potential interactions between different phenotypic dimensions, allowing for more complex and realistic fitness landscapes.
In the same vein, we define a genotype-phenotype density map \(GP(\underline{x})\) as a scalar function that maps a phenotype to a number related to the probability of sampling a given phenotype, i.e.,
\[ GP: \mathbb{R}^N \to \mathbb{R}, \tag{3}\]
where \(GP(\underline{x})\) is related to the probability of a genotype mapping to a phenotype \(\underline{x}\). As a particular genotype-phenotype density function we will consider a set of negative Gaussian peaks that will serve as “mutational barriers” that limit the range of phenotypes that can be reached. The deeper the peak, the more difficult it is to reach the phenotype associated with that peak. This idea is captured by the following genotype-phenotype density function:
\[ GP(\underline{x}) = \sum_{p=1}^P -B_p \exp\left( -\frac{1}{2}(\underline{x} - \underline{\hat{x}}^{(p)})^T \Sigma_p^{-1} (\underline{x} - \underline{\hat{x}}^{(p)}) \right), \tag{4}\]
where \(B_p\) is the depth of the \(p\)-th peak, and \(\underline{\hat{x}}^{(p)}\) is the \(N\)-dimensional vector of optimal phenotypes for the \(p\)-th peak.
Both, the fitness landscape and the genotype-phenotype density function are built on Gaussian functions. Let’s therefore define a struct
to hold the parameters of a single peak.
@doc raw"""
GaussianPeak
A struct to hold the parameters of a single Gaussian peak.
# Fields
- `amplitude::AbstractFloat`: The amplitude of the peak.
- `mean::AbstractVector`: The mean of the peak.
- `covariance::AbstractMatrix`: The covariance matrix of the peak.
"""
struct GaussianPeak
::AbstractFloat
amplitude::AbstractVector
mean::AbstractMatrix
covarianceend
Next, let’s define a function to evaluate the fitness landscape at a given phenotype.
@doc raw"""
fitness(peak::GaussianPeak, x::AbstractVecOrMat; min_value::AbstractFloat=0.0)
fitness(peaks::Vector{GaussianPeak}, x::AbstractVecOrMat; min_value::AbstractFloat=0.0)
Calculate the fitness value for a given phenotype `x` based on Gaussian peak(s).
# Arguments
- `peak::GaussianPeak`: A single Gaussian peak.
- `peaks::Vector{GaussianPeak}`: A vector of Gaussian peaks.
- `x::AbstractVecOrMat`: The phenotype(s) for which to calculate the fitness.
Can be a vector for a single phenotype or a matrix for multiple phenotypes,
where each column corresponds to a phenotype.
- `min_value::AbstractFloat=1.0`: The minimum fitness value to be added to the
Gaussian contribution.
# Returns
The calculated fitness value(s).
# Description
The first method computes the fitness for a single Gaussian peak, while the
second method computes the sum of fitness values for multiple Gaussian peaks. In
both cases, the `min_value` is added to the Gaussian contribution to ensure a
minimum fitness level.
"""
function fitness(
::GaussianPeak, x::AbstractVecOrMat; min_value::AbstractFloat=1.0
peak
)# Calculate the Gaussian peak
= peak.amplitude * exp(
gaussian -0.5 *
- peak.mean)' *
(x LinearAlgebra.inv(peak.covariance) *
- peak.mean)
(x
)# Return the fitness by shifting the Gaussian peak by the minimum value
return gaussian + min_value
end
function fitness(
::Vector{GaussianPeak},
peaks::AbstractVecOrMat;
x::AbstractFloat=1.0
min_value
)# Sum the Gaussian contributions from all peaks
= sum(
total_gaussian *
peak.amplitude exp(
-0.5 *
- peak.mean)' *
(x LinearAlgebra.inv(peak.covariance) *
- peak.mean)
(x
)for peak in peaks
)# Return the fitness by shifting the Gaussian peak by the minimum value
return total_gaussian + min_value
end
Let’s test the fitness function with a single Gaussian peak.
# Define peak parameters
= 1.0
amplitude = [0.0, 0.0]
mean = [0.5 0.0; 0.0 0.5]
covariance
# Create peak
= GaussianPeak(amplitude, mean, covariance)
fit_peak
# Define range of phenotypes to evaluate
= range(-2, 2, length=100)
x = range(-2, 2, length=100)
y
# Create meshgrid
= fitness.(Ref(fit_peak), [[x, y] for x in x, y in y])
F
# Initialize figure
= Figure(size=(700, 300))
fig
# Add axis for 3D plot
= Axis3(
ax3 1, 1],
fig[="phenotype 1",
xlabel="phenotype 2",
ylabel="fitness",
zlabel=false,
yticklabelsvisible=false,
xticklabelsvisible=false,
zticklabelsvisible
)
# Add axis for contour plot
= Axis(
ax2 1, 2],
fig[="phenotype 1",
xlabel="phenotype 2",
ylabel=AxisAspect(1),
aspect=false,
yticklabelsvisible=false,
xticklabelsvisible
)
# Plot fitness landscape
surface!(ax3, x, y, F, color=F, colormap=:algae)
# Plot a heatmap of the fitness landscape
= heatmap!(ax2, x, y, F, colormap=:algae)
hm
# Add a colorbar
Colorbar(
1, 3],
fig[
hm,="fitness",
label=13,
labelsize=Relative(1),
height=false,
ticksvisible=false
ticklabelsvisible
)# Plot contour plot
contour!(ax2, x, y, F, color=:white)
fig
Next, let’s define a function to evaluate the genotype-phenotype density function at a given phenotype.
@doc raw"""
genotype_phenotype_density(
peak::Union{GaussianPeak, Vector{GaussianPeak}},
x::AbstractVecOrMat;
max_value::Float64 = 1.0
)
Calculate the genotype-phenotype density value for a given phenotype `x` based
on Gaussian peak(s).
# Arguments
- `peak::Union{GaussianPeak, Vector{GaussianPeak}}`: A single Gaussian peak or a
vector of Gaussian peaks.
- `x::AbstractVecOrMat`: The phenotype(s) for which to calculate the genotype-
phenotype density. Can be a vector for a single phenotype or a matrix for
multiple phenotypes, where each column corresponds to a phenotype.
- `max_value::Float64`: Optional. The maximum value of the genotype-phenotype
density. Default is 1.0.
1.0.
# Returns
The calculated genotype-phenotype density value(s).
"""
function genotype_phenotype_density(
::GaussianPeak, x::AbstractVecOrMat; max_value::AbstractFloat=1.0
peak
)# Calculate the Gaussian peak
= peak.amplitude * exp(
gaussian -0.5 *
- peak.mean)' *
(x LinearAlgebra.inv(peak.covariance) *
- peak.mean)
(x
)# Return the genotype-phenotype density by inverting the Gaussian peak
# shifted by the maximum value
return -(gaussian - max_value)
end
function genotype_phenotype_density(
::Vector{GaussianPeak},
peaks::AbstractVecOrMat;
x::AbstractFloat=1.0
max_value
)# Sum the Gaussian contributions from all peaks
= sum(
total_gaussian *
peak.amplitude exp(
-0.5 *
- peak.mean)' *
(x LinearAlgebra.inv(peak.covariance) *
- peak.mean)
(x
)for peak in peaks
)# Return the genotype-phenotype density by inverting the Gaussian peak
# shifted by the maximum value
return -(total_gaussian - max_value)
end
Let’s test the genotype-phenotype density function with four Gaussian peaks in the corners of a square in phenotype space.
# Define peak parameters
= 0.5
amplitude = [
means -1.5, -1.5],
[1.5, -1.5],
[1.5, 1.5],
[-1.5, 1.5],
[
]= [0.45 0.0; 0.0 0.45]
covariance
# Create peak
= GaussianPeak.(Ref(amplitude), means, Ref(covariance))
mut_peaks
# Define range of phenotypes to evaluate
= range(-3, 3, length=100)
x = range(-3, 3, length=100)
y
# Create meshgrid
= genotype_phenotype_density.(Ref(mut_peaks), [[x, y] for x in x, y in y])
M
# Initialize figure
= Figure(size=(700, 300))
fig
# Add axis for 3D plot
= Axis3(
ax3 1, 1],
fig[="phenotype 1",
xlabel="phenotype 2",
ylabel="∝ probability of \nphenotype",
zlabel=false,
yticklabelsvisible=false,
xticklabelsvisible=false,
zticklabelsvisible
)
# Add axis for contour plot
= Axis(
ax2 1, 2],
fig[="phenotype 1",
xlabel="phenotype 2",
ylabel=AxisAspect(1),
aspect=false,
yticklabelsvisible=false,
xticklabelsvisible
)
# Plot genotype-phenotype density
surface!(ax3, x, y, M, color=M, colormap=Reverse(ColorSchemes.Purples_9))
# Plot a heatmap of the genotype-phenotype density
= heatmap!(ax2, x, y, M, colormap=Reverse(ColorSchemes.Purples_9))
hm
# Add a colorbar
Colorbar(
1, 3],
fig[
hm,="∝ probability of phenotype",
label=13,
labelsize=Relative(1),
height=false,
ticksvisible=false
ticklabelsvisible
)
# Plot contour plot
contour!(ax2, x, y, M, color=:white)
fig
Evolutionary dynamics
Metropolis-Hastings Algorithm in Evolutionary Dynamics
The Metropolis-Hastings algorithm is a Markov Chain Monte Carlo (MCMC) method used to sample from a target probability distribution \(\pi(\underline{x})\). In our evolutionary context, \(\pi(\underline{x})\) can be thought of as the steady-state distribution of phenotypes under the combined influence of selection and mutation. We can define this distribution as:
\[ \pi(\underline{x}) \propto \exp\left( -\beta U(\underline{x}) \right), \tag{5}\]
here, \(\beta\) is an inverse temperature parameter (from statistical mechanics) that controls the level of stochasticity in the system. A higher \(\beta\) means the system is more likely to move towards higher fitness and mutational accessibility.
Proposal Distribution
At each step, we propose a new phenotype \(\underline{x}{\prime}\) from a proposal distribution \(q(\underline{x}{\prime} | \underline{x})\). For simplicity, we can use a symmetric proposal distribution, such as a Gaussian centered at the current phenotype. Furthere, we constrain the proposal distribution to be symmetric, i.e., \[ q(x | x') = q(x' | x). \tag{6}\]
This symmetry simplifies the acceptance probability later on.
Acceptance Probability
The acceptance probability \(P_{\text{accept}}\) for moving from \(\underline{x}\) to \(\underline{x}{\prime}\) is given by:
\[ P_{\text{accept}} = \min\left(1, \frac{\pi(\underline{x}{\prime}) q(\underline{x} | \underline{x}{\prime})} {\pi(\underline{x}) q(\underline{x}{\prime} | \underline{x})} \right). \tag{7}\]
Given the symmetry of the proposal distribution, \(q(\underline{x}{\prime} | \underline{x}) = q(\underline{x} | \underline{x}{\prime})\) , so the acceptance probability simplifies to:
\[ P_{\text{accept}} = \min\left( 1, \frac{\pi(\underline{x}{\prime})}{\pi(\underline{x})} \right) = \min\left( 1, e^{\beta [\ln F_E(\underline{x}{\prime}) + \ln M(\underline{x}{\prime})] - \beta [\ln F_E(\underline{x}) + \ln M(\underline{x})]} \right). \tag{8}\]
This can be rewritten as \[ P_{\text{accept}} = \min\left( 1, \frac{F_E(\underline{x}{\prime})^β M(\underline{x}{\prime})^β} {F_E(\underline{x})^β M(\underline{x})^β} \right), \tag{9}\]
This expression shows that the acceptance probability depends on the difference in fitness and mutational accessibility between the current and proposed phenotypes.
Let’s now implement the Metropolis-Hastings algorithm in code.
@doc raw"""
evo_metropolis_hastings(x0, fitness_peaks, mut_peaks, β, µ, n_steps)
Perform evolutionary Metropolis-Hastings algorithm to simulate phenotypic
evolution.
# Arguments
- `x0::AbstractVecOrMat`: Initial phenotype vector or matrix.
- `fitness_peaks::Union{GaussianPeak, Vector{GaussianPeak}}`: Fitness landscape
defined by one or more Gaussian peaks.
- `mut_peaks::Union{GaussianPeak, Vector{GaussianPeak}}`: genotype-phenotype
density function defined by one or more Gaussian peaks.
- `β::AbstractFloat`: Inverse temperature parameter controlling selection
strength.
- `µ::AbstractFloat`: Mutation step size standard deviation.
- `n_steps::Int`: Number of steps to simulate.
# Returns
- `Matrix{Float64}`: Matrix of phenotypes, where each column represents a step
in the simulation.
# Description
This function implements the Metropolis-Hastings algorithm to simulate
phenotypic evolution in a landscape defined by fitness and mutational
accessibility. It uses the following steps:
1. Initialize the phenotype trajectory with the given starting point.
2. For each step:
a. Propose a new phenotype by adding Gaussian noise.
b. Calculate the fitness and genotype-phenotype density values for the new
phenotype.
c. Compute the acceptance probability based on the ratio of new and current
values.
d. Accept or reject the proposed phenotype based on the acceptance
probability.
3. Return the complete phenotype trajectory.
The acceptance probability is calculated using the simplified form:
P_accept = min(1, (F_E(x_new) * GP(x_new)) / (F_E(x) * GP(x)))
where F_E is the fitness function and GP is the genotype-phenotype density
function.
"""
function evo_metropolis_hastings(
::AbstractVector,
x0::Union{GaussianPeak,Vector{GaussianPeak}},
fitness_peaks::Union{GaussianPeak,Vector{GaussianPeak}},
mut_peaks::Real,
β::Real,
µ::Int,
n_steps
)# Initialize array to hold phenotypes
= Matrix{Float64}(undef, length(x0), n_steps + 1)
x # Set initial phenotype
:, 1] = x0
x[
# Compute fitness and genotype-phenotype density at initial phenotype
= fitness(fitness_peaks, x0)
fitness_val = genotype_phenotype_density(mut_peaks, x0)
mut_val
# Loop over steps
for t in 1:n_steps
# Propose new phenotype
= x[:, t] + µ * randn(length(x0))
x_new
# Calculate fitness and genotype-phenotype density
= fitness(fitness_peaks, x_new)
fitness_val_new = genotype_phenotype_density(mut_peaks, x_new)
mut_val_new
# Compute acceptance probability
= min(
P_accept 1, (fitness_val_new * mut_val_new / (fitness_val * mut_val))^β
)
# Accept or reject proposal
if rand() < P_accept
# Accept proposal
:, t+1] = x_new
x[# Update fitness and genotype-phenotype density
= fitness_val_new
fitness_val = mut_val_new
mut_val else
# Reject proposal
:, t+1] = x[:, t]
x[end
end # for
return x
end # function
Let’s test this function with the previously defined fitness and genotype-phenotype density functions.
Random.seed!(42)
# Define peak parameters
= 5.0
amplitude = [0.0, 0.0]
mean = [3.0 0.0; 0.0 3.0]
covariance # Create peak
= GaussianPeak(amplitude, mean, covariance)
fit_peak # Define peak parameters
= 1.0
amplitude = [
means -1.5, -1.5],
[1.5, -1.5],
[1.5, 1.5],
[-1.5, 1.5],
[
]= [0.45 0.0; 0.0 0.45]
covariance
# Create peak
= GaussianPeak.(Ref(amplitude), means, Ref(covariance))
mut_peaks
# Set initial phenotype
= [-2.5, -2.5]
x0
# Set parameters
= 10.0
β = 0.1
µ = 300
n_steps
# Run Metropolis-Hastings algorithm
= evo_metropolis_hastings(x0, fit_peak, mut_peaks, β, µ, n_steps) x_traj
Let’s plot the trajectory of the phenotypes in phenotype space.
Random.seed!(42)
# Define range of phenotypes to evaluate
= range(-4, 4, length=100)
x = range(-4, 4, length=100)
y
# Create meshgrid
= fitness.(Ref(fit_peak), [[x, y] for x in x, y in y])
F = genotype_phenotype_density.(Ref(mut_peaks), [[x, y] for x in x, y in y])
M
# Initialize figure
= Figure(size=(600, 300))
fig
# Add axis for trajectory in fitness landscape
= Axis(
ax1 1, 1],
fig[="phenotype 1",
xlabel="phenotype 2",
ylabel=AxisAspect(1),
aspect="Fitness landscape",
title=false,
yticklabelsvisible=false,
xticklabelsvisible
)# Add axis for trajectory in genotype-phenotype density
= Axis(
ax2 1, 2],
fig[="phenotype 1",
xlabel="phenotype 2",
ylabel=AxisAspect(1),
aspect="Genotype-phenotype\nDensity",
title=false,
yticklabelsvisible=false,
xticklabelsvisible
)
# Plot a heatmap of the fitness landscape
heatmap!(ax1, x, y, F, colormap=:algae)
# Plot heatmap of genotype-phenotype density
heatmap!(ax2, x, y, M, colormap=Reverse(ColorSchemes.Purples_9))
# Plot contour plot
contour!(ax1, x, y, F, color=:white)
contour!(ax2, x, y, M, color=:white)
# Plot trajectory
scatterlines!.(
[ax1, ax2],Ref(x_traj[1, :]),
Ref(x_traj[2, :]),
=ColorSchemes.seaborn_colorblind[4],
color=5
markersize
)
fig
Let’s plot the energy for the phenotypes in the trajectory.
# Initialize array to hold energy
= Vector{Float64}(undef, n_steps + 1)
U
# Loop over steps
for (i, x) in enumerate(eachcol(x_traj))
= -log(fitness(fit_peak, x)) - log(genotype_phenotype_density(mut_peaks, x))
U[i] end
# Initialize figure
= Figure(size=(350, 300))
fig
# Add axis for energy
= Axis(
ax 1, 1],
fig[="step",
xlabel="energy",
ylabel=false,
yticklabelsvisible
)
# Plot energy
lines!(ax, U)
fig
Let’s now repeat the simulation multiple times and plot all different trajectories.
Random.seed!(42)
# Define number of simulations
= 5
n_sim
# Run simulations
= [
x_traj_list evo_metropolis_hastings(x0, fit_peak, mut_peaks, β, µ, n_steps)
in 1:n_sim
for _
]
# Initialize figure
= Figure(size=(600, 300))
fig
# Add axis for fitness landscape
= Axis(
ax1 1, 1],
fig[="phenotype 1",
xlabel="phenotype 2",
ylabel=AxisAspect(1),
aspect="Fitness",
title=false,
yticklabelsvisible=false,
xticklabelsvisible
)# Add axis for genotype-phenotype density
= Axis(
ax2 1, 2],
fig[="phenotype 1",
xlabel="phenotype 2",
ylabel=AxisAspect(1),
aspect="Genotype-phenotype\nDensity",
title=false,
yticklabelsvisible=false,
xticklabelsvisible
)
# Plot fitness landscape
heatmap!(ax1, x, y, F, colormap=:algae)
# Plot heatmap of genotype-phenotype density
heatmap!(ax2, x, y, M, colormap=Reverse(ColorSchemes.Purples_9))
# Plot contour plot
contour!(ax1, x, y, F, color=:white)
contour!(ax2, x, y, M, color=:white)
# Loop over simulations
for (i, x_traj) in enumerate(x_traj_list)
# Plot trajectory
scatterlines!.(
[ax1, ax2],Ref(x_traj[1, :]),
Ref(x_traj[2, :]),
=ColorSchemes.seaborn_colorblind[i],
color=3
markersize
)end
# Set limits
xlims!(ax1, -4, 4)
ylims!(ax1, -4, 4)
xlims!(ax2, -4, 4)
ylims!(ax2, -4, 4)
fig
As desired, all trajectories climb up the fitness peak avoiding the low genotype-phenotype density regions.
Conclusion
In this notebook we proposed a simple model for evolutionary dynamics inspired on the classic Metropolis-Hastings algorithm. We used this model to simulate the evolution of a phenotype in a fitness landscape with an explicit genotype-phenotype density function. The simple energy-based model allows us to integrate both fitness and genotype-phenotype density in a single framework. This model is the basis for the simulations presented in the main text of the paper.