Engine
The GeneticEngine
is the core component. Once built, it manages the entire evolutionary process, including population management, fitness evaluation, and genetic operations. The engine itself is essentially a large iterator that produces Epoch
objects representing each generation.
Epochs
Each epoch represents a single generation in the evolutionary process. An epoch contains information related not only the current generation, but also the engine's state at that point in time. This is the primary output of the engine, and it can be used to track progress, visualize results, or make decisions based on the evolutionary process.
Single-Objective Epoch
This is the default epoch for the engine - Generation
. It contains:
- The generation number
Ecosystem
information (population, species, etc.)- Score, which is the fitness of the best individual in the generation
- Value, which is the decoded value of the best individual
- Performance metrics (e.g., time taken)
- The Objective (max or min). The fitness objective being optimized, used for comparison and disicion making during the evolutionary process.
import radiate as rd
# Create an engine
engine = rd.GeneticEngine(
codec=rd.FloatCodec.scalar(0.0, 1.0),
fitness_fn=my_fitness_fn, # Single objective fitness function
# ... other parameters ...
)
# Run the engine for 100 generations
result = engine.run(rd.GeneratinsLimit(100))
# Get the best individual's decoded value
value = result.value() # float
# Get the score (fitness) of the best individual or epoch score
score = result.score() # List[float] - note that this is a list.
# In this scenario, the engine is configured for single-objective optimization,
# so the list will contain a single value.
# Get the population of the engine's ecosystem
population = result.population() # Population object
# Get the index of the epoch (number of generations)
index = result.index() # int
# Get the metrics of the engine
metrics = result.metrics() # MetricSet object
use radiate::*;
// Create an engine of type:
// `GeneticEngine<FloatChromosome, f32>`
//
// Where the `epoch` is `Generation<FloatChromosome, f32>`
let mut engine = GeneticEngine::builder()
.codec(FloatCodec::scalar(0.0..1.0))
.fitness_fn(|genotype: f32| my_fitness_fn(genotype)) // Return a single fitness score
// ... other parameters ...
.build();
// Run the engine for 100 generations - the result will be a `Generation<FloatChromosome, f32>`
let result = engine.run(|generation: Generation<FloatChromosome, f32>| {
generation.index() >= 100
});
// -- or using the engine's iterator --
let result = engine.iter().take(100).last().unwrap();
// Get the best individual's decoded value and fitness score:
let best_value: f32 = result.value();
// Get the score (fitness) of the best individual (or epoch score):
let best_score: Score = result.score();
// Get the index of the epoch (number of generations):
let index: usize = result.index();
// Get the ecosystem level information:
let ecosystem: Ecosystem<FloatChromosome> = result.ecosystem();
let population: Population<FloatChromosome> = ecosystem.population();
let species: Option<&[Species<FloatChromosome>]> = ecosystem.species();
// Get performance metrics:
let metrics: MetricSet = result.metrics();
// Get evolution duration (also available in metrics):
let time: Duration = result.time();
Multi-Objective Epoch
When the engine is configured for multi-objective optimization, the engine Generation
will have a ParetoFront
attached to it. The only difference between the single-objective and multi-objective is the availablity of the ParetoFront
and the fitness
value. The fitness
value will be a list of scores, one for each objective being optimized.
import radiate as rd
# Create an engine
engine = rd.GeneticEngine(
codec=rd.FloatCodec.scalar(0.0, 1.0),
fitness_fn=my_fitness_fn, # Multi-objective fitness function
objective=['min', 'max', ...], # Specify multi-objective optimization
# ... other parameters ...
)
# Run the engine for 100 generations
result = engine.run(rd.GenerationsLimit(100))
# Everything in the multi-objective epoch is the same as the single-objective epoch, except for the value:
# This will be a list of objects as such:
# [
# {'genotype': [Float], 'fitness': [obj1_fit, obj2_fit, ...]},
# {'genotype': [Float], 'fitness': [obj1_fit, obj2_fit, ...]},
# ...
# ]
value = result.value()
use radiate::*;
// Create an engine of type:
// `GeneticEngine<FloatChromosome, f32>`
//
// Where the `epoch` is `Generation<FloatChromosome, f32>`
let mut engine = GeneticEngine::builder()
.codec(FloatCodec::scalar(0.0..1.0))
.multi_objective(vec![Objective::Min, Objective::Max]) // Specify multi-objective optimization
.fitness_fn(|genotype: f32| my_fitness_fn(genotype)) // Return a multi-objective fitness score
// ... other parameters ...
.build();
// Run the engine for 100 generations - the result will be a `MultiObjectiveGeneration<FloatChromosome>`
let result = engine.run(|generation: Generation<FloatChromosome, 32>| {
generation.index() >= 100
});
// -- or using the engine's iterator --
let result = engine.iter().take(100).last().unwrap();
// Everything in this generation is the same as the single-objective epoch, except that
// the function call to `front()` will return a `ParetoFront` object.:
// This will be of type `Front<Phenotype<FloatChromosome>>`
let front: Option<&Front<Phenotype<FloatChromosome>>> = result.front();
// Get the members of the Pareto front:
let individuals: &[Arc<Phenotype<FloatChromosome>>] = front.values();
Iterator API
The GeneticEngine
is an inherently iterable concept, as such we can treat the engine as an iterato. Because of this we can use it in a for
loop or with iterator methods like map
, filter
, etc. We can also extend the iterator with custom methods to provide additional functionality, such as running until a certain fitness (score) is reached, time limit, or convergence. These custom methods are essentially sytactic sugar for 'take_until' or 'skip_while' style iterators.
Stopping Condition
The engine's iterator is an 'infinite iterator', meaning it will continue to produce epochs until a stopping condition, a break
or a return
is met. So, unless you want to run the engine indefinitely, you should always use a method like take
, until
, or last
to limit the number of epochs produced.
import radiate as rd
# Create an engine
engine = rd.GeneticEngine(
codec=rd.FloatCodec.scalar(0.0, 1.0),
fitness_funn=my_fitness_fn, # Some fitness function
# ... other parameters ...
)
# use a simple for loop to iterate through 100 generations
for epoch in engine:
if epoch.index() >= 100:
break
print(f"Generation {epoch.index()}: Score = {epoch.score()}")
use radiate::*;
use std::time::Duration;
// Create an engine
let mut engine = GeneticEngine::builder()
.codec(FloatCodec::scalar(0.0..1.0))
.fitness_fn(|genotype: f32| my_fitness_fn(genotype))
// ... other parameters ...
.build();
// 1.) use a simple for loop to iterate through 100 generations
for epoch in engine.iter().take(100) {
println!("Generation {}: Score = {}", epoch.index(), epoch.score().as_f32());
}
// 2.) use the iterator's custom methods to run until a score target is reached
let target_score = 0.01;
let result = engine.iter().until_score(target_score).take(1).last().unwrap();
// 3.) run until a time limit is reached
let time_limit = Duration::from_secs(60);
let result = engine.iter().until_duration(time_limit).take(1).last().unwrap();
// 4.) run until convergence
let window = 50;
let epsilon = 0.01; // how close the scores must be over the window to consider convergence
let result = engine.iter().until_convergence(window, epsilon).take(1).last().unwrap();
Problem API
For certain optimization problems, it is useful to have a more structured way to define a problem
. For instance, it may be useful to hold stateful information within a fitness function, store data in a more unified way, or evaluate a Genotype<C>
directly without decoding. The problem
interface provides a way to do just that. Under the hood of the GeneticEngine
, the builder constructs a problem
object that holds the codec
and fitness function. Because of this, when using the problem
API, we don't need a codec
or a fitness function - the problem
will take care of that for us.
Under Construction
The problem API in Python is still under construction and is not yet available.
use radiate::*;
// Define a problem struct that holds stateful information
struct MyFloatProblem {
num_genes: usize,
value_range: Range<f32>,
}
impl Problem<FloatChromosome, Vec<f32>> for MyFloatProblem {
fn encode(&self) -> Genotype<FloatChromosome> {
Genotype::from(FloatChromosome::from((self.num_genes, self.value_range.clone())))
}
fn decode(&self, genotype: &Genotype<FloatChromosome>) -> Vec<f32> {
genotype.genes().iter().map(|gene| gene.value()).collect()
}
fn eval(&self, genotype: &Genotype<FloatChromosome>) -> Score {
// Evaluate the genotype directly without decoding
my_fitness_fn(&genotype)
}
}
// The `Problem<C, T>` trait requires `Send` and `Sync` implementations
unsafe impl Send for MyFloatProblem {}
unsafe impl Sync for MyFloatProblem {}
// Create an engine with the problem
let mut engine = GeneticEngine::builder()
.problem(MyProblem { num_genes: 10, value_range: 0.0..1.0 })
.build();
// Run the engine
let result = engine.run(|epoch| epoch.index() >= 100);
Metrics
The MetricSet
, included in the engine's epoch, provides a number of built-in metrics that can be used to evaluate the performance of the GeneticEngine
. These metrics can be used to monitor the progress of the engine, compare different runs, and tune hyperparameters. During evolution, the engine collects various metrics from it's different components as well as the overall performance of the engine.
A metric is defined as:
Value
- Represents a single value metric with a name and aStatistic
.Time
- Represents a time metric with a name and aTimeStatistic
.Distribution
- Represents a distribution metric with a name and aDistribution
.
Statistic
The Statistic
exposes a number of different statistical measures that can be used to summarize the data, such as, last_value
, count
, min
, max
, mean
, sum
, variance
, std_dev
, skewness
, and kurtosis
.
TimeStatistic
Similarly, the TimeStatistic
exposes the same measures, however the data is assumed to be time-based. As such, the results are expressed as a Duration::from_secs_f32(value)
.
Distribution
The Distribution
metric is used to represent a distribution of values. The distribution is stored as a Vec<f32>
and produces the same statistical measures as the Statistic
and TimeStatistic
with the exception of last_value
which is changed to last_sequence
.
The default metrics collected by the engine are:
Name | Type | Description |
---|---|---|
time |
TimeStatistic | The time taken for the evolution process. |
scores |
Statistic | The scores (fitness) of all the individuals evolved throughout the evolution process. |
age |
Statistic | The age of all the individuals in the Ecosystem . |
replace_age |
Statistic | The number of individuals replaced based on age. |
replace_invalid |
Statistic | The number of individuals replaced based on invalid structure (e.g. Bounds) |
genome_size |
Distribution | The size of each genome over the evolution process. This is usually static and doesn't change. |
front |
Statistic | The number of members added to the Pareto front throughout the evolution process. |
unique_members |
Statistic | The number of unique members in the Ecosystem . |
unique_scores |
Statistic | The number of unique scores in the Ecosystem . |
diversity_ratio |
Statistic | The ratio of unique scores to the size of the Ecosystem . |
score_volatility |
Statistic | The volatility of the scores in the Ecosystem . This is calculated as the standard deviation of the scores / mean. |
species_count |
Statistic | The number of species in the 'Ecosystem`. |
species_removed |
Statistic | The number of species removed based on stagnation. |
species_distance |
Distribution | The distance between species in the Ecosystem . |
species_created |
Statistic | The number of species created in the Ecosystem . |
species_died |
Statistic | The number of species that have died in the Ecosystem . |
species_age |
Statistic | The age of all the species in the Ecosystem . |
Along with the default metrics, each component will also collect operation metrics (statistics and time statistics) for the operations it performs. For example, each Alterer
and Selector
will collect metrics and be identified by their name. Its also important to note that species
level metrics will only be collected if the engine is configured to use species-based diversity.
These can be accessed through the metrics()
method of the epoch, which returns a MetricSet
.
import radiate as rd
# Create an engine
engine = rd.GeneticEngine(
codec=rd.FloatCodec.scalar(0.0, 1.0),
fitness_fn=my_fitness_fn, # Single objective fitness function
# ... other parameters ...
)
# Run the engine for 100 generations
result = engine.run(rd.GeneratinsLimit(100))
# Get the metrics of the engine
metrics = result.metrics() # MetricSet object
# Access specific metrics
time_taken = metrics["time"]['time_sum'] # Total time taken for the evolution process
scores = metrics["scores"] # dict of score statistics
mean_score = scores['value_mean'] # Mean score of all individuals
all_last_generation_scores = scores['sequence_last'] # Last generation scores
// --- set up the engine ---
let result = engine.run(|ctx| {
// get the scroe metric from the generation context
let temp = ctx.metrics().get("scores").unwrap();
// get the standard deviation of the score distribution
let std = temp.value_std_dev();
std < 0.01 // Example condition to stop the engine
});
// Access the metrics from the result
let metrics: MetricSet = result.metrics();
Tips
- Use appropriate population sizes (100-500 for most problems)
- Enable parallel execution for expensive fitness functions
- Use efficient selection strategies for large populations
- Consider species-based diversity for complex landscapes