fugue_evo/hyperparameter/
self_adaptive.rs1use rand::Rng;
7use rand_distr::StandardNormal;
8use serde::{Deserialize, Serialize};
9
10use crate::genome::traits::EvolutionaryGenome;
11
12#[derive(Clone, Debug, Serialize, Deserialize)]
14pub enum StrategyParams {
15 Isotropic(f64),
17
18 NonIsotropic(Vec<f64>),
20
21 Correlated {
24 sigmas: Vec<f64>,
25 rotations: Vec<f64>,
26 },
27}
28
29impl StrategyParams {
30 pub fn isotropic(sigma: f64) -> Self {
32 Self::Isotropic(sigma)
33 }
34
35 pub fn non_isotropic(sigmas: Vec<f64>) -> Self {
37 Self::NonIsotropic(sigmas)
38 }
39
40 pub fn correlated(sigmas: Vec<f64>) -> Self {
42 let n = sigmas.len();
43 let num_rotations = n * (n - 1) / 2;
44 Self::Correlated {
45 sigmas,
46 rotations: vec![0.0; num_rotations],
47 }
48 }
49
50 pub fn dimension(&self) -> usize {
52 match self {
53 Self::Isotropic(_) => 1,
54 Self::NonIsotropic(sigmas) => sigmas.len(),
55 Self::Correlated { sigmas, rotations } => sigmas.len() + rotations.len(),
56 }
57 }
58
59 const SIGMA_UNDERFLOW_FLOOR: f64 = 1e-10;
69
70 pub fn mutate<R: Rng>(&mut self, n: usize, min_sigma: f64, rng: &mut R) {
92 let tau_prime = 1.0 / (2.0 * n as f64).sqrt();
94 let tau = 1.0 / (2.0 * (n as f64).sqrt()).sqrt();
96 let tau_0 = 1.0 / (n as f64).sqrt();
98 let floor = min_sigma.max(Self::SIGMA_UNDERFLOW_FLOOR);
99 let n0: f64 = rng.sample(StandardNormal);
100
101 match self {
102 Self::Isotropic(sigma) => {
103 *sigma *= (tau_0 * n0).exp();
104 *sigma = sigma.max(floor);
105 }
106 Self::NonIsotropic(sigmas) => {
107 for sigma in sigmas.iter_mut() {
108 let ni: f64 = rng.sample(StandardNormal);
109 *sigma *= (tau_prime * n0 + tau * ni).exp();
110 *sigma = sigma.max(floor);
111 }
112 }
113 Self::Correlated { sigmas, rotations } => {
114 for sigma in sigmas.iter_mut() {
116 let ni: f64 = rng.sample(StandardNormal);
117 *sigma *= (tau_prime * n0 + tau * ni).exp();
118 *sigma = sigma.max(floor);
119 }
120 let beta = 0.0873;
122 for alpha in rotations.iter_mut() {
123 *alpha += beta * rng.sample::<f64, _>(StandardNormal);
124 }
125 }
126 }
127 }
128
129 pub fn get_sigma(&self, gene_idx: usize) -> f64 {
131 match self {
132 Self::Isotropic(sigma) => *sigma,
133 Self::NonIsotropic(sigmas) => sigmas.get(gene_idx).copied().unwrap_or(sigmas[0]),
134 Self::Correlated { sigmas, .. } => sigmas.get(gene_idx).copied().unwrap_or(sigmas[0]),
135 }
136 }
137
138 pub fn sigmas(&self) -> Vec<f64> {
140 match self {
141 Self::Isotropic(sigma) => vec![*sigma],
142 Self::NonIsotropic(sigmas) => sigmas.clone(),
143 Self::Correlated { sigmas, .. } => sigmas.clone(),
144 }
145 }
146}
147
148#[derive(Clone, Debug, Serialize, Deserialize)]
150pub struct AdaptiveGenome<G> {
151 pub genome: G,
153 pub strategy: StrategyParams,
155}
156
157impl<G: EvolutionaryGenome> AdaptiveGenome<G> {
158 pub fn new_isotropic(genome: G, initial_sigma: f64) -> Self {
160 Self {
161 genome,
162 strategy: StrategyParams::Isotropic(initial_sigma),
163 }
164 }
165
166 pub fn new_non_isotropic(genome: G, initial_sigmas: Vec<f64>) -> Self {
168 Self {
169 genome,
170 strategy: StrategyParams::NonIsotropic(initial_sigmas),
171 }
172 }
173
174 pub fn new_correlated(genome: G, initial_sigmas: Vec<f64>) -> Self {
176 Self {
177 genome,
178 strategy: StrategyParams::correlated(initial_sigmas),
179 }
180 }
181
182 pub fn inner(&self) -> &G {
184 &self.genome
185 }
186
187 pub fn inner_mut(&mut self) -> &mut G {
189 &mut self.genome
190 }
191
192 pub fn into_inner(self) -> G {
194 self.genome
195 }
196}
197
198pub fn adaptive_crossover<G: Clone, R: Rng>(
202 parent1: &AdaptiveGenome<G>,
203 parent2: &AdaptiveGenome<G>,
204 child_genome: G,
205 rng: &mut R,
206) -> AdaptiveGenome<G> {
207 let strategy = match (&parent1.strategy, &parent2.strategy) {
208 (StrategyParams::Isotropic(s1), StrategyParams::Isotropic(s2)) => {
209 StrategyParams::Isotropic((s1 * s2).sqrt())
211 }
212 (StrategyParams::NonIsotropic(s1), StrategyParams::NonIsotropic(s2)) => {
213 let sigmas: Vec<f64> = s1
214 .iter()
215 .zip(s2.iter())
216 .map(|(a, b)| (a * b).sqrt())
217 .collect();
218 StrategyParams::NonIsotropic(sigmas)
219 }
220 (
221 StrategyParams::Correlated {
222 sigmas: s1,
223 rotations: r1,
224 },
225 StrategyParams::Correlated {
226 sigmas: s2,
227 rotations: r2,
228 },
229 ) => {
230 let sigmas: Vec<f64> = s1
231 .iter()
232 .zip(s2.iter())
233 .map(|(a, b)| (a * b).sqrt())
234 .collect();
235 let rotations: Vec<f64> = r1
236 .iter()
237 .zip(r2.iter())
238 .map(|(a, b)| (a + b) / 2.0)
239 .collect();
240 StrategyParams::Correlated { sigmas, rotations }
241 }
242 (s1, s2) => {
244 if rng.gen_bool(0.5) {
245 s1.clone()
246 } else {
247 s2.clone()
248 }
249 }
250 };
251
252 AdaptiveGenome {
253 genome: child_genome,
254 strategy,
255 }
256}
257
258#[derive(Clone, Debug)]
260pub struct LearningRates {
261 pub tau_prime: f64,
263 pub tau: f64,
265 pub beta: f64,
267}
268
269impl LearningRates {
270 pub fn for_dimension(n: usize) -> Self {
276 Self {
277 tau_prime: 1.0 / (2.0 * n as f64).sqrt(),
278 tau: 1.0 / (2.0 * (n as f64).sqrt()).sqrt(),
279 beta: 0.0873, }
281 }
282
283 pub fn custom(tau_prime: f64, tau: f64, beta: f64) -> Self {
285 Self {
286 tau_prime,
287 tau,
288 beta,
289 }
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use crate::genome::real_vector::RealVector;
297 use crate::genome::traits::RealValuedGenome;
298
299 #[test]
300 fn test_strategy_params_isotropic() {
301 let mut params = StrategyParams::isotropic(1.0);
302 assert_eq!(params.dimension(), 1);
303 assert!((params.get_sigma(0) - 1.0).abs() < 1e-10);
304
305 let mut rng = rand::thread_rng();
306 params.mutate(10, 0.0, &mut rng);
307
308 assert!(params.get_sigma(0) > 0.0);
310 }
311
312 #[test]
313 fn test_strategy_params_non_isotropic() {
314 let params = StrategyParams::non_isotropic(vec![0.1, 0.2, 0.3]);
315 assert_eq!(params.dimension(), 3);
316 assert!((params.get_sigma(0) - 0.1).abs() < 1e-10);
317 assert!((params.get_sigma(1) - 0.2).abs() < 1e-10);
318 assert!((params.get_sigma(2) - 0.3).abs() < 1e-10);
319 }
320
321 #[test]
322 fn test_strategy_params_correlated() {
323 let params = StrategyParams::correlated(vec![0.1, 0.2, 0.3]);
324 assert_eq!(params.dimension(), 6);
326 }
327
328 #[test]
329 fn test_strategy_params_underflow_floor() {
330 let mut params = StrategyParams::isotropic(1e-20);
331 let mut rng = rand::thread_rng();
332
333 for _ in 0..100 {
336 params.mutate(10, 0.0, &mut rng);
337 }
338
339 assert!(params.get_sigma(0) >= StrategyParams::SIGMA_UNDERFLOW_FLOOR);
340 }
341
342 #[test]
348 fn test_configurable_min_sigma_prevents_collapse() {
349 use rand::SeedableRng;
350 let mut rng = rand::rngs::StdRng::seed_from_u64(7);
351 let min_sigma = 0.1;
352
353 let mut iso = StrategyParams::isotropic(1.0);
355 for _ in 0..10_000 {
356 iso.mutate(10, min_sigma, &mut rng);
357 assert!(
358 iso.get_sigma(0) >= min_sigma,
359 "isotropic sigma {} fell below configured min_sigma {}",
360 iso.get_sigma(0),
361 min_sigma
362 );
363 }
364
365 let mut aniso = StrategyParams::non_isotropic(vec![1.0; 5]);
367 for _ in 0..10_000 {
368 aniso.mutate(5, min_sigma, &mut rng);
369 for i in 0..5 {
370 assert!(aniso.get_sigma(i) >= min_sigma);
371 }
372 }
373 }
374
375 #[test]
382 fn test_non_isotropic_learning_rate_assignment() {
383 use rand::SeedableRng;
384 let n = 10usize;
385 let samples = 60_000usize;
386 let mut rng = rand::rngs::StdRng::seed_from_u64(1234);
387
388 let mut r0 = Vec::with_capacity(samples);
391 let mut r1 = Vec::with_capacity(samples);
392 for _ in 0..samples {
393 let mut p = StrategyParams::non_isotropic(vec![1.0; n]);
394 p.mutate(n, 0.0, &mut rng);
395 r0.push(p.get_sigma(0).ln());
396 r1.push(p.get_sigma(1).ln());
397 }
398
399 let mean0 = r0.iter().sum::<f64>() / samples as f64;
400 let mean1 = r1.iter().sum::<f64>() / samples as f64;
401 let cov: f64 = r0
402 .iter()
403 .zip(r1.iter())
404 .map(|(a, b)| (a - mean0) * (b - mean1))
405 .sum::<f64>()
406 / samples as f64;
407
408 let expected = 1.0 / (2.0 * n as f64);
411 assert!(
412 (cov - expected).abs() < 0.02,
413 "cross-coordinate covariance {} should be ≈ {} (shared deviate rate²), \
414 not the swapped 1/(2√n) ≈ {}",
415 cov,
416 expected,
417 1.0 / (2.0 * (n as f64).sqrt())
418 );
419 }
420
421 #[test]
425 fn test_isotropic_learning_rate() {
426 use rand::SeedableRng;
427 let n = 10usize;
428 let samples = 60_000usize;
429 let mut rng = rand::rngs::StdRng::seed_from_u64(99);
430
431 let mut r = Vec::with_capacity(samples);
432 for _ in 0..samples {
433 let mut p = StrategyParams::isotropic(1.0);
434 p.mutate(n, 0.0, &mut rng);
435 r.push(p.get_sigma(0).ln());
436 }
437 let mean = r.iter().sum::<f64>() / samples as f64;
438 let var = r.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / samples as f64;
439
440 let expected = 1.0 / n as f64; assert!(
442 (var - expected).abs() < 0.02,
443 "Var(ln σ'/σ) {} should be ≈ 1/n = {} (τ₀ = 1/√n), not 1/(2√n) ≈ {}",
444 var,
445 expected,
446 1.0 / (2.0 * (n as f64).sqrt())
447 );
448 }
449
450 #[test]
451 fn test_adaptive_genome_creation() {
452 let genome = RealVector::new(vec![1.0, 2.0, 3.0]);
453 let adaptive = AdaptiveGenome::new_isotropic(genome.clone(), 0.5);
454
455 assert_eq!(adaptive.inner().genes(), genome.genes());
456 assert!((adaptive.strategy.get_sigma(0) - 0.5).abs() < 1e-10);
457 }
458
459 #[test]
460 fn test_adaptive_crossover() {
461 let mut rng = rand::thread_rng();
462
463 let g1 = RealVector::new(vec![1.0, 2.0]);
464 let g2 = RealVector::new(vec![3.0, 4.0]);
465 let child_genome = RealVector::new(vec![2.0, 3.0]);
466
467 let p1 = AdaptiveGenome::new_isotropic(g1, 0.1);
468 let p2 = AdaptiveGenome::new_isotropic(g2, 0.4);
469
470 let child = adaptive_crossover(&p1, &p2, child_genome, &mut rng);
471
472 assert!((child.strategy.get_sigma(0) - 0.2).abs() < 1e-10);
474 }
475
476 #[test]
480 fn test_learning_rates() {
481 let rates = LearningRates::for_dimension(10);
482
483 assert!((rates.tau_prime - 0.2236).abs() < 0.01);
485
486 assert!((rates.tau - 0.3976).abs() < 0.01);
488
489 assert!(rates.tau_prime < rates.tau);
492 }
493}