diamondtail/lib/diamondtail/population.ex
2025-11-09 14:32:59 +01:00

129 lines
4.0 KiB
Elixir

defmodule Diamondtail.Population do
@moduledoc """
This module defines an Agent which maintains a list of every individual in the population, allowing an individual to be taken out or introduced.
"""
use Agent
defmodule Genome do
@moduledoc """
Genomes represents the parameters of an individual, which tweaks the weights in the algorithm and implements the genetic algorithm "learning" functionality
"""
use TypedStruct
typedstruct enforce: true do
field :mutation_chance, float()
field :mutation_max_amplitude, float()
field :enemy_alive_weight, float()
field :own_health_weight, float()
field :own_length_weight, float()
field :enemy_health_weight, float()
field :enemy_length_weight, float()
field :enemy_head_distance_weight, float()
field :food_distance_weight, float()
field :search_depth, float()
field :value_discount, float()
end
defp random_between(bot, top) do
bot + :rand.uniform() * (top - bot)
end
@spec mutate(Genome.t()) :: Genome.t()
def mutate(genome) do
Map.from_struct(genome)
|> Enum.map(fn {key, value} ->
if :rand.uniform() <= genome.mutation_chance do
{key, value + random_between(-genome.mutation_max_amplitude, genome.mutation_max_amplitude)}
else
{key, value}
end
end)
|> Map.new() |> Map.put(:__struct__, Genome)
end
def average(genomes) do
Enum.reduce(genomes, zero(), fn g, acc ->
Map.from_struct(acc)
|> Enum.map(fn {k, v} ->
{k, v + get_in(g, [Access.key!(k)])}
end)
|> Map.new() |> Map.put(:__struct__, Genome)
end) |> Map.from_struct()
|> Enum.map(fn {k, v} ->
{k, v / length(genomes)}
end) |> Map.new() |> Map.put(:__struct__, Genome)
end
def random() do
%Genome{
mutation_chance: random_between(0.1, 0.2),
mutation_max_amplitude: random_between(0.2, 0.05),
enemy_alive_weight: random_between(-100.0, -200.0),
own_health_weight: random_between(0.7, 1.0),
own_length_weight: random_between(5, 10),
enemy_health_weight: random_between(-0.8, -0.4), # Summed
enemy_length_weight: random_between(-10, -5), # Averaged
# both of the following are compared to the nearest object, to avoid overrewarding when there are more enemies/foods
enemy_head_distance_weight: random_between(1, 2), # maximize distance, reward staying away
food_distance_weight: random_between(-2, -1), # minimize distance, reward staying near
search_depth: random_between(3.0, 7.0),
value_discount: random_between(1.0, 10.0) # as percentage
}
end
# Not very computationally efficient but avoids repeating code
def zero() do
Map.from_struct(random())
|> Enum.map(fn {key, _value} ->
{key, 0}
end)
|> Map.new() |> Map.put(:__struct__, Genome)
end
end
def start_link(initial_value) do
Agent.start_link(fn -> initial_value end, name: __MODULE__)
end
@doc """
Takes out and returns a random genome from the population. If the population is empty, a new random genome will be introduced.
"""
@spec take_random() :: Genome.t()
def take_random() do
Agent.get_and_update(__MODULE__, fn list ->
index = :rand.uniform(length(list)) - 1
{got, list} = List.pop_at(list, index)
list = if list == [], do: [Genome.random], else: list
{got, list}
end)
end
@doc """
Adds a genome to the population
"""
@spec add(Genome.t()) :: :ok
def add(genome) do
Agent.update(__MODULE__, &[genome | &1])
end
@doc """
Returns the amount of genomes in the population
"""
@spec size() :: integer()
def size() do
Agent.get(__MODULE__, &length/1)
end
@doc """
Returns the "average" genome, to get a sense of the population's tendencies
"""
@spec avg() :: Genome.t()
def avg() do
Agent.get(__MODULE__, &Genome.average/1)
end
end