Skip to main content

fugue_evo/hyperparameter/
adaptive.rs

1//! Adaptive control mechanisms
2//!
3//! These mechanisms adapt parameters based on feedback from the search process.
4
5use rand::distributions::WeightedIndex;
6use rand::prelude::Distribution;
7use rand::Rng;
8use std::collections::VecDeque;
9
10/// Rechenberg's 1/5 success rule for step-size adaptation
11///
12/// If the success rate is above 1/5, increase step size (more exploration)
13/// If the success rate is below 1/5, decrease step size (more exploitation)
14///
15/// Reference: Rechenberg, I. (1973). Evolutionsstrategie.
16#[derive(Clone, Debug)]
17pub struct OneFifthRule {
18    /// Factor to increase step size (typically 1.22 ≈ e^(1/5))
19    pub increase_factor: f64,
20    /// Factor to decrease step size (typically 0.82 ≈ e^(-1/5))
21    pub decrease_factor: f64,
22    /// Window size for computing success rate
23    pub window_size: usize,
24    /// Target success rate (default: 0.2)
25    pub target_success_rate: f64,
26    /// History of success/failure outcomes
27    success_history: VecDeque<bool>,
28}
29
30impl OneFifthRule {
31    /// Create a new 1/5 rule adapter with default parameters
32    pub fn new() -> Self {
33        Self {
34            increase_factor: 1.22,
35            decrease_factor: 0.82,
36            window_size: 10,
37            target_success_rate: 0.2,
38            success_history: VecDeque::with_capacity(10),
39        }
40    }
41
42    /// Set custom factors
43    pub fn with_factors(mut self, increase: f64, decrease: f64) -> Self {
44        self.increase_factor = increase;
45        self.decrease_factor = decrease;
46        self
47    }
48
49    /// Set window size
50    pub fn with_window_size(mut self, size: usize) -> Self {
51        self.window_size = size;
52        self.success_history = VecDeque::with_capacity(size);
53        self
54    }
55
56    /// Set target success rate
57    pub fn with_target_rate(mut self, rate: f64) -> Self {
58        self.target_success_rate = rate;
59        self
60    }
61
62    /// Record a mutation outcome
63    pub fn record(&mut self, success: bool) {
64        self.success_history.push_back(success);
65        if self.success_history.len() > self.window_size {
66            self.success_history.pop_front();
67        }
68    }
69
70    /// Get current success rate
71    pub fn success_rate(&self) -> Option<f64> {
72        if self.success_history.is_empty() {
73            return None;
74        }
75        let successes = self.success_history.iter().filter(|&&s| s).count();
76        Some(successes as f64 / self.success_history.len() as f64)
77    }
78
79    /// Adapt a step size based on current success rate
80    pub fn adapt(&self, sigma: f64) -> f64 {
81        if self.success_history.len() < self.window_size {
82            return sigma;
83        }
84
85        let success_rate = self.success_rate().unwrap_or(self.target_success_rate);
86
87        if success_rate > self.target_success_rate {
88            sigma * self.increase_factor
89        } else if success_rate < self.target_success_rate {
90            sigma * self.decrease_factor
91        } else {
92            sigma
93        }
94    }
95
96    /// Reset the history
97    pub fn reset(&mut self) {
98        self.success_history.clear();
99    }
100}
101
102impl Default for OneFifthRule {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108/// Adaptive operator selection using fitness-based credit assignment
109///
110/// Tracks performance of multiple operators and adjusts selection probabilities
111/// based on the fitness improvements they produce.
112#[derive(Clone, Debug)]
113pub struct AdaptiveOperatorSelection {
114    /// Number of operators
115    pub num_operators: usize,
116    /// Selection weights for each operator
117    pub weights: Vec<f64>,
118    /// Learning rate for weight updates
119    pub learning_rate: f64,
120    /// Minimum probability for any operator
121    pub min_probability: f64,
122    /// Decay factor for old rewards
123    pub decay: f64,
124}
125
126impl AdaptiveOperatorSelection {
127    /// Create a new adaptive operator selection with uniform initial weights
128    pub fn new(num_operators: usize) -> Self {
129        assert!(num_operators > 0, "Must have at least one operator");
130        Self {
131            num_operators,
132            weights: vec![1.0 / num_operators as f64; num_operators],
133            learning_rate: 0.1,
134            min_probability: 0.05,
135            decay: 0.99,
136        }
137    }
138
139    /// Set learning rate
140    pub fn with_learning_rate(mut self, rate: f64) -> Self {
141        self.learning_rate = rate;
142        self
143    }
144
145    /// Set minimum probability
146    pub fn with_min_probability(mut self, prob: f64) -> Self {
147        self.min_probability = prob;
148        self
149    }
150
151    /// Set decay factor
152    pub fn with_decay(mut self, decay: f64) -> Self {
153        self.decay = decay;
154        self
155    }
156
157    /// Select an operator index
158    pub fn select<R: Rng>(&self, rng: &mut R) -> usize {
159        let dist = WeightedIndex::new(&self.weights).unwrap();
160        dist.sample(rng)
161    }
162
163    /// Update weights based on fitness improvement from an operator
164    pub fn update(&mut self, operator_idx: usize, fitness_improvement: f64) {
165        assert!(operator_idx < self.num_operators);
166
167        // Apply decay to all weights
168        for w in &mut self.weights {
169            *w *= self.decay;
170        }
171
172        // Credit assignment based on fitness improvement
173        let reward = fitness_improvement.max(0.0);
174        self.weights[operator_idx] += self.learning_rate * reward;
175
176        // Normalize and enforce minimum probability
177        self.normalize_weights();
178    }
179
180    /// Normalize weights to sum to 1 while enforcing minimums
181    fn normalize_weights(&mut self) {
182        let sum: f64 = self.weights.iter().sum();
183        if sum <= 0.0 {
184            // Reset to uniform if weights collapsed
185            for w in &mut self.weights {
186                *w = 1.0 / self.num_operators as f64;
187            }
188            return;
189        }
190
191        // Normalize
192        for w in &mut self.weights {
193            *w /= sum;
194        }
195
196        // Enforce minimum probability
197        let n = self.num_operators as f64;
198        let mut deficit = 0.0;
199        let mut excess_count = 0;
200
201        for w in &mut self.weights {
202            if *w < self.min_probability / n {
203                deficit += self.min_probability / n - *w;
204                *w = self.min_probability / n;
205            } else {
206                excess_count += 1;
207            }
208        }
209
210        // Redistribute deficit from weights above minimum
211        if deficit > 0.0 && excess_count > 0 {
212            let reduction = deficit / excess_count as f64;
213            for w in &mut self.weights {
214                if *w > self.min_probability / n + reduction {
215                    *w -= reduction;
216                }
217            }
218        }
219
220        // Final normalization
221        let sum: f64 = self.weights.iter().sum();
222        for w in &mut self.weights {
223            *w /= sum;
224        }
225    }
226
227    /// Get current selection probabilities
228    pub fn probabilities(&self) -> &[f64] {
229        &self.weights
230    }
231
232    /// Reset weights to uniform
233    pub fn reset(&mut self) {
234        for w in &mut self.weights {
235            *w = 1.0 / self.num_operators as f64;
236        }
237    }
238}
239
240/// Sliding window statistics tracker
241#[derive(Clone, Debug)]
242pub struct SlidingWindowStats {
243    /// Window of values
244    values: VecDeque<f64>,
245    /// Maximum window size
246    window_size: usize,
247}
248
249impl SlidingWindowStats {
250    /// Create a new sliding window tracker
251    pub fn new(window_size: usize) -> Self {
252        Self {
253            values: VecDeque::with_capacity(window_size),
254            window_size,
255        }
256    }
257
258    /// Add a value to the window
259    pub fn push(&mut self, value: f64) {
260        self.values.push_back(value);
261        if self.values.len() > self.window_size {
262            self.values.pop_front();
263        }
264    }
265
266    /// Get the mean of values in the window
267    pub fn mean(&self) -> Option<f64> {
268        if self.values.is_empty() {
269            return None;
270        }
271        Some(self.values.iter().sum::<f64>() / self.values.len() as f64)
272    }
273
274    /// Get the variance of values in the window
275    pub fn variance(&self) -> Option<f64> {
276        if self.values.len() < 2 {
277            return None;
278        }
279        let mean = self.mean()?;
280        let sum_sq: f64 = self.values.iter().map(|v| (v - mean).powi(2)).sum();
281        Some(sum_sq / (self.values.len() - 1) as f64)
282    }
283
284    /// Get the standard deviation
285    pub fn std_dev(&self) -> Option<f64> {
286        self.variance().map(|v| v.sqrt())
287    }
288
289    /// Get the minimum value in the window
290    pub fn min(&self) -> Option<f64> {
291        self.values.iter().copied().reduce(f64::min)
292    }
293
294    /// Get the maximum value in the window
295    pub fn max(&self) -> Option<f64> {
296        self.values.iter().copied().reduce(f64::max)
297    }
298
299    /// Check if window is full
300    pub fn is_full(&self) -> bool {
301        self.values.len() >= self.window_size
302    }
303
304    /// Get number of values in window
305    pub fn len(&self) -> usize {
306        self.values.len()
307    }
308
309    /// Check if empty
310    pub fn is_empty(&self) -> bool {
311        self.values.is_empty()
312    }
313
314    /// Clear the window
315    pub fn clear(&mut self) {
316        self.values.clear();
317    }
318}
319
320/// Fitness-based adaptive mutation rate
321///
322/// Adapts mutation rate based on whether mutations are producing improvements.
323#[derive(Clone, Debug)]
324pub struct AdaptiveMutationRate {
325    /// Current mutation rate
326    pub rate: f64,
327    /// Minimum mutation rate
328    pub min_rate: f64,
329    /// Maximum mutation rate
330    pub max_rate: f64,
331    /// Increase factor when improvements are rare
332    pub increase_factor: f64,
333    /// Decrease factor when improvements are common
334    pub decrease_factor: f64,
335    /// Statistics tracker
336    stats: SlidingWindowStats,
337    /// Improvement threshold
338    improvement_threshold: f64,
339}
340
341impl AdaptiveMutationRate {
342    /// Create a new adaptive mutation rate
343    pub fn new(initial_rate: f64) -> Self {
344        Self {
345            rate: initial_rate,
346            min_rate: 0.001,
347            max_rate: 0.5,
348            increase_factor: 1.1,
349            decrease_factor: 0.9,
350            stats: SlidingWindowStats::new(20),
351            improvement_threshold: 0.3, // Target 30% improvement rate
352        }
353    }
354
355    /// Record a mutation outcome
356    pub fn record(&mut self, improved: bool) {
357        self.stats.push(if improved { 1.0 } else { 0.0 });
358    }
359
360    /// Adapt the mutation rate based on recent history
361    pub fn adapt(&mut self) {
362        if !self.stats.is_full() {
363            return;
364        }
365
366        let improvement_rate = self.stats.mean().unwrap_or(0.0);
367
368        if improvement_rate < self.improvement_threshold {
369            // Not enough improvements, increase mutation rate
370            self.rate = (self.rate * self.increase_factor).min(self.max_rate);
371        } else if improvement_rate > self.improvement_threshold * 1.5 {
372            // Too many improvements (might be too disruptive), decrease
373            self.rate = (self.rate * self.decrease_factor).max(self.min_rate);
374        }
375    }
376
377    /// Get current rate
378    pub fn current_rate(&self) -> f64 {
379        self.rate
380    }
381}
382
383/// Population diversity-based parameter adaptation
384#[derive(Clone, Debug)]
385pub struct DiversityBasedAdaptation {
386    /// Window for tracking diversity
387    diversity_history: SlidingWindowStats,
388    /// Target diversity level
389    pub target_diversity: f64,
390    /// Tolerance around target
391    pub tolerance: f64,
392}
393
394impl DiversityBasedAdaptation {
395    /// Create a new diversity-based adapter
396    pub fn new(target_diversity: f64) -> Self {
397        Self {
398            diversity_history: SlidingWindowStats::new(10),
399            target_diversity,
400            tolerance: 0.1,
401        }
402    }
403
404    /// Record current diversity
405    pub fn record_diversity(&mut self, diversity: f64) {
406        self.diversity_history.push(diversity);
407    }
408
409    /// Get recommended mutation rate multiplier
410    ///
411    /// Returns > 1.0 if diversity is too low, < 1.0 if too high
412    pub fn mutation_multiplier(&self) -> f64 {
413        let Some(current_diversity) = self.diversity_history.mean() else {
414            return 1.0;
415        };
416
417        if current_diversity < self.target_diversity * (1.0 - self.tolerance) {
418            // Diversity too low, increase mutation
419            1.5
420        } else if current_diversity > self.target_diversity * (1.0 + self.tolerance) {
421            // Diversity too high, decrease mutation
422            0.8
423        } else {
424            1.0
425        }
426    }
427
428    /// Get recommended selection pressure multiplier
429    ///
430    /// Returns > 1.0 if diversity is too high, < 1.0 if too low
431    pub fn selection_pressure_multiplier(&self) -> f64 {
432        let Some(current_diversity) = self.diversity_history.mean() else {
433            return 1.0;
434        };
435
436        if current_diversity < self.target_diversity * (1.0 - self.tolerance) {
437            // Diversity too low, reduce selection pressure
438            0.8
439        } else if current_diversity > self.target_diversity * (1.0 + self.tolerance) {
440            // Diversity too high, increase selection pressure
441            1.2
442        } else {
443            1.0
444        }
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn test_one_fifth_rule_increase() {
454        let mut rule = OneFifthRule::new().with_window_size(5);
455
456        // All successes -> increase
457        for _ in 0..5 {
458            rule.record(true);
459        }
460
461        let sigma = 1.0;
462        let new_sigma = rule.adapt(sigma);
463        assert!(new_sigma > sigma);
464    }
465
466    #[test]
467    fn test_one_fifth_rule_decrease() {
468        let mut rule = OneFifthRule::new().with_window_size(5);
469
470        // All failures -> decrease
471        for _ in 0..5 {
472            rule.record(false);
473        }
474
475        let sigma = 1.0;
476        let new_sigma = rule.adapt(sigma);
477        assert!(new_sigma < sigma);
478    }
479
480    #[test]
481    fn test_one_fifth_rule_at_target() {
482        let mut rule = OneFifthRule::new()
483            .with_window_size(5)
484            .with_target_rate(0.2);
485
486        // Exactly 1/5 success rate
487        rule.record(true);
488        for _ in 0..4 {
489            rule.record(false);
490        }
491
492        let sigma = 1.0;
493        let new_sigma = rule.adapt(sigma);
494        assert!((new_sigma - sigma).abs() < 1e-10);
495    }
496
497    #[test]
498    fn test_adaptive_operator_selection() {
499        let mut aos = AdaptiveOperatorSelection::new(3);
500        let mut rng = rand::thread_rng();
501
502        // Initially uniform
503        assert_eq!(aos.probabilities().len(), 3);
504        for &p in aos.probabilities() {
505            assert!((p - 1.0 / 3.0).abs() < 1e-10);
506        }
507
508        // Update with reward for operator 0
509        aos.update(0, 10.0);
510
511        // Operator 0 should have higher weight now
512        assert!(aos.probabilities()[0] > aos.probabilities()[1]);
513
514        // Selection should work
515        let _ = aos.select(&mut rng);
516    }
517
518    #[test]
519    fn test_sliding_window_stats() {
520        let mut stats = SlidingWindowStats::new(5);
521
522        assert!(stats.mean().is_none());
523
524        stats.push(1.0);
525        stats.push(2.0);
526        stats.push(3.0);
527
528        assert!((stats.mean().unwrap() - 2.0).abs() < 1e-10);
529        assert!((stats.min().unwrap() - 1.0).abs() < 1e-10);
530        assert!((stats.max().unwrap() - 3.0).abs() < 1e-10);
531
532        // Fill window
533        stats.push(4.0);
534        stats.push(5.0);
535        assert!(stats.is_full());
536
537        // Add more, should drop oldest
538        stats.push(6.0);
539        assert_eq!(stats.len(), 5);
540        assert!((stats.min().unwrap() - 2.0).abs() < 1e-10);
541    }
542
543    #[test]
544    fn test_adaptive_mutation_rate() {
545        let mut amr = AdaptiveMutationRate::new(0.1);
546
547        // Record no improvements
548        for _ in 0..25 {
549            amr.record(false);
550        }
551        amr.adapt();
552
553        // Rate should increase
554        assert!(amr.current_rate() > 0.1);
555    }
556
557    #[test]
558    fn test_diversity_based_adaptation() {
559        let mut dba = DiversityBasedAdaptation::new(0.5);
560
561        // Record low diversity
562        for _ in 0..10 {
563            dba.record_diversity(0.2);
564        }
565
566        // Should recommend higher mutation
567        assert!(dba.mutation_multiplier() > 1.0);
568        // Should recommend lower selection pressure
569        assert!(dba.selection_pressure_multiplier() < 1.0);
570    }
571}