We recently visited the Galapagos islands and to prepare I read The Beak of the Finch by Jonathan Weiner: a Pulitzer prize winning examination of evolution following the work of the Grants on the finches on Daphne Major. You can see the island as you land in the airport at Baltra: remarkable that they lived on it for months at a time.

The surprising bit of the book is how fast evolution works in the Galapagos. My intuition from my education was that evolution and speciation were very slow processes. Apparently not necessarily! The book showcases that the strong selection effects on the islands can substantially change the phenotypic makeup of the finches in just a few (quite harsh) selection episodes. They even have a unit for it: “the darwin”.

When going through the book I was mostly following the logic but it struck me that this is perfectly suited for a simulation to really drive home the dynamics. And thus…

The first dynamic that we’re introduce to in the book is character displacement. As Weiner notes:

Darwinian competition is not only the clas of stag horns, the gore on the jaws of lions, nature red in tooth and claw. Competition also can be a silent race, side by side, for the last food on a desert island, where te competititors never fight one another, and the only sound of battle is the occasional crack of a Tribulus seed.

and to quote Peter Thiel: “competition is for losers”. Thus what happens in nature is that similar species evolve to differ from each other in order to minimize competition for limited resources.

Let’s set up a simulation: we’ll imagine that we have supplies of resources \(X_{i} \sim Uniform(0,1)\). Individual birds utilize the resources from the range \([a_{i} - \delta, a_{i} + \delta]\) . When they reproduce they pass along their \(a_{i}\) plus some additional noise \(\epsilon \sim N(0, 0.05)\)

using DataFrames
using Distributions
using Random
using AlgebraOfGraphics
using CairoMakie

CARRYING_CAPACITY = 1000

struct Bird
    a::Any
    delta::Any
    species::Any
end

function reproduce(bird::Bird)
    return Bird(bird.a + rand(Normal(0, 0.05)), bird.delta, bird.species)
end

function step(birds)
    resources = rand(CARRYING_CAPACITY)

    next_generation = Bird[]
    for bird in shuffle(birds)
        food = findfirst(x -> abs(bird.a - x) <= bird.delta, resources)
        isnothing(food) && continue # bird starves
        deleteat!(resources, food)
        push!(next_generation, reproduce(bird))
        push!(next_generation, reproduce(bird))
    end

    return next_generation
end

function simulate(n_iters)
    subpopulation_left = Bird.(rand(Normal(0.45, 0.1), 500), 0.05, 1)
    subpopulation_right = Bird.(rand(Normal(0.55, 0.1), 500), 0.05, 2)

    population = [subpopulation_left; subpopulation_right]

    dfs = []

    for iter in 1:n_iters
        push!(dfs,
              DataFrame(
                  :iter => iter,
                  :targets => [bird.a for bird in population],
                  :species => [bird.species for bird in population],
              ),
              )
        population = step(population)
    end

    return vcat(dfs...)
end

And we see starrting from a fairly close distribution we see the species start to seprate into two separate clusters. We can make this even starker by introducing a non-uniform resource distribution

function step(birds)
    resources = rand(MixtureModel([Normal(0.2, 0.05), Normal(0.8, 0.05)], [0.5, 0.5]), CARRYING_CAPACITY)

    next_generation = Bird[]
    for bird in shuffle(birds)
        food = findfirst(x -> abs(bird.a - x) <= bird.delta, resources)
        isnothing(food) && continue # bird starves
        deleteat!(resources, food)
        push!(next_generation, reproduce(bird))
        push!(next_generation, reproduce(bird))
    end

    return next_generation
end

There is something unsatisfying with this simulation however: we’re not exactly seeing the creation of two species. Indeed you see a buch of Species 1 overlapping with Species 2; species only comes in as a historical accident but otherwise has no effect. So let’s fix that.

Apparently the definition of a species is a little more complicated then I thought: originally I was under the impression that species can’t interbreed. Turns out it’s more like they usually don’t interbreed. You can get this through sexual selection: if hybrids are less fit then the birds that prefer to, um, flock with birds of the same feather will have an advantage.

struct Bird
    a::Any
    delta::Any
    species_preference::Any
    species::Any
end

function reproduce(bird_a::Bird, bird_b::Bird)
    return Bird((bird_a.a + bird_b.a) / 2.0 + rand(Normal(0, 0.05)),
                bird_a.delta,
                bird_a.species_preference + rand(Normal(0, 0.05)),
                bird_a.species)
end

