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