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    /// Get the minimum step size to prevent collapse
60    const MIN_SIGMA: f64 = 1e-10;
61
62    /// Mutate the strategy parameters using log-normal distribution
63    ///
64    /// τ = 1/√(2n) - global learning rate
65    /// τ' = 1/√(2√n) - local learning rate
66    pub fn mutate<R: Rng>(&mut self, n: usize, rng: &mut R) {
67        let tau = 1.0 / (2.0 * n as f64).sqrt();
68        let tau_prime = 1.0 / (2.0 * (n as f64).sqrt()).sqrt();
69        let n0: f64 = rng.sample(StandardNormal);
70
71        match self {
72            Self::Isotropic(sigma) => {
73                *sigma *= (tau_prime * n0).exp();
74                *sigma = sigma.max(Self::MIN_SIGMA);
75            }
76            Self::NonIsotropic(sigmas) => {
77                for sigma in sigmas.iter_mut() {
78                    let ni: f64 = rng.sample(StandardNormal);
79                    *sigma *= (tau_prime * n0 + tau * ni).exp();
80                    *sigma = sigma.max(Self::MIN_SIGMA);
81                }
82            }
83            Self::Correlated { sigmas, rotations } => {
84                // Update step sizes
85                for sigma in sigmas.iter_mut() {
86                    let ni: f64 = rng.sample(StandardNormal);
87                    *sigma *= (tau_prime * n0 + tau * ni).exp();
88                    *sigma = sigma.max(Self::MIN_SIGMA);
89                }
90                // Update rotation angles (≈5° per step)
91                let beta = 0.0873;
92                for alpha in rotations.iter_mut() {
93                    *alpha += beta * rng.sample::<f64, _>(StandardNormal);
94                }
95            }
96        }
97    }
98
99    /// Get step size for a specific gene (for non-isotropic and correlated)
100    pub fn get_sigma(&self, gene_idx: usize) -> f64 {
101        match self {
102            Self::Isotropic(sigma) => *sigma,
103            Self::NonIsotropic(sigmas) => sigmas.get(gene_idx).copied().unwrap_or(sigmas[0]),
104            Self::Correlated { sigmas, .. } => sigmas.get(gene_idx).copied().unwrap_or(sigmas[0]),
105        }
106    }
107
108    /// Get all step sizes
109    pub fn sigmas(&self) -> Vec<f64> {
110        match self {
111            Self::Isotropic(sigma) => vec![*sigma],
112            Self::NonIsotropic(sigmas) => sigmas.clone(),
113            Self::Correlated { sigmas, .. } => sigmas.clone(),
114        }
115    }
116}
117
118/// Genome wrapper with self-adaptive strategy parameters
119#[derive(Clone, Debug, Serialize, Deserialize)]
120pub struct AdaptiveGenome<G> {
121    /// The underlying genome
122    pub genome: G,
123    /// Strategy parameters
124    pub strategy: StrategyParams,
125}
126
127impl<G: EvolutionaryGenome> AdaptiveGenome<G> {
128    /// Create a new adaptive genome with isotropic strategy
129    pub fn new_isotropic(genome: G, initial_sigma: f64) -> Self {
130        Self {
131            genome,
132            strategy: StrategyParams::Isotropic(initial_sigma),
133        }
134    }
135
136    /// Create a new adaptive genome with non-isotropic strategy
137    pub fn new_non_isotropic(genome: G, initial_sigmas: Vec<f64>) -> Self {
138        Self {
139            genome,
140            strategy: StrategyParams::NonIsotropic(initial_sigmas),
141        }
142    }
143
144    /// Create a new adaptive genome with correlated strategy
145    pub fn new_correlated(genome: G, initial_sigmas: Vec<f64>) -> Self {
146        Self {
147            genome,
148            strategy: StrategyParams::correlated(initial_sigmas),
149        }
150    }
151
152    /// Get reference to the underlying genome
153    pub fn inner(&self) -> &G {
154        &self.genome
155    }
156
157    /// Get mutable reference to the underlying genome
158    pub fn inner_mut(&mut self) -> &mut G {
159        &mut self.genome
160    }
161
162    /// Consume and return the underlying genome
163    pub fn into_inner(self) -> G {
164        self.genome
165    }
166}
167
168/// Crossover for adaptive genomes
169///
170/// Performs intermediate recombination of strategy parameters.
171pub fn adaptive_crossover<G: Clone, R: Rng>(
172    parent1: &AdaptiveGenome<G>,
173    parent2: &AdaptiveGenome<G>,
174    child_genome: G,
175    rng: &mut R,
176) -> AdaptiveGenome<G> {
177    let strategy = match (&parent1.strategy, &parent2.strategy) {
178        (StrategyParams::Isotropic(s1), StrategyParams::Isotropic(s2)) => {
179            // Geometric mean of step sizes
180            StrategyParams::Isotropic((s1 * s2).sqrt())
181        }
182        (StrategyParams::NonIsotropic(s1), StrategyParams::NonIsotropic(s2)) => {
183            let sigmas: Vec<f64> = s1
184                .iter()
185                .zip(s2.iter())
186                .map(|(a, b)| (a * b).sqrt())
187                .collect();
188            StrategyParams::NonIsotropic(sigmas)
189        }
190        (
191            StrategyParams::Correlated {
192                sigmas: s1,
193                rotations: r1,
194            },
195            StrategyParams::Correlated {
196                sigmas: s2,
197                rotations: r2,
198            },
199        ) => {
200            let sigmas: Vec<f64> = s1
201                .iter()
202                .zip(s2.iter())
203                .map(|(a, b)| (a * b).sqrt())
204                .collect();
205            let rotations: Vec<f64> = r1
206                .iter()
207                .zip(r2.iter())
208                .map(|(a, b)| (a + b) / 2.0)
209                .collect();
210            StrategyParams::Correlated { sigmas, rotations }
211        }
212        // Mixed types: randomly pick one parent's strategy
213        (s1, s2) => {
214            if rng.gen_bool(0.5) {
215                s1.clone()
216            } else {
217                s2.clone()
218            }
219        }
220    };
221
222    AdaptiveGenome {
223        genome: child_genome,
224        strategy,
225    }
226}
227
228/// Learning rate parameters for self-adaptation
229#[derive(Clone, Debug)]
230pub struct LearningRates {
231    /// Global learning rate τ'
232    pub tau_prime: f64,
233    /// Local learning rate τ
234    pub tau: f64,
235    /// Rotation angle step β
236    pub beta: f64,
237}
238
239impl LearningRates {
240    /// Create default learning rates for dimension n
241    pub fn for_dimension(n: usize) -> Self {
242        Self {
243            tau_prime: 1.0 / (2.0 * (n as f64).sqrt()).sqrt(),
244            tau: 1.0 / (2.0 * n as f64).sqrt(),
245            beta: 0.0873, // ≈ 5 degrees
246        }
247    }
248
249    /// Create custom learning rates
250    pub fn custom(tau_prime: f64, tau: f64, beta: f64) -> Self {
251        Self {
252            tau_prime,
253            tau,
254            beta,
255        }
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use crate::genome::real_vector::RealVector;
263    use crate::genome::traits::RealValuedGenome;
264
265    #[test]
266    fn test_strategy_params_isotropic() {
267        let mut params = StrategyParams::isotropic(1.0);
268        assert_eq!(params.dimension(), 1);
269        assert!((params.get_sigma(0) - 1.0).abs() < 1e-10);
270
271        let mut rng = rand::thread_rng();
272        params.mutate(10, &mut rng);
273
274        // Sigma should remain positive after mutation
275        assert!(params.get_sigma(0) > 0.0);
276    }
277
278    #[test]
279    fn test_strategy_params_non_isotropic() {
280        let params = StrategyParams::non_isotropic(vec![0.1, 0.2, 0.3]);
281        assert_eq!(params.dimension(), 3);
282        assert!((params.get_sigma(0) - 0.1).abs() < 1e-10);
283        assert!((params.get_sigma(1) - 0.2).abs() < 1e-10);
284        assert!((params.get_sigma(2) - 0.3).abs() < 1e-10);
285    }
286
287    #[test]
288    fn test_strategy_params_correlated() {
289        let params = StrategyParams::correlated(vec![0.1, 0.2, 0.3]);
290        // 3 sigmas + 3 rotation angles = 6
291        assert_eq!(params.dimension(), 6);
292    }
293
294    #[test]
295    fn test_strategy_params_min_sigma() {
296        let mut params = StrategyParams::isotropic(1e-20);
297        let mut rng = rand::thread_rng();
298
299        // Even with tiny initial sigma, should not go below minimum
300        for _ in 0..100 {
301            params.mutate(10, &mut rng);
302        }
303
304        assert!(params.get_sigma(0) >= StrategyParams::MIN_SIGMA);
305    }
306
307    #[test]
308    fn test_adaptive_genome_creation() {
309        let genome = RealVector::new(vec![1.0, 2.0, 3.0]);
310        let adaptive = AdaptiveGenome::new_isotropic(genome.clone(), 0.5);
311
312        assert_eq!(adaptive.inner().genes(), genome.genes());
313        assert!((adaptive.strategy.get_sigma(0) - 0.5).abs() < 1e-10);
314    }
315
316    #[test]
317    fn test_adaptive_crossover() {
318        let mut rng = rand::thread_rng();
319
320        let g1 = RealVector::new(vec![1.0, 2.0]);
321        let g2 = RealVector::new(vec![3.0, 4.0]);
322        let child_genome = RealVector::new(vec![2.0, 3.0]);
323
324        let p1 = AdaptiveGenome::new_isotropic(g1, 0.1);
325        let p2 = AdaptiveGenome::new_isotropic(g2, 0.4);
326
327        let child = adaptive_crossover(&p1, &p2, child_genome, &mut rng);
328
329        // Geometric mean of 0.1 and 0.4 = 0.2
330        assert!((child.strategy.get_sigma(0) - 0.2).abs() < 1e-10);
331    }
332
333    #[test]
334    fn test_learning_rates() {
335        let rates = LearningRates::for_dimension(10);
336
337        // τ = 1/√(2*10) ≈ 0.2236
338        assert!((rates.tau - 0.2236).abs() < 0.01);
339
340        // τ' = 1/√(2*√10) ≈ 0.3976
341        assert!((rates.tau_prime - 0.3976).abs() < 0.01);
342    }
343}