optirs_nas/hyperparameter/
evolution.rs1use 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#[derive(Debug, Clone, Copy)]
24pub struct EvolutionConfig {
25 pub population_size: usize,
27 pub tournament_size: usize,
29 pub crossover_rate: f64,
31 pub mutation_rate: f64,
33 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
49fn 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
65pub 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 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 None => sample_parameter(range, rng),
107 };
108
109 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
153pub 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}