function step(birds)
    resources = rand(MixtureModel([Normal(0.2, 0.05), Normal(0.8, 0.05)]), CARRYING_CAPACITY)

    survivors = []
    for bird in shuffle(birds)
        food = findfirst(x -> abs(bird.a - x) <= bird.delta, resources)
        isnothing(food) && continue # bird starves
        push!(survivors, bird)
        deleteat!(resources, food)
    end

    next_generation = []
    for bird in survivors
        mate_species = rand() <= bird.species_preference ? bird.species : 3 - bird.species
        others = shuffle(survivors)
        mate = findfirst(x -> x.species == mate_species, others)
        isnothing(mate) && continue
        push!(next_generation, reproduce(bird, others[mate]))
        push!(next_generation, reproduce(bird, others[mate]))
    end

    return next_generation
end

function simulate(n_iters)
    subpopulation_left = Bird.(rand(Normal(0.45, 0.1), 500), 0.05, rand(Normal(0.9, 0.1), 500), 1)
    subpopulation_right = Bird.(rand(Normal(0.55, 0.1), 500), 0.05, rand(Normal(0.9, 0.1), 500), 2)

    population = [subpopulation_left; subpopulation_right]

    dfs = []

    for iter = 1:n_iters
        push!(
            dfs,
            DataFrame(
                :iter => iter,
                :targets => [bird.a for bird in population],
                :species => [bird.species for bird in population],
            ),
        )
        population = step(population)
    end

    return vcat(dfs...)
end

Of course we’re being a little prescriptive with our definition of species: it’s not really an enumeration but rather a cluster. Let’s give each bird a “trait affinity” for how they select a partner: higher affinity means they strongly prefer mates with similar beak size. Mates are selected with probability proportional to the product of each bird’s preference for the other.

struct Bird
    a::Float64
    delta::Float64
    affinity::Float64  # mate preference strength: higher → prefers more similar-looking mates
end

function mate_weight(bird_a::Bird, bird_b::Bird)
    diff_sq = (bird_a.a - bird_b.a)^2
    return exp(-(bird_a.affinity + bird_b.affinity) * diff_sq)
end

function reproduce(bird_a::Bird, bird_b::Bird)
    child_a = (bird_a.a + bird_b.a) / 2.0 + rand(Normal(0, 0.05))
    child_affinity = (bird_a.affinity + bird_b.affinity) / 2.0 + rand(Normal(0, 0.3))
    return Bird(child_a, bird_a.delta, max(0.0, child_affinity))
end

function step(birds; bimodal_resources = true)
    resources = bimodal_resources ?
        rand(MixtureModel([Normal(0.2, 0.05), Normal(0.8, 0.05)], [0.5, 0.5]), CARRYING_CAPACITY) :
        rand(Uniform(0, 1), CARRYING_CAPACITY)

    survivors = Bird[]
    for bird in shuffle(birds)
        food = findfirst(x -> abs(bird.a - x) <= bird.delta, resources)
        isnothing(food) && continue
        push!(survivors, bird)
        deleteat!(resources, food)
    end

    isempty(survivors) && return Bird[], Bird[]

    next_generation = Bird[]
    for bird in survivors
        weights = [mate_weight(bird, other) for other in survivors]
        total = sum(weights)
        total == 0.0 && continue
        mate = survivors[rand(Distributions.Categorical(weights ./ total))]
        push!(next_generation, reproduce(bird, mate))
        push!(next_generation, reproduce(bird, mate))
    end

    return survivors, next_generation
end

function simulate(n_iters; bimodal_resources = true)
    subpopulation_left  = Bird.(rand(Normal(0.2, 0.05), 500), 0.05, clamp.(rand(Normal(2.0, 0.5), 500), 0.0, Inf))
    subpopulation_right = Bird.(rand(Normal(0.8, 0.05), 500), 0.05, clamp.(rand(Normal(2.0, 0.5), 500), 0.0, Inf))

    population = [subpopulation_left; subpopulation_right]
    dfs = []

    for iter = 1:n_iters
        survivors, population = step(population; bimodal_resources)
        isempty(survivors) && break

        push!(dfs, DataFrame(
            :iter     => iter,
            :a        => [bird.a for bird in survivors],
            :affinity => [bird.affinity for bird in survivors],
        ))
    end

    return vcat(dfs...)
end

We can verify that affinity is being selected for by comparing against a control where resources are distributed uniformly (no penalty for hybridization). Without the penalty, being picky about mates is costly as less picky birds are more likely to mate so affinity should drift downward. With the penalty, avoiding costly crosses is advantageous, so affinity should rise.

It’s cool to see how straightforward it is to express these ideas in simulation. Reading the book was fascinating but actually implementing this helped me understand the dynamics much better. And of course seeing the outcomes in person was wild: the naturalists showed us the subtle differences in all of lizards as we went from island to island. Truly enchanted.