Skip to main content

optirs_nas/hyperparameter/
evolution.rs

1//! Evolutionary hyperparameter search for
2//! [`super::OptimizationStrategy::Evolutionary`] (F15).
3//!
4//! The previous implementation pushed 20 random configurations into
5//! `state.population` and then never read it again — the population was
6//! write-only, and every suggestion after the first was a mutation of the single
7//! best configuration, so there was no recombination and no selection pressure
8//! beyond "the best so far".
9//!
10//! This module implements real selection, crossover and mutation over the
11//! population, all bounded by the search space.
12
13use super::support::{
14    bounds, denormalize, normalize, sample_parameter, snap_to_discrete, sorted_categorical_names,
15    sorted_parameter_names, standard_normal, StrategyRng, SuggestedParameters,
16};
17use super::{HyperparameterConfiguration, HyperparameterSpace};
18use crate::error::{OptimError, Result};
19use scirs2_core::numeric::Float;
20use std::collections::HashMap;
21
22/// Tunables of the evolutionary strategy.
23#[derive(Debug, Clone, Copy)]
24pub struct EvolutionConfig {
25    /// Target population size.
26    pub population_size: usize,
27    /// Number of individuals compared in each tournament.
28    pub tournament_size: usize,
29    /// Per-parameter probability of taking the second parent's value.
30    pub crossover_rate: f64,
31    /// Per-parameter probability of a mutation.
32    pub mutation_rate: f64,
33    /// Gaussian mutation width as a fraction of the (normalized) axis.
34    pub mutation_scale: f64,
35}
36
37impl Default for EvolutionConfig {
38    fn default() -> Self {
39        Self {
40            population_size: 20,
41            tournament_size: 3,
42            crossover_rate: 0.5,
43            mutation_rate: 0.3,
44            mutation_scale: 0.15,
45        }
46    }
47}
48
49/// Pick the fittest of `tournament_size` random individuals (higher score wins).
50fn tournament<'a, T: Float>(
51    population: &'a [(&'a HyperparameterConfiguration<T>, f64)],
52    config: &EvolutionConfig,
53    rng: &mut StrategyRng,
54) -> &'a HyperparameterConfiguration<T> {
55    let mut best = rng.gen_range(0..population.len());
56    for _ in 1..config.tournament_size.max(1) {
57        let challenger = rng.gen_range(0..population.len());
58        if population[challenger].1 > population[best].1 {
59            best = challenger;
60        }
61    }
62    population[best].0
63}
64
65/// Produce one offspring by tournament-selecting two parents, recombining them
66/// per parameter, and mutating the result inside the declared bounds.
67///
68/// Returns an error when the population is empty — there is nothing to breed
69/// from, and inventing a random configuration here would hide the fact that
70/// selection never happened.
71pub fn breed<T: Float>(
72    space: &HyperparameterSpace<T>,
73    population: &[(&HyperparameterConfiguration<T>, f64)],
74    config: &EvolutionConfig,
75    rng: &mut StrategyRng,
76) -> Result<SuggestedParameters<T>> {
77    if population.is_empty() {
78        return Err(OptimError::InvalidConfig(
79            "evolutionary search needs a scored population to select parents from \
80             (run the random initial design first)"
81                .to_string(),
82        ));
83    }
84
85    let first = tournament(population, config, rng);
86    let second = tournament(population, config, rng);
87
88    let mut parameters = HashMap::new();
89    for name in sorted_parameter_names(space) {
90        let Some(range) = space.parameter_ranges().get(&name) else {
91            continue;
92        };
93        // Recombine: take either parent's value for this gene.
94        let inherited = if rng.gen_range(0.0..1.0) < config.crossover_rate {
95            second.parameters.get(&name)
96        } else {
97            first.parameters.get(&name)
98        }
99        .or_else(|| first.parameters.get(&name))
100        .or_else(|| second.parameters.get(&name))
101        .map(|value| value.to_f64().unwrap_or(0.0));
102
103        let mut raw = match inherited {
104            Some(value) => value,
105            // Neither parent carries this gene: draw it from the prior.
106            None => sample_parameter(range, rng),
107        };
108
109        // Mutate on the parameter's own axis so a log-scaled range mutates
110        // multiplicatively rather than additively.
111        if rng.gen_range(0.0..1.0) < config.mutation_rate {
112            let unit = normalize(range, raw);
113            let mutated = (unit + standard_normal(rng) * config.mutation_scale).clamp(0.0, 1.0);
114            raw = denormalize(range, mutated);
115        }
116
117        let (lower, upper) = bounds(range);
118        raw = snap_to_discrete(range, raw.clamp(lower, upper));
119        parameters.insert(
120            name,
121            scirs2_core::numeric::NumCast::from(raw).unwrap_or_else(T::zero),
122        );
123    }
124
125    let mut categorical = HashMap::new();
126    for name in sorted_categorical_names(space) {
127        let Some(options) = space.categorical_options().get(&name) else {
128            continue;
129        };
130        if options.is_empty() {
131            continue;
132        }
133        let inherited = if rng.gen_range(0.0..1.0) < config.crossover_rate {
134            second.categorical_parameters.get(&name)
135        } else {
136            first.categorical_parameters.get(&name)
137        }
138        .or_else(|| first.categorical_parameters.get(&name))
139        .filter(|value| options.contains(value))
140        .cloned();
141
142        let chosen = if rng.gen_range(0.0..1.0) < config.mutation_rate || inherited.is_none() {
143            options[rng.gen_range(0..options.len())].clone()
144        } else {
145            inherited.unwrap_or_else(|| options[0].clone())
146        };
147        categorical.insert(name, chosen);
148    }
149
150    Ok((parameters, categorical))
151}
152
153/// Trim `population` to the fittest `population_size` individuals (higher score
154/// first). Called after every recorded evaluation so the population is a real,
155/// bounded, elitist pool rather than an append-only log.
156pub fn survivor_selection<T: Float>(
157    population: &mut Vec<HyperparameterConfiguration<T>>,
158    config: &EvolutionConfig,
159) {
160    population.sort_by(|a, b| {
161        let sa = a
162            .score
163            .and_then(|s| s.to_f64())
164            .unwrap_or(f64::NEG_INFINITY);
165        let sb = b
166            .score
167            .and_then(|s| s.to_f64())
168            .unwrap_or(f64::NEG_INFINITY);
169        sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
170    });
171    population.truncate(config.population_size.max(1));
172}
173
174#[cfg(test)]
175mod tests {
176    use super::super::{DistributionType, ParameterRange};
177    use super::*;
178    use scirs2_core::random::Random;
179
180    fn space() -> HyperparameterSpace<f64> {
181        let mut space: HyperparameterSpace<f64> = HyperparameterSpace::new();
182        space.add_parameter(
183            "x".to_string(),
184            ParameterRange {
185                name: "x".to_string(),
186                min_value: 0.0,
187                max_value: 1.0,
188                distribution: DistributionType::Uniform,
189                log_scale: false,
190                discrete_values: None,
191            },
192        );
193        space.add_categorical_parameter(
194            "opt".to_string(),
195            vec!["adam".to_string(), "sgd".to_string()],
196        );
197        space
198    }
199
200    fn config_with(id: &str, x: f64, opt: &str, score: f64) -> HyperparameterConfiguration<f64> {
201        let mut parameters = HashMap::new();
202        parameters.insert("x".to_string(), x);
203        let mut categorical = HashMap::new();
204        categorical.insert("opt".to_string(), opt.to_string());
205        HyperparameterConfiguration {
206            id: id.to_string(),
207            parameters,
208            categorical_parameters: categorical,
209            score: Some(score),
210            metadata: HashMap::new(),
211        }
212    }
213
214    #[test]
215    fn breeding_requires_a_population() {
216        let mut rng = Random::seed(1);
217        assert!(breed(&space(), &[], &EvolutionConfig::default(), &mut rng).is_err());
218    }
219
220    #[test]
221    fn selection_pressure_pulls_offspring_toward_the_fittest_parents() {
222        let population = [
223            config_with("a", 0.90, "adam", 1.0),
224            config_with("b", 0.88, "adam", 0.9),
225            config_with("c", 0.10, "sgd", -1.0),
226            config_with("d", 0.12, "sgd", -0.9),
227        ];
228        let scored: Vec<(&HyperparameterConfiguration<f64>, f64)> = population
229            .iter()
230            .map(|c| (c, c.score.unwrap_or(0.0)))
231            .collect();
232
233        let space = space();
234        let config = EvolutionConfig {
235            mutation_rate: 0.0,
236            ..EvolutionConfig::default()
237        };
238        let mut rng = Random::seed(555);
239        let mut values = Vec::new();
240        for _ in 0..60 {
241            let (parameters, _) = breed(&space, &scored, &config, &mut rng).expect("breed");
242            values.push(parameters["x"]);
243        }
244        let mean = values.iter().sum::<f64>() / values.len() as f64;
245        assert!(
246            mean > 0.6,
247            "tournament selection must favour the high-scoring parents, mean = {mean}"
248        );
249    }
250
251    #[test]
252    fn offspring_stay_inside_the_declared_bounds() {
253        let population = [config_with("a", 0.99, "adam", 1.0)];
254        let scored: Vec<(&HyperparameterConfiguration<f64>, f64)> =
255            population.iter().map(|c| (c, 1.0)).collect();
256        let space = space();
257        let config = EvolutionConfig {
258            mutation_rate: 1.0,
259            mutation_scale: 5.0,
260            ..EvolutionConfig::default()
261        };
262        let mut rng = Random::seed(31);
263        for _ in 0..200 {
264            let (parameters, categorical) =
265                breed(&space, &scored, &config, &mut rng).expect("breed");
266            let x = parameters["x"];
267            assert!((0.0..=1.0).contains(&x), "x escaped: {x}");
268            assert!(["adam", "sgd"].contains(&categorical["opt"].as_str()));
269        }
270    }
271
272    #[test]
273    fn crossover_mixes_both_parents() {
274        let population = [
275            config_with("low", 0.0, "adam", 1.0),
276            config_with("high", 1.0, "sgd", 1.0),
277        ];
278        let scored: Vec<(&HyperparameterConfiguration<f64>, f64)> =
279            population.iter().map(|c| (c, 1.0)).collect();
280        let space = space();
281        let config = EvolutionConfig {
282            mutation_rate: 0.0,
283            ..EvolutionConfig::default()
284        };
285        let mut rng = Random::seed(808);
286        let mut seen_low = false;
287        let mut seen_high = false;
288        for _ in 0..100 {
289            let (parameters, _) = breed(&space, &scored, &config, &mut rng).expect("breed");
290            if parameters["x"] == 0.0 {
291                seen_low = true;
292            }
293            if parameters["x"] == 1.0 {
294                seen_high = true;
295            }
296        }
297        assert!(
298            seen_low && seen_high,
299            "recombination must be able to inherit from either parent"
300        );
301    }
302
303    #[test]
304    fn survivor_selection_keeps_the_best_and_bounds_the_population() {
305        let mut population: Vec<HyperparameterConfiguration<f64>> = (0..50)
306            .map(|i| config_with(&format!("c{i}"), 0.5, "adam", i as f64))
307            .collect();
308        let config = EvolutionConfig {
309            population_size: 10,
310            ..EvolutionConfig::default()
311        };
312        survivor_selection(&mut population, &config);
313        assert_eq!(population.len(), 10);
314        assert_eq!(population[0].score, Some(49.0));
315        assert_eq!(population[9].score, Some(40.0));
316    }
317}