use std::fmt::Debug;
use std::marker::PhantomData;
use burn::tensor::{Tensor, backend::Backend};
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use rlevo_core::evaluation::{BenchEnv, BenchError, BenchStep};
use crate::fitness::BatchFitnessFn;
pub trait Strategy<B: Backend>: Send + Sync {
type Params: Clone + Debug + Send + Sync;
type State: Clone + Debug + Send;
type Genome: Clone + Send;
fn init(&self, params: &Self::Params, rng: &mut dyn Rng, device: &B::Device) -> Self::State;
fn ask(
&self,
params: &Self::Params,
state: &Self::State,
rng: &mut dyn Rng,
device: &B::Device,
) -> (Self::Genome, Self::State);
fn tell(
&self,
params: &Self::Params,
population: Self::Genome,
fitness: Tensor<B, 1>,
state: Self::State,
rng: &mut dyn Rng,
) -> (Self::State, StrategyMetrics);
fn best(&self, state: &Self::State) -> Option<(Self::Genome, f32)>;
}
#[derive(Debug, Clone)]
pub struct StrategyMetrics {
pub generation: usize,
pub population_size: usize,
pub best_fitness: f32,
pub mean_fitness: f32,
pub worst_fitness: f32,
pub best_fitness_ever: f32,
}
impl StrategyMetrics {
#[must_use]
pub fn from_host_fitness(generation: usize, fitnesses: &[f32], best_fitness_ever: f32) -> Self {
assert!(!fitnesses.is_empty(), "fitness slice must be non-empty");
let population_size = fitnesses.len();
let (mut best, mut worst, mut sum) = (f32::INFINITY, f32::NEG_INFINITY, 0.0_f32);
for &f in fitnesses {
if f < best {
best = f;
}
if f > worst {
worst = f;
}
sum += f;
}
#[allow(clippy::cast_precision_loss)]
let mean = sum / population_size as f32;
Self {
generation,
population_size,
best_fitness: best,
mean_fitness: mean,
worst_fitness: worst,
best_fitness_ever: best_fitness_ever.min(best),
}
}
}
pub struct EvolutionaryHarness<B, S, F>
where
B: Backend,
S: Strategy<B>,
F: BatchFitnessFn<B, S::Genome>,
{
strategy: S,
params: S::Params,
fitness_fn: F,
state: Option<S::State>,
rng: StdRng,
base_seed: u64,
device: B::Device,
generation: usize,
max_generations: usize,
latest_metrics: Option<StrategyMetrics>,
_backend: PhantomData<B>,
}
impl<B, S, F> Debug for EvolutionaryHarness<B, S, F>
where
B: Backend,
S: Strategy<B>,
F: BatchFitnessFn<B, S::Genome>,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EvolutionaryHarness")
.field("base_seed", &self.base_seed)
.field("generation", &self.generation)
.field("max_generations", &self.max_generations)
.field("latest_metrics", &self.latest_metrics)
.finish_non_exhaustive()
}
}
impl<B, S, F> EvolutionaryHarness<B, S, F>
where
B: Backend,
S: Strategy<B>,
F: BatchFitnessFn<B, S::Genome>,
{
pub fn new(
strategy: S,
params: S::Params,
fitness_fn: F,
seed: u64,
device: B::Device,
max_generations: usize,
) -> Self {
Self {
strategy,
params,
fitness_fn,
state: None,
rng: StdRng::seed_from_u64(seed),
base_seed: seed,
device,
generation: 0,
max_generations,
latest_metrics: None,
_backend: PhantomData,
}
}
#[must_use]
pub fn latest_metrics(&self) -> Option<&StrategyMetrics> {
self.latest_metrics.as_ref()
}
#[must_use]
pub fn generation(&self) -> usize {
self.generation
}
#[must_use]
pub fn state(&self) -> Option<&S::State> {
self.state.as_ref()
}
pub fn best(&self) -> Option<(S::Genome, f32)> {
self.state.as_ref().and_then(|s| self.strategy.best(s))
}
pub fn reset(&mut self) {
self.rng = StdRng::seed_from_u64(self.base_seed);
self.generation = 0;
self.latest_metrics = None;
self.state = Some(
self.strategy
.init(&self.params, &mut self.rng, &self.device),
);
}
pub fn step(&mut self, _action: ()) -> BenchStep<()> {
let state = self
.state
.take()
.expect("EvolutionaryHarness::reset must be called before step");
let (population, state) =
self.strategy
.ask(&self.params, &state, &mut self.rng, &self.device);
let fitness = self.fitness_fn.evaluate_batch(&population, &self.device);
let (new_state, metrics) =
self.strategy
.tell(&self.params, population, fitness, state, &mut self.rng);
self.state = Some(new_state);
self.generation += 1;
let reward = -f64::from(metrics.best_fitness_ever);
self.latest_metrics = Some(metrics);
let done = self.generation >= self.max_generations;
BenchStep {
observation: (),
reward,
done,
}
}
}
impl<B, S, F> BenchEnv for EvolutionaryHarness<B, S, F>
where
B: Backend,
S: Strategy<B>,
F: BatchFitnessFn<B, S::Genome>,
{
type Observation = ();
type Action = ();
fn reset(&mut self) -> Result<Self::Observation, BenchError> {
EvolutionaryHarness::<B, S, F>::reset(self);
Ok(())
}
fn step(
&mut self,
action: Self::Action,
) -> Result<BenchStep<Self::Observation>, BenchError> {
Ok(EvolutionaryHarness::<B, S, F>::step(self, action))
}
}
#[cfg(test)]
mod tests {
use super::*;
use burn::backend::NdArray;
use burn::tensor::TensorData;
type TestBackend = NdArray;
#[derive(Debug, Clone, Copy)]
struct Constant;
#[derive(Debug, Clone)]
struct Params {
pop_size: usize,
dim: usize,
}
#[derive(Debug, Clone)]
struct State {
generation: usize,
best: f32,
}
impl Strategy<TestBackend> for Constant {
type Params = Params;
type State = State;
type Genome = Tensor<TestBackend, 2>;
fn init(
&self,
params: &Params,
_: &mut dyn Rng,
device: &<TestBackend as Backend>::Device,
) -> State {
let _ = device;
let _ = params;
State {
generation: 0,
best: f32::INFINITY,
}
}
fn ask(
&self,
params: &Params,
state: &State,
_: &mut dyn Rng,
device: &<TestBackend as Backend>::Device,
) -> (Tensor<TestBackend, 2>, State) {
let data = TensorData::new(
vec![0.0f32; params.pop_size * params.dim],
[params.pop_size, params.dim],
);
let pop = Tensor::<TestBackend, 2>::from_data(data, device);
(pop, state.clone())
}
fn tell(
&self,
_: &Params,
_: Tensor<TestBackend, 2>,
fitness: Tensor<TestBackend, 1>,
mut state: State,
_: &mut dyn Rng,
) -> (State, StrategyMetrics) {
let values = fitness.into_data().into_vec::<f32>().unwrap();
state.generation += 1;
let metrics = StrategyMetrics::from_host_fitness(state.generation, &values, state.best);
state.best = metrics.best_fitness_ever;
(state, metrics)
}
fn best(&self, _state: &State) -> Option<(Tensor<TestBackend, 2>, f32)> {
None
}
}
struct FortyTwo;
impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2>> for FortyTwo {
fn evaluate_batch(
&mut self,
population: &Tensor<B, 2>,
device: &B::Device,
) -> Tensor<B, 1> {
let n = population.shape().dims[0];
let data = TensorData::new(vec![42.0f32; n], [n]);
Tensor::<B, 1>::from_data(data, device)
}
}
#[test]
#[allow(clippy::float_cmp)]
fn harness_runs_one_generation() {
let device = Default::default();
let strategy = Constant;
let params = Params {
pop_size: 4,
dim: 3,
};
let mut harness =
EvolutionaryHarness::<TestBackend, _, _>::new(strategy, params, FortyTwo, 1, device, 5);
harness.reset();
let step = harness.step(());
assert_eq!(step.reward, -42.0);
assert!(!step.done);
assert_eq!(harness.generation(), 1);
let m = harness.latest_metrics().unwrap();
assert_eq!(m.generation, 1);
assert_eq!(m.population_size, 4);
approx::assert_relative_eq!(m.best_fitness, 42.0, epsilon = 1e-6);
}
#[test]
fn harness_reports_done_after_budget() {
let device = Default::default();
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
Constant,
Params {
pop_size: 2,
dim: 2,
},
FortyTwo,
1,
device,
2,
);
harness.reset();
assert!(!harness.step(()).done);
assert!(harness.step(()).done);
}
#[test]
fn from_host_fitness_computes_stats() {
let m = StrategyMetrics::from_host_fitness(5, &[3.0, 1.0, 5.0, 2.0], 4.0);
assert_eq!(m.generation, 5);
assert_eq!(m.population_size, 4);
approx::assert_relative_eq!(m.best_fitness, 1.0, epsilon = 1e-6);
approx::assert_relative_eq!(m.worst_fitness, 5.0, epsilon = 1e-6);
approx::assert_relative_eq!(m.mean_fitness, 2.75, epsilon = 1e-6);
approx::assert_relative_eq!(m.best_fitness_ever, 1.0, epsilon = 1e-6);
}
}