Skip to content

Commit

Permalink
Merge branch 'develop'
Browse files Browse the repository at this point in the history
  • Loading branch information
pkalivas committed Nov 22, 2024
2 parents f88a254 + 4155253 commit 4a2b6fd
Show file tree
Hide file tree
Showing 11 changed files with 238 additions and 8 deletions.
1 change: 1 addition & 0 deletions .idea/radiate.iml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 1 addition & 4 deletions radiate-examples/string-evolver/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@ fn main() {
GeneticEngine::from_codex(&codex)
.offspring_selector(BoltzmannSelector::new(4_f32))
.survivor_selector(TournamentSelector::new(3))
.alterer(vec![
Alterer::Mutator(0.01),
Alterer::UniformCrossover(0.5)
])
.alterer(vec![Alterer::Mutator(0.01), Alterer::UniformCrossover(0.5)])
.fitness_fn(|genotype: String| {
Score::from_usize(genotype.chars().zip(target.chars()).fold(
0,
Expand Down
14 changes: 14 additions & 0 deletions radiate/src/engines/domain/thread_pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ pub struct WorkResult<T> {
}

impl<T> WorkResult<T> {
/// Get the result of the job.
/// Note: This method will block until the result is available.
pub fn result(&self) -> T {
self.reseiver.recv().unwrap()
}
Expand All @@ -19,6 +21,9 @@ pub struct ThreadPool {
}

impl ThreadPool {
/// Basic thread pool implementation.
///
/// Create a new ThreadPool with the given size.
pub fn new(size: usize) -> Self {
let (sender, receiver) = mpsc::channel();
let receiver = Arc::new(Mutex::new(receiver));
Expand All @@ -31,6 +36,7 @@ impl ThreadPool {
}
}

/// Execute a job in the thread pool. This is a 'fire and forget' method.
pub fn execute<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
Expand All @@ -39,6 +45,7 @@ impl ThreadPool {
self.sender.send(Message::NewJob(job)).unwrap();
}

/// Execute a job in the thread pool and return a WorkResult that can be used to get the result of the job.
pub fn task<F, T>(&self, f: F) -> WorkResult<T>
where
F: FnOnce() -> T + Send + 'static,
Expand Down Expand Up @@ -84,6 +91,11 @@ struct Worker {
}

impl Worker {
/// Create a new Worker.
///
/// The Worker will listen for incoming jobs on the given receiver.
/// When a job is received, it will be executed in a new thread and the
/// mutex will release allowing another job to be received from a different worker.
fn new(receiver: Arc<Mutex<mpsc::Receiver<Message>>>) -> Self {
Self {
thread: Some(thread::spawn(move || loop {
Expand All @@ -97,6 +109,8 @@ impl Worker {
}
}

/// Simple check if the worker is alive. The thread is 'taken' when the worker is dropped.
/// So if the thread is 'taken' the worker is no longer alive.
pub fn is_alive(&self) -> bool {
self.thread.is_some()
}
Expand Down
15 changes: 15 additions & 0 deletions radiate/src/engines/engine_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ use crate::engines::schema::timer::Timer;
use super::score::Score;
use super::MetricSet;

/// The context of the genetic engine. This struct contains the current state of the genetic engine
/// at any given time. This includes:
/// * current population
/// * current best individual
/// * current index - the number of generations that have passed
/// * timer - the duration of time the engine has bee running
/// * metrics - a set of metrics that are collected during the run
/// * current best score - the score of the current best individual
///
/// The EngineContext is passed to the user-defined closure that is executed each generation. The user
/// can use the EngineContext to access the current state of the genetic engine and make decisions based
/// on the current state on how to proceed.
pub struct EngineContext<G, A, T>
where
G: Gene<G, A>,
Expand All @@ -23,14 +35,17 @@ impl<G, A, T> EngineContext<G, A, T>
where
G: Gene<G, A>,
{
/// Get the current score of the best individual in the population.
pub fn score(&self) -> &Score {
self.score.as_ref().unwrap()
}

/// Get the current duration of the genetic engine run in seconds.
pub fn seconds(&self) -> f64 {
self.timer.duration().as_secs_f64()
}

/// Upsert (update or create) a metric with the given key and value. This is only used within the engine itself.
pub fn upsert_metric(&mut self, key: &'static str, value: f32, time: Option<Duration>) {
self.metrics.upsert_value(key, value);
if let Some(time) = time {
Expand Down
126 changes: 126 additions & 0 deletions radiate/src/engines/genetic_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,51 @@ use super::{
METRIC_SCORE, METRIC_UNIQUE,
};

/// The `GeneticEngine` struct is the core component of the Radiate library's genetic algorithm implementation.
/// It manages the evolutionary process, including selection, crossover, mutation,
/// and fitness evaluation. The ```GeneticEngine``` is designed to be flexible and extensible, allowing users to
/// customize various aspects of the genetic algorithm to suit their specific needs.
///
/// # Examples
/// ``` rust
/// use radiate::*;
///
/// // Define a codex that encodes and decodes individuals in the population, in this case using floats.
/// let codex = FloatCodex::new(1, 5, 0.0, 100.0);
/// // This codex will encode Genotype instances with 1 Chromosome and 5 FloatGenes,
/// // with random allels between 0.0 and 100.0. It will decode into a Vec<Vec<f32>>.
/// // eg: [[1.0, 2.0, 3.0, 4.0, 5.0]]
///
/// // Create a new instance of the genetic engine with the given codex.
/// let engine = GeneticEngine::from_codex(&codex)
/// .minimizing() // Minimize the fitness function.
/// .population_size(150) // Set the population size to 150 individuals.
/// .max_age(15) // Set the maximum age of an individual to 15 generations before it is replaced with a new individual.
/// .offspring_fraction(0.5) // Set the fraction of the population that will be replaced by offspring each generation.
/// .num_threads(4) // Set the number of threads to use in the thread pool for parallel fitness evaluation.
/// .offspring_selector(BoltzmannSelector::new(4_f32)) // Use boltzmann selection selection to select offspring.
/// .survivor_selector(TournamentSelector::new(3)) // Use tournament selection to select survivors.
/// .alterer(vec![
/// Alterer::mutation(NumericMutator::new(0.01)), // Specific mutator for numeric values.
/// Alterer::crossover(MeanCrossover::new(0.5)) // Specific crossover operation for numeric values.
/// ])
/// .fitness_fn(|genotype: Vec<Vec<f32>>| { // Define the fitness function to be minimized.
/// // Calculate the fitness score of the individual based on the decoded genotype.
/// let score = genotype.iter().fold(0.0, |acc, chromosome| {
/// acc + chromosome.iter().fold(0.0, |acc, gene| acc + gene)
/// });
/// Score::from_f32(score)
/// })
/// .build(); // Build the genetic engine.
///
/// // Run the genetic algorithm until the stopping condition is met.
/// let result = engine.run(|output| output.score().as_int() == 0);
/// ```
///
/// # Type Parameters
/// - `G`: The type of gene used in the genetic algorithm, which must implement the `Gene` trait.
/// - `A`: The type of the allele associated with the gene - the gene's "expression".
/// - `T`: The type of the phenotype produced by the genetic algorithm, which must be `Clone`, `Send`, and `'static`.
pub struct GeneticEngine<'a, G, A, T>
where
G: Gene<G, A>,
Expand All @@ -31,14 +76,23 @@ where
G: Gene<G, A>,
T: Clone + Send,
{
/// Create a new instance of the `GeneticEngine` struct with the given parameters.
/// - `params`: An instance of `GeneticEngineParams` that holds configuration parameters for the genetic engine.
pub fn new(params: GeneticEngineParams<'a, G, A, T>) -> Self {
GeneticEngine { params }
}

/// Initializes a ```GeneticEngineParams``` using the provided codex, which defines how individuals
/// are represented in the population. Because the ```Codex``` is always needed, this
/// is a convenience method that allows users to create a ```GeneticEngineParams``` instance
/// which will then be 'built' resulting in a ```GeneticEngine``` instance.
pub fn from_codex(codex: &'a impl Codex<G, A, T>) -> GeneticEngineParams<G, A, T> {
GeneticEngineParams::new().codex(codex)
}

/// Executes the genetic algorithm. The algorithm continues until a specified
/// stopping condition, 'limit', is met, such as reaching a target fitness score or
/// exceeding a maximum number of generations. When 'limit' returns true, the algorithm stops.
pub fn run<F>(&self, limit: F) -> EngineContext<G, A, T>
where
F: Fn(&EngineContext<G, A, T>) -> bool,
Expand Down Expand Up @@ -67,6 +121,15 @@ where
}
}

/// Evaluates the fitness of each individual in the population using the fitness function
/// provided in the genetic engine parameters. The fitness function is a closure that takes
/// a phenotype as input and returns a score. The score is then used to rank the individuals
/// in the population.
///
/// Importantly, this method uses a thread pool to evaluate the fitness of each individual in
/// parallel, which can significantly speed up the evaluation process for large populations.
/// It will also only evaluate individuals that have not yet been scored, which saves time
/// by avoiding redundant evaluations.
fn evaluate(&self, handle: &mut EngineContext<G, A, T>) {
let codex = self.codex();
let optimize = self.optimize();
Expand Down Expand Up @@ -96,6 +159,14 @@ where
optimize.sort(&mut handle.population);
}

/// Selects the individuals that will survive to the next generation. The number of survivors
/// is determined by the population size and the offspring fraction specified in the genetic
/// engine parameters. The survivors are selected using the survivor selector specified in the
/// genetic engine parameters, which is typically a selection algorithm like tournament selection
/// or roulette wheel selection. For example, if the population size is 100 and the offspring
/// fraction is 0.8, then 20 individuals will be selected as survivors.
///
/// This method returns a new population containing only the selected survivors.
fn select_survivors(
&self,
population: &Population<G, A>,
Expand All @@ -113,6 +184,15 @@ where
result
}

/// Selects the offspring that will be used to create the next generation. The number of offspring
/// is determined by the population size and the offspring fraction specified in the genetic
/// engine parameters. The offspring are selected using the offspring selector specified in the
/// genetic engine parameters, which, like the survivor selector, is typically a selection algorithm
/// like tournament selection or roulette wheel selection. For example, if the population size is 100
/// and the offspring fraction is 0.8, then 80 individuals will be selected as offspring which will
/// be used to create the next generation through crossover and mutation.
///
/// This method returns a new population containing only the selected offspring.
fn select_offspring(
&self,
population: &Population<G, A>,
Expand All @@ -130,6 +210,9 @@ where
result
}

/// Alters the offspring population using the alterers specified in the genetic engine parameters.
/// The alterer in this case is going to be a ```CompositeAlterer``` and is responsible for applying
/// the provided mutation and crossover operations to the offspring population.
fn alter(&self, population: &mut Population<G, A>, metrics: &mut MetricSet, generation: i32) {
let alterer = self.alterer();
let optimize = self.optimize();
Expand All @@ -140,6 +223,12 @@ where
}
}

/// Filters the population to remove individuals that are too old or invalid. The maximum age
/// of an individual is determined by the 'max_age' parameter in the genetic engine parameters.
/// If an individual's age exceeds this limit, it is replaced with a new individual. Similarly,
/// if an individual is found to be invalid (i.e., its genotype is not valid, provided by the ```Valid``` trait),
/// it is replaced with a new individual. This method ensures that the population remains
/// healthy and that only valid individuals are allowed to reproduce or survive to the next generation.
fn filter(&self, population: &mut Population<G, A>, metrics: &mut MetricSet, generation: i32) {
let max_age = self.params.max_age;
let codex = self.codex();
Expand Down Expand Up @@ -167,6 +256,11 @@ where
);
}

/// Recombines the survivors and offspring populations to create the next generation. The survivors
/// are the individuals from the previous generation that will survive to the next generation, while the
/// offspring are the individuals that were selected from the previous generation then altered.
/// This method combines the survivors and offspring populations into a single population that
/// will be used in the next iteration of the genetic algorithm.
fn recombine(
&self,
handle: &mut EngineContext<G, A, T>,
Expand All @@ -179,6 +273,9 @@ where
.collect::<Population<G, A>>();
}

/// Audits the current state of the genetic algorithm, updating the best individual found so far
/// and calculating various metrics such as the age of individuals, the score of individuals, and the
/// number of unique scores in the population. This method is called at the end of each generation.
fn audit(&self, output: &mut EngineContext<G, A, T>) {
let codex = self.codex();
let optimize = self.optimize();
Expand All @@ -204,6 +301,13 @@ where
output.index += 1;
}

/// Adds various metrics to the output context, including the age of individuals, the score of individuals,
/// and the number of unique scores in the population. These metrics can be used to monitor the progress of
/// the genetic algorithm and to identify potential issues or areas for improvement.
///
/// The age of an individual is the number of generations it has survived, while the score of an individual
/// is a measure of its fitness. The number of unique scores in the population is a measure of diversity, with
/// a higher number indicating a more diverse population.
fn add_metrics(&self, output: &mut EngineContext<G, A, T>) {
let mut unique = HashSet::new();
for i in 0..output.population.len() {
Expand All @@ -222,46 +326,67 @@ where
.upsert_value(METRIC_UNIQUE, unique.len() as f32);
}

/// Returns the survivor selector specified in the genetic engine parameters. The survivor selector is
/// responsible for selecting the individuals that will survive to the next generation.
fn survivor_selector(&self) -> &dyn Select<G, A> {
self.params.survivor_selector.as_ref()
}

/// Returns the offspring selector specified in the genetic engine parameters. The offspring selector is
/// responsible for selecting the offspring that will be used to create the next generation through crossover
/// and mutation.
fn offspring_selector(&self) -> &dyn Select<G, A> {
self.params.offspring_selector.as_ref()
}

/// Returns the alterer specified in the genetic engine parameters. The alterer is responsible for applying
/// the provided mutation and crossover operations to the offspring population.
fn alterer(&self) -> &impl Alter<G, A> {
self.params.alterer.as_ref().unwrap()
}

/// Returns the codex specified in the genetic engine parameters. The codex is responsible for encoding and
/// decoding individuals in the population, converting between the genotype and phenotype representations.
fn codex(&self) -> Arc<&'a dyn Codex<G, A, T>> {
Arc::clone(self.params.codex.as_ref().unwrap())
}

/// Returns the fitness function specified in the genetic engine parameters. The fitness function is a closure
/// that takes a 'T' (the decoded Genotype) as input and returns a score. The score is used to rank the individuals in the population.
fn fitness_fn(&self) -> Arc<dyn Fn(T) -> Score + Send + Sync> {
Arc::clone(self.params.fitness_fn.as_ref().unwrap())
}

/// Returns the population specified in the genetic engine parameters. This is only called at the start of the genetic algorithm.
fn population(&self) -> &Population<G, A> {
self.params.population.as_ref().unwrap()
}

/// Returns the optimize function specified in the genetic engine parameters. The optimize function is responsible
/// for sorting the population based on the fitness of the individuals. This is typically done in descending order,
/// with the best individuals at the front of the population.
fn optimize(&self) -> &Optimize {
&self.params.optimize
}

/// Returns the number of survivors in the population. This is calculated based on the population size and the offspring fraction.
fn survivor_count(&self) -> usize {
self.params.population_size - self.offspring_count()
}

/// Returns the number of offspring in the population. This is calculated based on the population size and the offspring fraction.
/// For example, if the population size is 100 and the offspring fraction is 0.8, then 80 individuals will be selected as offspring.
fn offspring_count(&self) -> usize {
(self.params.population_size as f32 * self.params.offspring_fraction) as usize
}

/// Returns the thread pool specified in the genetic engine parameters. The thread pool is used to evaluate the fitness of
/// individuals in parallel, which can significantly speed up the evaluation process for large populations.
fn thread_pool(&self) -> &ThreadPool {
&self.params.thread_pool
}

/// Starts the genetic algorithm by initializing the population and returning the initial state of the genetic engine.
fn start(&self) -> EngineContext<G, A, T> {
let population = self.population();

Expand All @@ -275,6 +400,7 @@ where
}
}

/// Stops the genetic algorithm by stopping the timer and returning the final state of the genetic engine.
fn stop(&self, output: &mut EngineContext<G, A, T>) -> EngineContext<G, A, T> {
output.timer.stop();
output.clone()
Expand Down
Loading

0 comments on commit 4a2b6fd

Please sign in to comment.