# Import custom project package
import Antibiotic
import Antibiotic.mh as mh # Metropolis-Hastings dynamics module
# Import packages for storing results
import DimensionalData as DD
# Import JLD2 for saving results
import JLD2
# Import IterTools for iterating over Cartesian products
import IterTools
# Import basic math libraries
import StatsBase
import LinearAlgebra
import Random
import Distributions
import Distances
# Packages for visualization
using CairoMakie
import ColorSchemes
# Activate Makie backend
activate!()
CairoMakie.
# Set custom plotting style
theme_makie!()
Antibiotic.viz.
# Set random seed for reproducibility
Random.seed!(42)
Evolutionary Dynamics Simulation
evolutionary dynamics, fitness landscapes, genotype-phenotype density, Metropolis-Hastings evolutionary algorithm
(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 Simulation
In this notebook, we simulate the dynamics of populations evolving in a 2-dimensional phenotypic space under various fitness landscapes and a fixed mutational landscape. We’ll use the Metropolis-Hastings algorithm to model evolution as a biased random walk influenced by both fitness and mutational constraints.
Setup Environment
First, let’s import the necessary packages for our simulation.
Simulation Parameters
Let’s define the parameters that govern our evolutionary simulation. The things we need to define are:
- The dimensionality of the phenotype space
n_dim
- The number of phenotypic initial conditions, i.e., number of lineages (positions in phenotype space)
n_sim
- The number of replicates (evolving strains per lineage)
n_rep
- The inverse temperature (controls selection strength)
β
- The mutation step size
µ
- The number of evolution steps
n_steps
- The number of fitness landscapes
n_fit_lans
- The range of peak means
peak_mean_min
andpeak_mean_max
- The range of fitness amplitudes
fit_amp_min
andfit_amp_max
- The range of fitness covariances
fit_cov_min
andfit_cov_max
- The number of fitness peaks
n_fit_peaks_min
andn_fit_peaks_max
# Phenotype space dimensionality
= 2
n_dim # Number of initial conditions (positions in phenotype space)
= 10
n_sim # Number of replicates (evolving strains per initial condition)
= 2
n_rep # Inverse temperature (controls selection strength)
= 10.0
β # Mutation step size
= 0.1
µ # Number of evolution steps
= 300
n_steps
# Define number of fitness landscapes
= 50
n_fit_lans
# Define range of peak means
= -4.0
peak_mean_min = 4.0
peak_mean_max
# Define range of fitness amplitudes
= 1.0
fit_amp_min = 5.0
fit_amp_max
# Define covariance range
= 3.0
fit_cov_min = 10.0
fit_cov_max
# Define possible number of fitness peaks
= 1
n_fit_peaks_min = 4 n_fit_peaks_max
Having defined these parameters, we must define the two functions that govern the dynamics of the system:
- The genotype-phenotype density function \(GP(\underline{p})\)
- The fitness functions \(F_E(\underline{p})\) for each environment \(E\).
Let’s start by defining the genotype-phenotype density function.
Genotype-Phenotype Density Function
The genotype-phenotype density function \(GP(\underline{p})\) represents constraints on phenotypic evolution due to the underlying genotype-phenotype mapping. It defines which regions of phenotypic space are more or less accessible to mutations.
In our simulation, we model the genotype-phenotype density function as a collection of inverted Gaussian peaks. These peaks define areas of phenotypic space with lower densities of possible mutations. In other words, we define four “wells” in which the genotype-phenotype density drops, making it more unlikely for mutations to take a population towards these regions.
For illustrative purposes, as done in the main text, we will define the genotype-phenotype density function as a collection of four Gaussian peaks, which we will place at the corners of a square in phenotype space.
# GP density peak amplitude
= 1.0
gp_evo_amplitude
# GP density peak means - defines 4 peaks in a square arrangement
= [
gp_means -1.5, -1.5],
[1.5, -1.5],
[1.5, 1.5],
[-1.5, 1.5],
[
]
# GP density peak covariance (controls spread of each peak)
= 0.45
gp_evo_covariance
# Create GP density peaks object using the `GaussianPeaks` constructor
= mh.GaussianPeaks(
gp_evo_peaks
gp_evo_amplitude,
gp_means,
gp_evo_covariance
)
# Define grid on which to evaluate GP density landscape
= range(peak_mean_min, peak_mean_max, length=100)
gp_evo_grid
# Evaluate GP density landscape on grid
= mh.genetic_density(
gp_evo_grid_points tuple(repeat([gp_evo_grid], n_dim)...),
gp_evo_peaks
)
# Define grid of possible initial conditions
= [[x...] for x in IterTools.product(fill(gp_evo_grid, 2)...)] init_grid
Let’s visualize this mutational landscape:
# Define ranges of phenotypes to evaluate
= range(-4, 4, length=100)
x = range(-4, 4, length=100)
y
# Initialize figure
= Figure(size=(350, 300))
fig
# Add axis
= Axis(
ax 1, 1],
fig[="phenotype 1",
xlabel="phenotype 2",
ylabel=AxisAspect(1),
aspect="Genotype-to-Phenotype\nDensity",
title=14,
titlesize=false,
yticklabelsvisible=false,
xticklabelsvisible
)
# Evaluate mutational landscape
= mh.genetic_density(x, y, gp_evo_peaks)
M
# Plot mutational landscape
= heatmap!(ax, x, y, M, colormap=Reverse(ColorSchemes.Purples_9))
hm_gp
# Add contour plot
contour!(ax, x, y, M, color=:white)
# Add colorbar for mutational landscape
Colorbar(
1, 2],
fig[
hm_gp,="genotype-phenotype\ndensity",
label=13,
labelsize=Relative(1),
height=false,
ticksvisible=false
ticklabelsvisible
)
fig
The mutational landscape shows four peaks (in darker purple) representing regions of phenotypic space that have lower accessibility through mutations. The lighter regions between these peaks represent “valleys” which are much easier for populations to traverse.
Let’s continue by defining the fitness landscapes that we’ll use to simulate evolutionary dynamics.
Fixed Evolution Condition
We first define a reference fitness landscape (evolution condition) that we’ll use as a baseline for comparison. This is the simplest possible fitness landscape with a single peak in the center of the phenotype space.
# Evolution condition amplitude
= 5.0
fit_evo_amplitude # Evolution condition mean
= [0.0, 0.0]
fit_evo_mean # Evolution condition covariance
= 3.0
fit_evo_covariance # Create peak
= mh.GaussianPeak(
fit_evo_peak
fit_evo_amplitude,
fit_evo_mean,
fit_evo_covariance )
Let’s visualize this reference fitness landscape:
# Initialize figure
= Figure(size=(350, 300))
fig
# Add axis
= Axis(
ax 1, 1],
fig[="phenotype 1",
xlabel="phenotype 2",
ylabel=AxisAspect(1),
aspect="reference fitness landscape",
title=14,
titlesize=false,
yticklabelsvisible=false,
xticklabelsvisible
)
# Evaluate fitness landscape
= mh.fitness(x, y, fit_evo_peak)
F
# Plot fitness landscape
= heatmap!(ax, x, y, F, colormap=:algae)
hm_fit
# Add contour plot
contour!(ax, x, y, F, color=:white)
# Add colorbar for fitness landscape
Colorbar(
1, 2],
fig[
hm_fit,="fitness",
label=13,
labelsize=Relative(1),
height=false,
ticksvisible=false
ticklabelsvisible
)
fig
This fitness landscape shows a single peak centered at [0, 0]
. The color represents fitness, with darker regions having higher fitness. The contour lines show equal fitness values.
Alternative Fitness Landscapes
To explore how evolution proceeds in different environments, we generate a variety of fitness landscapes with randomly selected numbers, positions, and shapes of fitness peaks. The sampled numbers of fitness peaks, positions, and amplitudes will be drawn from uniform distributions within the ranges defined above.
# Reset random seed for reproducibility
Random.seed!(42)
# Initialize array to hold fitness landscapes
= Array{mh.AbstractPeak}(undef, n_fit_lans)
fit_lans
# Add fixed evolution condition to fitness landscapes
1] = fit_evo_peak
fit_lans[
# Loop over number of fitness landscapes
for i in 2:n_fit_lans
# Sample number of fitness peaks
= rand(n_fit_peaks_min:n_fit_peaks_max)
n_fit_peaks
# Sample fitness means as 2D vectors from uniform distribution
= [
fit_means rand(Distributions.Uniform(peak_mean_min, peak_mean_max), 2)
in 1:n_fit_peaks
for _
]
# Sample fitness amplitudes from uniform distribution
= rand(
fit_amplitudes Uniform(fit_amp_min, fit_amp_max), n_fit_peaks
Distributions.
)
# Sample fitness covariances from uniform distribution
= rand(
fit_covariances Uniform(fit_cov_min, fit_cov_max), n_fit_peaks
Distributions.
)
# Check dimensionality
if n_fit_peaks == 1
# Create fitness peaks
= mh.GaussianPeak(
fit_lans[i] first(fit_amplitudes), first(fit_means), first(fit_covariances)
)else
# Create fitness peaks
= mh.GaussianPeaks(
fit_lans[i]
fit_amplitudes, fit_means, fit_covariances
)end
end
Let’s visualize a sample of these fitness landscapes:
Random.seed!(42)
# Define number of rows and columns
= 4
n_rows = 4
n_cols
# Define number of landscapes to display
= n_rows * n_cols
n_sample
# Initialize figure
= Figure(size=(600, 600))
fig
# Add global grid layout
= fig[1, 1] = GridLayout()
gl
# Loop over a sample of fitness landscapes
for (i, idx) in enumerate(1:n_sample)
# Define row and column indices
= (i - 1) ÷ n_cols + 1
row = (i - 1) % n_cols + 1
col
# Create subplot
= Axis(
ax
gl[row, col],=AxisAspect(1),
aspect="landscape $(idx)",
title=12,
titlesize=false,
yticklabelsvisible=false,
xticklabelsvisible
)
# Evaluate fitness landscape
= mh.fitness(x, y, fit_lans[idx])
F
# Plot fitness landscape
= heatmap!(ax, x, y, F, colormap=:algae)
hm_fit
# Add contour plot
contour!(ax, x, y, F, color=:white)
end
# Set global axis labels
Label(
:, end, Bottom()],
fig["phenotype 1",
=14,
fontsize=(0, 0, 0, 10),
padding
)
Label(
1, :, Left()],
fig["phenotype 2",
=14,
fontsize=π / 2,
rotation
)
fig
These fitness landscapes represent different environments in which our populations will evolve. Each landscape has a unique configuration of fitness peaks, with varying heights, positions, and shapes.
We are now ready to simulate the evolutionary dynamics of our populations.
Simulating Evolution with Metropolis-Hastings
Now we’ll simulate the evolutionary dynamics using the Metropolis-Hastings algorithm. In this algorithm:
- We start with an initial phenotype
- We propose a random mutation (a step in phenotype space)
- We accept or reject the mutation based on both fitness and genotype-phenotype density
- We repeat for many generations
The Metropolis-Hastings acceptance probability is given by:
\[ P_{\text{accept}} = \min\left( 1, \frac{F_E(\underline{x}')^β \cdot GP(\underline{x}')^β} {F_E(\underline{x})^β \cdot GP(\underline{x})^β} \right) \tag{1}\]
where \(F_E(\underline{x})\) is the fitness in environment \(E\), \(GP(\underline{x})\) is the genotype-phenotype density, and \(\beta\) is the inverse temperature parameter controlling selection strength.
Our first step is to sample initial conditions for our replicates. For this, we sample initial positions on phenotype space from a uniform distribution, taking into account the mutational landscape (not to start at a low mutational peak).
# Reset random seed for reproducibility
Random.seed!(42)
# Sample initial positions on phenotype space from uniform distribution taking
# into account mutational landscape (not to start at a low mutational peak)
= StatsBase.sample(
x0 vec(init_grid),
Weights(vec(gp_evo_grid_points)),
StatsBase.
n_sim
)
# Select initial conditions for replicates from multivariate normal distribution
# around each initial condition
= reduce(
x0_reps -> cat(x, y, dims=3),
(x, y)
[rand(Distributions.MvNormal(x0[i], 0.1), n_rep)
in 1:n_sim
for i
]
)# Change order of dimensions
= permutedims(x0_reps, (1, 3, 2)) x0_reps
Next, we initialize a very convenient data structure from the DimensionalData.jl package to hold our trajectories and fitness values for each replicate, landscape, and evolution condition.
# Define dimensions to be used with DimensionalData
= DD.Dim{:phenotype}([:x1, :x2]) # phenotype
phenotype = DD.Dim{:fitness}([:fitness]) # fitness
fitness = DD.Dim{:time}(0:n_steps) # time
time = DD.Dim{:lineage}(1:n_sim) # lineage
lineage = DD.Dim{:replicate}(1:n_rep) # replicate
replicate = DD.Dim{:landscape}(1:n_fit_lans) # landscape
landscape = DD.Dim{:evo}(1:n_fit_lans) # evolution condition
evo
# Initialize DimensionalData array to hold trajectories and fitness
= DD.zeros(
phenotype_traj Float32,
phenotype,
time,
lineage,
replicate,
landscape,
evo,
)= DD.zeros(
fitness_traj Float32,
fitness,
time,
lineage,
replicate,
landscape,
evo,
)
# Stack arrays to store trajectories in phenotype and fitness dimensions
= DD.DimStack(
x_traj =phenotype_traj, fitness=fitness_traj),
(phenotype
)
# Store initial conditions
=1] = repeat(
x_traj.phenotype[time
x0_reps,=(1, 1, 1, n_fit_lans, n_fit_lans)
outer
)
# Map initial phenotype to fitness
=1] = repeat(
x_traj.fitness[timereduce(
-> cat(x, y, dims=3),
(x, y) fitness.(Ref(x0_reps), fit_lans)
mh.
),=(1, 1, 1, 1, n_fit_lans)
outer )
Now we can loop over landscapes, lineages, and replicates to simulate the evolutionary dynamics of our populations. The core function to run the simulations is already implemented in the mh
module of our custom package.
Random.seed!(42)
# Loop over landscapes
for evo in DD.dims(x_traj, :evo)
# Loop over lineages
for lin in DD.dims(x_traj, :lineage)
# Loop over replicates
for rep in DD.dims(x_traj, :replicate)
# Run Metropolis-Hastings algorithm
= mh.evo_metropolis_hastings(
trajectory
x_traj.phenotype[=1,
time=lin,
lineage=rep,
replicate=evo,
landscape=evo,
evo
].data,
fit_lans[evo],
gp_evo_peaks,
β,
µ,
n_steps
)
# Store trajectory
x_traj.phenotype[=lin,
lineage=rep,
replicate=evo,
evo.= trajectory
]
# Calculate and store fitness for each point in the trajectory
x_traj.fitness[=lin,
lineage=rep,
replicate=evo,
evo= mh.fitness(trajectory, fit_lans)
] end
end
end
Visualize Evolutionary Trajectories
Having run the simulations, we can now visualize some of the evolutionary trajectories to understand how populations navigate the phenotypic space.
Random.seed!(42)
# Define number of rows and columns
= 4
n_rows = 4
n_cols
# Define number of landscapes to display
= n_rows * n_cols
n_sample
# Initialize figure
= Figure(size=(600, 600))
fig
# Add global grid layout
= fig[1, 1] = GridLayout()
gl
# Loop over a sample of fitness landscapes
for (i, idx) in enumerate(1:n_sample)
# Define row and column indices
= (i - 1) ÷ n_cols + 1
row = (i - 1) % n_cols + 1
col
# Create subplot
= Axis(
ax
gl[row, col],=AxisAspect(1),
aspect="landscape $(idx)",
title=12,
titlesize=false,
yticklabelsvisible=false,
xticklabelsvisible
)
# Evaluate fitness landscape
= mh.fitness(x, y, fit_lans[idx])
F
# Plot fitness landscape
= heatmap!(ax, x, y, F, colormap=:algae)
hm_fit
# Add contour plot
contour!(ax, x, y, F, color=:black)
# Evaluate genetic density
= mh.genetic_density(x, y, gp_evo_peaks)
M
# Plot genetic density as contour lines
contour!(ax, x, y, M, color=:white)
# Set limits
xlims!(ax, -4, 4)
ylims!(ax, -4, 4)
# Initialize counter for color index
= 0
color_idx
# Plot evolutionary trajectories
for lin in DD.dims(x_traj, :lineage)
for rep in DD.dims(x_traj, :replicate)
# Extract x and y coordinates
= x_traj.phenotype[
x_data # time=DD.At(1:n_steps+1),
=DD.At(:x1),
phenotype=lin,
lineage=rep,
replicate=idx,
landscape=idx,
evo
].data= x_traj.phenotype[
y_data # time=DD.At(1:n_steps+1),
=DD.At(:x2),
phenotype=lin,
lineage=rep,
replicate=idx,
landscape=idx,
evo
].data
# Plot trajectory
+= 1
color_idx lines!(
ax,
x_data,
y_data,=ColorSchemes.glasbey_hv_n256[color_idx],
color=1
linewidth
)
# Plot initial condition
scatter!(
ax,1],
x_data[1],
y_data[=ColorSchemes.glasbey_hv_n256[color_idx],
color=12,
markersize=:xcross,
marker=:black,
strokecolor=1.5,
strokewidth
)
# Plot final condition
scatter!(
ax,end],
x_data[end],
y_data[=ColorSchemes.glasbey_hv_n256[color_idx],
color=12,
markersize=:utriangle,
marker=:black,
strokecolor=1.5,
strokewidth
)end
end
end
# Set global axis labels
Label(
:, end, Bottom()],
fig["phenotype 1",
=14,
fontsize=(0, 0, 0, 10),
padding
)
Label(
1, :, Left()],
fig["phenotype 2",
=14,
fontsize=π / 2,
rotation
)
fig
In these visualizations, we can observe several key features of evolutionary dynamics:
- Fitness landscape influence: Populations tend to climb fitness peaks (brighter regions).
- Mutational constraints: Trajectories are influenced by the genotype-phenotype density map (white contour lines), avoiding regions of low density.
- Initial condition effects: Different starting points can lead to different evolutionary outcomes.
- Stochasticity: Even with the same starting point (replicate lines of the same color), stochasticity leads to different evolutionary paths.
Fitness Trajectory Analysis
Let’s analyze how fitness changes over time during evolution:
Random.seed!(42)
# Define number of rows and columns
= 4
n_rows = 4
n_cols
# Define number of landscapes to display
= n_rows * n_cols
n_sample
# Initialize figure
= Figure(size=(600, 600))
fig
# Add global grid layout
= fig[1, 1] = GridLayout()
gl
# Loop over a sample of fitness landscapes
for (i, idx) in enumerate(1:n_sample)
# Define row and column indices
= (i - 1) ÷ n_cols + 1
row = (i - 1) % n_cols + 1
col
# Create subplot
= Axis(
ax
gl[row, col],="time (generations)",
xlabel="fitness",
ylabel="landscape $(idx)",
title=12,
titlesize=false,
yticklabelsvisible=false,
xticklabelsvisible=10,
xlabelsize=10,
ylabelsize
)
# Plot fitness trajectories
for lin in DD.dims(x_traj, :lineage)
for rep in DD.dims(x_traj, :replicate)
# Extract fitness data
= x_traj.fitness[
fit_data =DD.At(:fitness),
fitness=lin,
lineage=rep,
replicate=idx,
landscape=idx,
evo
].data
# Plot trajectory
= (lin - 1) * n_rep + rep
color_idx lines!(
ax,0:n_steps,
vec(fit_data),
=ColorSchemes.glasbey_hv_n256[color_idx],
color=1
linewidth
)end
end
end
fig
Conclusion
In this notebook, we’ve simulated evolutionary dynamics in a 2D phenotypic space under various fitness landscapes and a fixed mutational landscape. We’ve used the Metropolis-Hastings algorithm to model evolution as a biased random walk influenced by both fitness and mutational constraints.
Key insights from our simulations:
- Fitness-mutation interplay: Evolution is shaped by both the fitness landscape (selection) and the mutational landscape (genetic constraints).
- Multiple evolutionary outcomes: Different initial conditions can lead to different evolutionary endpoints, even in the same fitness landscape.
- Stochastic effects: Even with identical starting points, stochasticity in the evolutionary process leads to variation in outcomes.
- Landscape navigation: Populations navigate the phenotypic space by climbing fitness gradients while being constrained by the mutational landscape.
This approach allows us to explore how populations might adapt to various environments given underlying genetic constraints, providing insights into the predictability and paths of evolution.