Skip to main content

fugue_evo/hyperparameter/
self_adaptive.rs

1//! Self-adaptive control mechanisms
2//!
3//! In self-adaptive evolution strategies, strategy parameters (like mutation step sizes)
4//! are encoded in the genome and evolve alongside the solution parameters.
5
6use rand::Rng;
7use rand_distr::StandardNormal;
8use serde::{Deserialize, Serialize};
9
10use crate::genome::traits::EvolutionaryGenome;
11
12/// Strategy parameters that can be evolved alongside the genome
13#[derive(Clone, Debug, Serialize, Deserialize)]
14pub enum StrategyParams {
15    /// Single global step size (σ) - (1, σ)-ES or simple ES
16    Isotropic(f64),
17
18    /// Per-gene step sizes (σ₁, ..., σₙ) - Non-isotropic mutation
19    NonIsotropic(Vec<f64>),
20
21    /// Full covariance with rotation angles
22    /// Contains step sizes (σ₁, ..., σₙ) and rotation angles (α₁, ..., αₖ) where k = n(n-1)/2
23    Correlated {
24        sigmas: Vec<f64>,
25        rotations: Vec<f64>,
26    },
27}
28
29impl StrategyParams {
30    /// Create isotropic parameters with given step size
31    pub fn isotropic(sigma: f64) -> Self {
32        Self::Isotropic(sigma)
33    }
34
35    /// Create non-isotropic parameters with given step sizes
36    pub fn non_isotropic(sigmas: Vec<f64>) -> Self {
37        Self::NonIsotropic(sigmas)
38    }
39
40    /// Create correlated parameters
41    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    /// Get dimension of the strategy parameters
51    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    /// Absolute numerical-underflow floor for step sizes.
60    ///
61    /// This is *only* a guard against σ decaying to a literal zero /
62    /// degenerate delta distribution — it does **not** prevent premature
63    /// convergence (1e-10 is many orders of magnitude below typical domain
64    /// scales, so the population has long since stagnated before this floor
65    /// ever engages). For a floor that actually guards against premature
66    /// step-size collapse, pass a problem-scaled `min_sigma` to
67    /// [`StrategyParams::mutate`] (regression: EV-62).
68    const SIGMA_UNDERFLOW_FLOOR: f64 = 1e-10;
69
70    /// Mutate the strategy parameters using the log-normal self-adaptation rule.
71    ///
72    /// Following Beyer & Schwefel (2002) and Eiben & Smith (*Introduction to
73    /// Evolutionary Computing* §4.4.2), the uncorrelated-mutation update with
74    /// `n` step sizes is
75    ///
76    /// ```text
77    /// σ_i' = σ_i · exp(τ' · N(0,1) + τ · N_i(0,1))
78    /// ```
79    ///
80    /// where `N(0,1)` is drawn **once per individual** (shared across all
81    /// coordinates) and `N_i(0,1)` is drawn independently per coordinate, with:
82    /// * `τ' = 1/√(2n)` on the shared/global deviate — the *smaller* coefficient,
83    ///   because its effect is coherent across all `n` coordinates, and
84    /// * `τ = 1/√(2√n)` on the per-coordinate deviate — the *larger* coefficient.
85    ///
86    /// For the single-step-size (isotropic) case the standard rule is
87    /// `σ' = σ · exp(τ₀ · N(0,1))` with `τ₀ = 1/√n` (Schwefel).
88    ///
89    /// `min_sigma` is a caller-supplied, problem-scaled lower bound on the step
90    /// size; the effective floor is `max(min_sigma, SIGMA_UNDERFLOW_FLOOR)`.
91    pub fn mutate<R: Rng>(&mut self, n: usize, min_sigma: f64, rng: &mut R) {
92        // Global (once-per-individual) learning rate τ' = 1/√(2n).
93        let tau_prime = 1.0 / (2.0 * n as f64).sqrt();
94        // Per-coordinate learning rate τ = 1/√(2√n).
95        let tau = 1.0 / (2.0 * (n as f64).sqrt()).sqrt();
96        // Single-step-size (isotropic) learning rate τ₀ = 1/√n.
97        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                // Update step sizes
115                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                // Update rotation angles (≈5° per step)
121                let beta = 0.0873;
122                for alpha in rotations.iter_mut() {
123                    *alpha += beta * rng.sample::<f64, _>(StandardNormal);
124                }
125            }
126        }
127    }
128
129    /// Get step size for a specific gene (for non-isotropic and correlated)
130    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    /// Get all step sizes
139    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/// Genome wrapper with self-adaptive strategy parameters
149#[derive(Clone, Debug, Serialize, Deserialize)]
150pub struct AdaptiveGenome<G> {
151    /// The underlying genome
152    pub genome: G,
153    /// Strategy parameters
154    pub strategy: StrategyParams,
155}
156
157impl<G: EvolutionaryGenome> AdaptiveGenome<G> {
158    /// Create a new adaptive genome with isotropic strategy
159    pub fn new_isotropic(genome: G, initial_sigma: f64) -> Self {
160        Self {
161            genome,
162            strategy: StrategyParams::Isotropic(initial_sigma),
163        }
164    }
165
166    /// Create a new adaptive genome with non-isotropic strategy
167    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    /// Create a new adaptive genome with correlated strategy
175    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    /// Get reference to the underlying genome
183    pub fn inner(&self) -> &G {
184        &self.genome
185    }
186
187    /// Get mutable reference to the underlying genome
188    pub fn inner_mut(&mut self) -> &mut G {
189        &mut self.genome
190    }
191
192    /// Consume and return the underlying genome
193    pub fn into_inner(self) -> G {
194        self.genome
195    }
196}
197
198/// Crossover for adaptive genomes
199///
200/// Performs intermediate recombination of strategy parameters.
201pub 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            // Geometric mean of step sizes
210            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        // Mixed types: randomly pick one parent's strategy
243        (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/// Learning rate parameters for self-adaptation
259#[derive(Clone, Debug)]
260pub struct LearningRates {
261    /// Global (once-per-individual) learning rate τ' = 1/√(2n)
262    pub tau_prime: f64,
263    /// Per-coordinate learning rate τ = 1/√(2√n)
264    pub tau: f64,
265    /// Rotation angle step β
266    pub beta: f64,
267}
268
269impl LearningRates {
270    /// Create default learning rates for dimension n.
271    ///
272    /// Per Beyer & Schwefel (2002): the global (shared) deviate carries the
273    /// smaller rate `τ' = 1/√(2n)`, and each per-coordinate deviate carries the
274    /// larger rate `τ = 1/√(2√n)` (regression: EV-05, EV-24).
275    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, // ≈ 5 degrees
280        }
281    }
282
283    /// Create custom learning rates
284    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        // Sigma should remain positive after mutation
309        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        // 3 sigmas + 3 rotation angles = 6
325        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        // Even with tiny initial sigma and no problem-scaled floor, sigma must
334        // not underflow below the absolute floor.
335        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    /// regression: EV-62 — a configurable `min_sigma` must actually floor the
343    /// step size (the old code only had an absolute 1e-10 underflow guard that
344    /// did nothing to prevent premature collapse). Pre-fix `mutate` had no
345    /// `min_sigma` parameter and would let σ decay far below any meaningful
346    /// problem scale.
347    #[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        // Isotropic
354        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        // Non-isotropic: every coordinate must respect the floor.
366        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    /// regression: EV-05 / EV-24 — the shared (once-per-individual) deviate must
376    /// carry the smaller coefficient τ' = 1/√(2n) and each per-coordinate
377    /// deviate the larger τ = 1/√(2√n). The cross-coordinate covariance of
378    /// ln(σ_i'/σ_i) equals (coefficient on the shared deviate)²; with the
379    /// pre-fix swapped rates it would be 1/(2√n) ≈ 0.158 for n=10 instead of the
380    /// correct 1/(2n) = 0.05.
381    #[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        // Collect ln-ratios for coordinates 0 and 1 across many independent
389        // single mutations (each starts from σ = 1).
390        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        // Cov equals (coefficient on shared deviate)². Correct: 1/(2n) = 0.05.
409        // Buggy (swapped): 1/(2√n) ≈ 0.158.
410        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    /// regression: EV-97 — the isotropic (single-step-size) case must use
422    /// τ₀ = 1/√n, so Var(ln σ'/σ) = 1/n. The pre-fix code used 1/√(2√n), giving
423    /// variance 1/(2√n) ≈ 0.158 for n=10 instead of the correct 0.1.
424    #[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; // τ₀² = 1/n
441        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        // Geometric mean of 0.1 and 0.4 = 0.2
473        assert!((child.strategy.get_sigma(0) - 0.2).abs() < 1e-10);
474    }
475
476    /// regression: EV-05 / EV-24 — the global rate τ' must be the *smaller*
477    /// 1/√(2n) and the per-coordinate rate τ the *larger* 1/√(2√n). The pre-fix
478    /// `for_dimension` had these two values swapped between the fields.
479    #[test]
480    fn test_learning_rates() {
481        let rates = LearningRates::for_dimension(10);
482
483        // Global (shared) rate τ' = 1/√(2*10) ≈ 0.2236
484        assert!((rates.tau_prime - 0.2236).abs() < 0.01);
485
486        // Per-coordinate rate τ = 1/√(2*√10) ≈ 0.3976
487        assert!((rates.tau - 0.3976).abs() < 0.01);
488
489        // The global (shared) rate must be strictly smaller than the
490        // per-coordinate rate.
491        assert!(rates.tau_prime < rates.tau);
492    }
493}