use scirs2_core::numeric::Float;
use scirs2_core::random::Random;
use scirs2_core::RngExt;
use std::collections::{HashMap, VecDeque};
use std::fmt::Debug;
use crate::error::{OptimError, Result};
use crate::nas_engine::config::ParameterRange;
use crate::nas_engine::{OptimizerArchitecture, SearchResult, SearchSpaceConfig};
use crate::EvaluationMetric;
use super::random::RandomSearch;
use super::{SearchStrategy, SearchStrategyStatistics};
const FITNESS_MEMO_FACTOR: usize = 64;
pub struct EvolutionarySearch<T: Float + Debug + Send + Sync + 'static> {
pub(crate) population: Vec<OptimizerArchitecture<T>>,
pub population_size: usize,
pub(crate) mutation_rate: f64,
pub(crate) crossover_rate: f64,
pub(crate) tournament_size: usize,
pub(crate) generation_count: usize,
pub(crate) statistics: SearchStrategyStatistics<T>,
pub(crate) elite_preservation: bool,
pub(crate) elitism_ratio: f64,
pub(crate) adaptive_rates: bool,
pub(crate) fitness: HashMap<String, T>,
pub(crate) rng: Random<scirs2_core::random::rngs::StdRng>,
}
impl<T: Float + Debug + Default + Clone + Send + Sync + std::fmt::Debug + std::iter::Sum>
EvolutionarySearch<T>
{
pub fn new(
population_size: usize,
mutation_rate: f64,
crossover_rate: f64,
tournament_size: usize,
) -> Self {
Self::build(
population_size,
mutation_rate,
crossover_rate,
tournament_size,
scirs2_core::random::random::<u64>(),
)
}
pub fn with_seed(
population_size: usize,
mutation_rate: f64,
crossover_rate: f64,
tournament_size: usize,
seed: u64,
) -> Self {
Self::build(
population_size,
mutation_rate,
crossover_rate,
tournament_size,
seed,
)
}
fn build(
population_size: usize,
mutation_rate: f64,
crossover_rate: f64,
tournament_size: usize,
seed: u64,
) -> Self {
Self {
population: Vec::new(),
population_size,
mutation_rate,
crossover_rate,
tournament_size: tournament_size.max(1),
generation_count: 0,
statistics: SearchStrategyStatistics::default(),
elite_preservation: true,
elitism_ratio: 0.1,
adaptive_rates: true,
fitness: HashMap::new(),
rng: Random::seed(seed),
}
}
pub fn set_elitism(&mut self, preserve: bool, ratio: f64) {
self.elite_preservation = preserve;
self.elitism_ratio = if ratio.is_finite() {
ratio.clamp(0.0, 1.0)
} else {
0.0
};
}
pub fn elite_count(&self) -> usize {
if !self.elite_preservation || self.population.len() < 2 {
return 0;
}
let ratio = if self.elitism_ratio.is_finite() {
self.elitism_ratio.clamp(0.0, 1.0)
} else {
0.0
};
if ratio <= 0.0 {
return 0;
}
let raw = (ratio * self.population.len() as f64).ceil() as usize;
raw.clamp(1, self.population.len() - 1)
}
pub fn fitness_of(&self, architecture_id: &str) -> Option<T> {
self.fitness.get(architecture_id).copied()
}
pub(crate) fn ranked_indices(&self) -> Vec<usize> {
let mut indices: Vec<usize> = (0..self.population.len()).collect();
indices.sort_by(|&a, &b| {
let fa = self.fitness_of(&self.population[a].architecture_id);
let fb = self.fitness_of(&self.population[b].architecture_id);
match (fa, fb) {
(Some(x), Some(y)) => y
.partial_cmp(&x)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| {
self.population[a]
.architecture_id
.cmp(&self.population[b].architecture_id)
}),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => self.population[a]
.architecture_id
.cmp(&self.population[b].architecture_id),
}
});
indices
}
fn initialize_population(&mut self, searchspace: &SearchSpaceConfig) -> Result<()> {
self.population.clear();
self.fitness.clear();
let seed = self.rng.random::<u64>();
let mut random_search = RandomSearch::<T>::new(Some(seed));
random_search.initialize(searchspace)?;
for _ in 0..self.population_size {
let architecture =
random_search.generate_architecture(searchspace, &VecDeque::new())?;
self.population.push(architecture);
}
Ok(())
}
fn selection(&mut self) -> Result<usize> {
if self.population.is_empty() {
return Err(OptimError::OptimizationError(
"EvolutionarySearch cannot select a parent from an empty population".to_string(),
));
}
let mut best_idx = self.rng.gen_range(0..self.population.len());
let mut best_fitness = self.fitness_of(&self.population[best_idx].architecture_id);
for _ in 1..self.tournament_size {
let idx = self.rng.gen_range(0..self.population.len());
let fitness = self.fitness_of(&self.population[idx].architecture_id);
let better = match (fitness, best_fitness) {
(Some(candidate), Some(current)) => candidate > current,
(Some(_), None) => true,
_ => false,
};
if better {
best_idx = idx;
best_fitness = fitness;
}
}
Ok(best_idx)
}
pub(crate) fn replacement_index(&mut self) -> Result<usize> {
let ranked = self.ranked_indices();
if ranked.is_empty() {
return Err(OptimError::OptimizationError(
"EvolutionarySearch cannot replace a member of an empty population".to_string(),
));
}
let elites = self.elite_count().min(ranked.len() - 1);
let replaceable = &ranked[elites..];
let mut worst_offset = self.rng.gen_range(0..replaceable.len());
for _ in 1..self.tournament_size {
let offset = self.rng.gen_range(0..replaceable.len());
if offset > worst_offset {
worst_offset = offset;
}
}
Ok(replaceable[worst_offset])
}
fn crossover(
&mut self,
parent1: &OptimizerArchitecture<T>,
parent2: &OptimizerArchitecture<T>,
) -> Result<OptimizerArchitecture<T>> {
let mut child_components = Vec::new();
let max_len = parent1.components.len().max(parent2.components.len());
for i in 0..max_len {
let component = if i < parent1.components.len() && i < parent2.components.len() {
if self.rng.random::<f64>() < 0.5 {
parent1.components[i].clone()
} else {
parent2.components[i].clone()
}
} else if i < parent1.components.len() {
parent1.components[i].clone()
} else {
parent2.components[i].clone()
};
child_components.push(component);
}
let mut child_parameters = HashMap::new();
for (key, value) in &parent1.parameters {
if self.rng.random::<f64>() < 0.5 {
child_parameters.insert(key.clone(), *value);
} else if let Some(parent2_value) = parent2.parameters.get(key) {
child_parameters.insert(key.clone(), *parent2_value);
} else {
child_parameters.insert(key.clone(), *value);
}
}
Ok(OptimizerArchitecture {
components: child_components,
parameters: child_parameters,
connections: Vec::new(),
metadata: HashMap::new(),
hyperparameters: HashMap::new(),
architecture_id: format!("arch_{}", self.rng.random::<u64>()),
})
}
fn mutate(
&mut self,
architecture: &mut OptimizerArchitecture<T>,
searchspace: &SearchSpaceConfig,
) -> Result<()> {
let mutation_rate = self.mutation_rate;
for (param_name, current_value) in architecture.parameters.iter_mut() {
if self.rng.random::<f64>() < mutation_rate {
let param_range = searchspace
.components
.iter()
.flat_map(|c| c.hyperparameter_ranges.iter())
.find(|(name, _)| name == ¶m_name)
.map(|(_, range)| range);
if let Some(param_range) = param_range {
match param_range {
ParameterRange::Continuous(min, max) => {
let noise = self.rng.random::<f64>() * 0.1 - 0.05;
let current_f64 = current_value.to_f64().unwrap_or(0.0);
let new_val = current_f64 + noise;
let clamped = new_val.clamp(*min, *max);
*current_value = scirs2_core::numeric::NumCast::from(clamped)
.unwrap_or_else(|| T::zero());
}
ParameterRange::LogUniform(min, max) => {
let current_f64 = current_value.to_f64().unwrap_or(0.001);
let log_val = current_f64.ln();
let noise = self.rng.random::<f64>() * 0.2 - 0.1;
let new_log = log_val + noise;
let new_val = new_log.exp().clamp(*min, *max);
*current_value = scirs2_core::numeric::NumCast::from(new_val)
.unwrap_or_else(|| T::zero());
}
_ => {
}
}
}
}
}
Ok(())
}
pub(crate) fn record_result(&mut self, result: &SearchResult<T>) {
let Some(&score) = result
.evaluation_results
.metric_scores
.get(&EvaluationMetric::FinalPerformance)
else {
return;
};
let id = result.architecture.architecture_id.clone();
if id.is_empty() {
return;
}
self.fitness
.entry(id)
.and_modify(|best| {
if score > *best {
*best = score;
}
})
.or_insert(score);
}
fn record_history(&mut self, history: &VecDeque<SearchResult<T>>) {
for result in history {
self.record_result(result);
}
self.prune_fitness();
}
fn prune_fitness(&mut self) {
let cap = FITNESS_MEMO_FACTOR.saturating_mul(self.population_size.max(1));
if self.fitness.len() <= cap {
return;
}
let live: std::collections::HashSet<String> = self
.population
.iter()
.map(|member| member.architecture_id.clone())
.collect();
self.fitness.retain(|id, _| live.contains(id));
}
fn calculate_recent_improvement(&self, performances: &[T]) -> T {
if performances.len() < 10 {
return T::zero();
}
let recent_avg = performances.iter().rev().take(5).cloned().sum::<T>()
/ scirs2_core::numeric::NumCast::from(5.0).unwrap_or_else(|| T::zero());
let earlier_avg = performances
.iter()
.rev()
.skip(5)
.take(5)
.cloned()
.sum::<T>()
/ scirs2_core::numeric::NumCast::from(5.0).unwrap_or_else(|| T::zero());
recent_avg - earlier_avg
}
}
impl<T: Float + Debug + Default + Clone + Send + Sync + std::fmt::Debug + std::iter::Sum>
SearchStrategy<T> for EvolutionarySearch<T>
{
fn initialize(&mut self, searchspace: &SearchSpaceConfig) -> Result<()> {
self.initialize_population(searchspace)?;
self.generation_count = 0;
Ok(())
}
fn generate_architecture(
&mut self,
searchspace: &SearchSpaceConfig,
history: &VecDeque<SearchResult<T>>,
) -> Result<OptimizerArchitecture<T>> {
if self.population.is_empty() {
self.initialize_population(searchspace)?;
}
if self.population.is_empty() {
return Err(OptimError::SearchSpaceError(
"EvolutionarySearch produced an empty population; the search space \
must allow at least one architecture"
.to_string(),
));
}
self.record_history(history);
let evaluated = self
.population
.iter()
.any(|member| self.fitness.contains_key(&member.architecture_id));
if !evaluated {
let idx = self.rng.gen_range(0..self.population.len());
self.statistics.total_architectures_generated += 1;
return Ok(self.population[idx].clone());
}
let parent1_idx = self.selection()?;
let parent2_idx = self.selection()?;
let parent1 = self.population[parent1_idx].clone();
let parent2 = self.population[parent2_idx].clone();
let mut child = if self.rng.random::<f64>() < self.crossover_rate {
self.crossover(&parent1, &parent2)?
} else {
parent1
};
self.mutate(&mut child, searchspace)?;
child.architecture_id = format!("arch_{}", self.rng.random::<u64>());
let victim = self.replacement_index()?;
self.population[victim] = child.clone();
self.generation_count += 1;
self.statistics.total_architectures_generated += 1;
Ok(child)
}
fn update_with_results(&mut self, results: &[SearchResult<T>]) -> Result<()> {
if !results.is_empty() {
for result in results {
self.record_result(result);
}
self.prune_fitness();
let performances: Vec<T> = results
.iter()
.filter_map(|r| {
r.evaluation_results
.metric_scores
.get(&EvaluationMetric::FinalPerformance)
})
.cloned()
.collect();
if !performances.is_empty() {
self.statistics.best_performance = performances
.iter()
.max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.cloned()
.unwrap_or(T::zero());
let sum: T = performances.iter().cloned().sum();
let count: T = scirs2_core::numeric::NumCast::from(performances.len() as f64)
.unwrap_or_else(T::one);
self.statistics.average_performance = if count > T::zero() {
sum / count
} else {
T::zero()
};
if self.adaptive_rates && self.generation_count > 10 {
let recent_improvement = self.calculate_recent_improvement(&performances);
if recent_improvement
< scirs2_core::numeric::NumCast::from(0.01).unwrap_or_else(|| T::zero())
{
self.mutation_rate = (self.mutation_rate * 1.1).min(0.5);
} else {
self.mutation_rate = (self.mutation_rate * 0.95).max(0.01);
}
}
}
}
Ok(())
}
fn name(&self) -> &str {
"EvolutionarySearch"
}
fn get_statistics(&self) -> SearchStrategyStatistics<T> {
let mut stats = self.statistics.clone();
stats.exploration_rate =
scirs2_core::numeric::NumCast::from(self.mutation_rate).unwrap_or_else(|| T::zero());
stats.exploitation_rate = scirs2_core::numeric::NumCast::from(1.0 - self.mutation_rate)
.unwrap_or_else(|| T::zero());
stats
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::nas_engine::config::{
ComponentType as ConfigComponentType, OptimizerComponentConfig,
};
use crate::nas_engine::results::{
ArchitectureEncoding, EvaluationResults, ResourceUsage, SearchResultMetadata,
};
fn search_space() -> SearchSpaceConfig {
SearchSpaceConfig {
components: vec![
OptimizerComponentConfig {
component_type: ConfigComponentType::Adam,
hyperparameter_ranges: {
let mut ranges = HashMap::new();
ranges.insert(
"learning_rate".to_string(),
ParameterRange::LogUniform(1e-4, 1e-1),
);
ranges
},
complexity_score: 1.0,
memory_requirement: 1024,
computational_cost: 1.0,
compatibility_constraints: Vec::new(),
},
OptimizerComponentConfig {
component_type: ConfigComponentType::SGD,
hyperparameter_ranges: {
let mut ranges = HashMap::new();
ranges.insert(
"momentum".to_string(),
ParameterRange::Continuous(0.5, 0.99),
);
ranges
},
complexity_score: 0.5,
memory_requirement: 512,
computational_cost: 0.5,
compatibility_constraints: Vec::new(),
},
],
min_components: 1,
max_components: 3,
..SearchSpaceConfig::default()
}
}
fn result_for(id: &str, score: f64) -> SearchResult<f64> {
let mut metric_scores = HashMap::new();
metric_scores.insert(EvaluationMetric::FinalPerformance, score);
SearchResult {
architecture: OptimizerArchitecture {
architecture_id: id.to_string(),
components: vec!["Adam".to_string()],
parameters: HashMap::new(),
connections: Vec::new(),
metadata: HashMap::new(),
hyperparameters: HashMap::new(),
},
evaluation_results: EvaluationResults {
metric_scores,
overall_score: score,
confidence_intervals: HashMap::new(),
evaluation_time: std::time::Duration::from_secs(0),
success: true,
error_message: None,
cv_results: None,
benchmark_results: HashMap::new(),
training_trajectory: Vec::new(),
},
generation: 0,
search_time: 0.0,
resource_usage: ResourceUsage::default(),
encoding: ArchitectureEncoding::default(),
metadata: SearchResultMetadata::default(),
}
}
fn score_population(search: &mut EvolutionarySearch<f64>) {
let ids: Vec<String> = search
.population
.iter()
.map(|member| member.architecture_id.clone())
.collect();
for (rank, id) in ids.iter().enumerate() {
let result = result_for(id, 1.0 - rank as f64 * 0.01);
search.record_result(&result);
}
}
fn seeded(population_size: usize, seed: u64) -> EvolutionarySearch<f64> {
let mut search = EvolutionarySearch::<f64>::with_seed(population_size, 0.2, 0.8, 3, seed);
search
.initialize(&search_space())
.expect("initialize the population");
search
}
#[test]
fn fitness_is_attributed_by_architecture_id() {
let mut search = seeded(4, 11);
let id = search.population[2].architecture_id.clone();
assert!(search.fitness_of(&id).is_none());
search.record_result(&result_for(&id, 0.42));
assert_eq!(search.fitness_of(&id), Some(0.42));
search.record_result(&result_for(&id, 0.10));
assert_eq!(search.fitness_of(&id), Some(0.42));
search.record_result(&result_for(&id, 0.90));
assert_eq!(search.fitness_of(&id), Some(0.90));
search.record_result(&result_for("not-in-population", 1.0));
assert_eq!(search.fitness_of("not-in-population"), Some(1.0));
for member in &search.population {
if member.architecture_id != id {
assert!(search.fitness_of(&member.architecture_id).is_none());
}
}
}
#[test]
fn ranking_puts_the_fittest_first_and_the_unevaluated_last() {
let mut search = seeded(5, 12);
let ids: Vec<String> = search
.population
.iter()
.map(|m| m.architecture_id.clone())
.collect();
search.record_result(&result_for(&ids[1], 0.2));
search.record_result(&result_for(&ids[3], 0.9));
let ranked = search.ranked_indices();
assert_eq!(ranked[0], 3, "the best evaluated member must rank first");
assert_eq!(ranked[1], 1, "the other evaluated member must rank second");
let tail: Vec<usize> = ranked[2..].to_vec();
assert_eq!(tail.len(), 3);
assert!(tail.iter().all(|&i| i != 1 && i != 3));
assert_eq!(tail, search.ranked_indices()[2..].to_vec());
}
#[test]
fn elite_count_protects_a_fraction_but_never_the_whole_population() {
let mut search = seeded(10, 13);
search.set_elitism(true, 0.1);
assert_eq!(search.elite_count(), 1);
search.set_elitism(true, 0.35);
assert_eq!(search.elite_count(), 4);
search.set_elitism(true, 1.0);
assert_eq!(search.elite_count(), 9);
search.set_elitism(true, 0.0);
assert_eq!(search.elite_count(), 0);
search.set_elitism(false, 0.5);
assert_eq!(search.elite_count(), 0);
search.set_elitism(true, f64::NAN);
assert_eq!(search.elite_count(), 0);
search.set_elitism(true, 5.0);
assert_eq!(search.elite_count(), 9);
let mut tiny = seeded(1, 14);
tiny.set_elitism(true, 0.5);
assert_eq!(tiny.elite_count(), 0);
}
#[test]
fn elitism_keeps_the_fittest_members_out_of_the_replacement_pool() {
let mut search = seeded(10, 15);
score_population(&mut search);
search.set_elitism(true, 0.3);
let protected: Vec<usize> = search.ranked_indices()[..search.elite_count()].to_vec();
assert_eq!(protected.len(), 3);
for _ in 0..500 {
let victim = search.replacement_index().expect("a victim must exist");
assert!(
!protected.contains(&victim),
"elitism must never offer a protected member for replacement"
);
}
search.set_elitism(false, 0.0);
search.tournament_size = 1;
let best = search.ranked_indices()[0];
let mut saw_best = false;
for _ in 0..500 {
if search.replacement_index().expect("a victim must exist") == best {
saw_best = true;
break;
}
}
assert!(
saw_best,
"without elitism the fittest member must be reachable for replacement"
);
}
#[test]
fn tournament_size_controls_replacement_pressure() {
let mut search = seeded(10, 21);
score_population(&mut search);
search.set_elitism(false, 0.0);
let worst = *search
.ranked_indices()
.last()
.expect("a non-empty population has a worst member");
search.tournament_size = 10;
let wide = (0..400)
.filter(|_| search.replacement_index().expect("victim") == worst)
.count();
search.tournament_size = 1;
let uniform = (0..400)
.filter(|_| search.replacement_index().expect("victim") == worst)
.count();
assert!(
wide > uniform,
"a wider tournament must concentrate replacement on the worst member: \
{wide} vs {uniform}"
);
assert!(
wide > 200,
"a tournament as wide as the population should usually pick the worst: {wide}/400"
);
assert!(
uniform < 120,
"a tournament of one must be roughly uniform over 10 members: {uniform}/400"
);
}
#[test]
fn selection_prefers_the_member_with_the_better_recorded_score() {
let mut search = seeded(2, 16);
let ids: Vec<String> = search
.population
.iter()
.map(|m| m.architecture_id.clone())
.collect();
search.record_result(&result_for(&ids[0], 0.1));
search.record_result(&result_for(&ids[1], 0.9));
search.tournament_size = 8;
let mut picked_better = 0;
for _ in 0..200 {
if search.selection().expect("selection must succeed") == 1 {
picked_better += 1;
}
}
assert!(
picked_better > 190,
"tournament selection ignored recorded fitness: {picked_better}/200"
);
}
#[test]
fn an_evaluated_generation_breeds_and_replaces_by_id() {
let space = search_space();
let mut search = seeded(6, 17);
score_population(&mut search);
search.set_elitism(true, 0.5);
let protected: Vec<String> = search.ranked_indices()[..search.elite_count()]
.iter()
.map(|&i| search.population[i].architecture_id.clone())
.collect();
assert_eq!(protected.len(), 3);
for _ in 0..30 {
let child = search
.generate_architecture(&space, &VecDeque::new())
.expect("generation must succeed");
assert!(search
.population
.iter()
.any(|member| member.architecture_id == child.architecture_id));
for id in &protected {
assert!(
search
.population
.iter()
.any(|member| &member.architecture_id == id),
"protected architecture {id} was evicted"
);
}
}
assert_eq!(search.generation_count, 30);
}
#[test]
fn an_unevaluated_population_hands_back_a_member_instead_of_breeding() {
let space = search_space();
let mut search = seeded(4, 18);
let architecture = search
.generate_architecture(&space, &VecDeque::new())
.expect("generation must succeed");
assert!(
search
.population
.iter()
.any(|member| member.architecture_id == architecture.architecture_id),
"with no measured fitness the strategy must return an existing member"
);
assert_eq!(
search.generation_count, 0,
"no generation has completed without a single evaluation"
);
}
#[test]
fn history_results_are_absorbed_even_without_an_explicit_update() {
let space = search_space();
let mut search = seeded(4, 19);
let id = search.population[0].architecture_id.clone();
let mut history = VecDeque::new();
history.push_back(result_for(&id, 0.75));
let _ = search
.generate_architecture(&space, &history)
.expect("generation must succeed");
assert_eq!(search.fitness_of(&id), Some(0.75));
}
#[test]
fn seeding_is_reproducible_and_unseeded_searches_differ() {
let space = search_space();
let mut a = seeded(6, 0xA11CE);
let mut b = seeded(6, 0xA11CE);
score_population(&mut a);
score_population(&mut b);
for _ in 0..5 {
let left = a
.generate_architecture(&space, &VecDeque::new())
.expect("generation must succeed");
let right = b
.generate_architecture(&space, &VecDeque::new())
.expect("generation must succeed");
assert_eq!(left.components, right.components);
assert_eq!(left.architecture_id, right.architecture_id);
}
let first = EvolutionarySearch::<f64>::new(6, 0.2, 0.8, 3);
let second = EvolutionarySearch::<f64>::new(6, 0.2, 0.8, 3);
let mut first = first;
let mut second = second;
first.initialize(&space).expect("initialize");
second.initialize(&space).expect("initialize");
let ids_first: Vec<&String> = first
.population
.iter()
.map(|m| &m.architecture_id)
.collect();
let ids_second: Vec<&String> = second
.population
.iter()
.map(|m| &m.architecture_id)
.collect();
assert_ne!(ids_first, ids_second);
}
#[test]
fn statistics_survive_an_empty_metric_set() {
let mut search = seeded(3, 20);
let mut result = result_for("no-metrics", 0.0);
result.evaluation_results.metric_scores.clear();
search
.update_with_results(std::slice::from_ref(&result))
.expect("update must succeed");
assert!(search.fitness_of("no-metrics").is_none());
assert_eq!(search.get_statistics().average_performance, 0.0);
}
}