Skip to main content

fugue_evo/diagnostics/
mod.rs

1//! Diagnostics and statistics
2//!
3//! This module provides statistics collection and analysis for evolutionary runs.
4
5pub mod convergence;
6
7use std::time::Duration;
8
9use serde::{Deserialize, Serialize};
10
11use crate::fitness::traits::FitnessValue;
12use crate::genome::traits::EvolutionaryGenome;
13use crate::population::population::Population;
14
15/// Statistics for a single generation
16#[derive(Clone, Debug, Serialize, Deserialize)]
17pub struct GenerationStats {
18    /// Generation number
19    pub generation: usize,
20    /// Total fitness evaluations so far
21    pub evaluations: usize,
22    /// Best fitness in this generation
23    pub best_fitness: f64,
24    /// Worst fitness in this generation
25    pub worst_fitness: f64,
26    /// Mean fitness
27    pub mean_fitness: f64,
28    /// Median fitness
29    pub median_fitness: f64,
30    /// Fitness standard deviation
31    pub fitness_std: f64,
32    /// Population diversity
33    pub diversity: f64,
34    /// Timing information
35    pub timing: TimingStats,
36}
37
38/// Timing statistics
39#[derive(Clone, Debug, Default, Serialize, Deserialize)]
40pub struct TimingStats {
41    /// Time spent on fitness evaluation (ms)
42    pub evaluation_ms: f64,
43    /// Time spent on selection (ms)
44    pub selection_ms: f64,
45    /// Time spent on crossover (ms)
46    pub crossover_ms: f64,
47    /// Time spent on mutation (ms)
48    pub mutation_ms: f64,
49    /// Total generation time (ms)
50    pub total_ms: f64,
51}
52
53impl TimingStats {
54    /// Create new timing stats
55    pub fn new() -> Self {
56        Self::default()
57    }
58
59    /// Set evaluation time
60    pub fn with_evaluation(mut self, duration: Duration) -> Self {
61        self.evaluation_ms = duration.as_secs_f64() * 1000.0;
62        self
63    }
64
65    /// Set selection time
66    pub fn with_selection(mut self, duration: Duration) -> Self {
67        self.selection_ms = duration.as_secs_f64() * 1000.0;
68        self
69    }
70
71    /// Set crossover time
72    pub fn with_crossover(mut self, duration: Duration) -> Self {
73        self.crossover_ms = duration.as_secs_f64() * 1000.0;
74        self
75    }
76
77    /// Set mutation time
78    pub fn with_mutation(mut self, duration: Duration) -> Self {
79        self.mutation_ms = duration.as_secs_f64() * 1000.0;
80        self
81    }
82
83    /// Set total time
84    pub fn with_total(mut self, duration: Duration) -> Self {
85        self.total_ms = duration.as_secs_f64() * 1000.0;
86        self
87    }
88}
89
90impl GenerationStats {
91    /// Compute statistics from a population
92    pub fn from_population<G, F>(
93        population: &Population<G, F>,
94        generation: usize,
95        evaluations: usize,
96    ) -> Self
97    where
98        G: EvolutionaryGenome,
99        F: FitnessValue,
100    {
101        let mut fitnesses: Vec<f64> = population
102            .iter()
103            .filter_map(|i| i.fitness.as_ref().map(|f| f.to_f64()))
104            .collect();
105
106        if fitnesses.is_empty() {
107            return Self {
108                generation,
109                evaluations,
110                best_fitness: f64::NEG_INFINITY,
111                worst_fitness: f64::INFINITY,
112                mean_fitness: 0.0,
113                median_fitness: 0.0,
114                fitness_std: 0.0,
115                diversity: 0.0,
116                timing: TimingStats::default(),
117            };
118        }
119
120        fitnesses.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
121
122        let best = fitnesses.last().copied().unwrap_or(f64::NEG_INFINITY);
123        let worst = fitnesses.first().copied().unwrap_or(f64::INFINITY);
124        let mean = fitnesses.iter().sum::<f64>() / fitnesses.len() as f64;
125        let median = if fitnesses.len().is_multiple_of(2) {
126            (fitnesses[fitnesses.len() / 2 - 1] + fitnesses[fitnesses.len() / 2]) / 2.0
127        } else {
128            fitnesses[fitnesses.len() / 2]
129        };
130
131        let variance = if fitnesses.len() > 1 {
132            fitnesses.iter().map(|f| (f - mean).powi(2)).sum::<f64>() / (fitnesses.len() - 1) as f64
133        } else {
134            0.0
135        };
136        let std = variance.sqrt();
137
138        let diversity = population.diversity();
139
140        Self {
141            generation,
142            evaluations,
143            best_fitness: best,
144            worst_fitness: worst,
145            mean_fitness: mean,
146            median_fitness: median,
147            fitness_std: std,
148            diversity,
149            timing: TimingStats::default(),
150        }
151    }
152
153    /// Set timing information
154    pub fn with_timing(mut self, timing: TimingStats) -> Self {
155        self.timing = timing;
156        self
157    }
158}
159
160/// Statistics collector for an entire evolution run
161#[derive(Clone, Debug, Default, Serialize, Deserialize)]
162pub struct EvolutionStats {
163    /// Statistics per generation
164    pub generations: Vec<GenerationStats>,
165    /// Total runtime in milliseconds
166    pub total_runtime_ms: f64,
167    /// Reason for termination
168    pub termination_reason: Option<String>,
169}
170
171impl EvolutionStats {
172    /// Create a new stats collector
173    pub fn new() -> Self {
174        Self::default()
175    }
176
177    /// Record a generation's statistics
178    pub fn record(&mut self, stats: GenerationStats) {
179        self.generations.push(stats);
180    }
181
182    /// Get the number of generations recorded
183    pub fn num_generations(&self) -> usize {
184        self.generations.len()
185    }
186
187    /// Get the best fitness across all generations
188    pub fn best_fitness(&self) -> Option<f64> {
189        self.generations
190            .iter()
191            .map(|g| g.best_fitness)
192            .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
193    }
194
195    /// Get the final best fitness
196    pub fn final_best_fitness(&self) -> Option<f64> {
197        self.generations.last().map(|g| g.best_fitness)
198    }
199
200    /// Get the history of best fitness values
201    pub fn best_fitness_history(&self) -> Vec<f64> {
202        self.generations.iter().map(|g| g.best_fitness).collect()
203    }
204
205    /// Get the history of mean fitness values
206    pub fn mean_fitness_history(&self) -> Vec<f64> {
207        self.generations.iter().map(|g| g.mean_fitness).collect()
208    }
209
210    /// Get the history of diversity values
211    pub fn diversity_history(&self) -> Vec<f64> {
212        self.generations.iter().map(|g| g.diversity).collect()
213    }
214
215    /// Set the termination reason
216    pub fn set_termination_reason(&mut self, reason: &str) {
217        self.termination_reason = Some(reason.to_string());
218    }
219
220    /// Set the total runtime
221    pub fn set_runtime(&mut self, duration: Duration) {
222        self.total_runtime_ms = duration.as_secs_f64() * 1000.0;
223    }
224
225    /// Get a summary of the evolution run
226    pub fn summary(&self) -> String {
227        let best = self.best_fitness().unwrap_or(f64::NEG_INFINITY);
228        let final_best = self.final_best_fitness().unwrap_or(f64::NEG_INFINITY);
229        let generations = self.num_generations();
230        let runtime = self.total_runtime_ms;
231
232        format!(
233            "Evolution Summary:\n\
234             - Generations: {}\n\
235             - Best fitness: {:.6}\n\
236             - Final best: {:.6}\n\
237             - Runtime: {:.2}ms\n\
238             - Termination: {}",
239            generations,
240            best,
241            final_best,
242            runtime,
243            self.termination_reason.as_deref().unwrap_or("unknown")
244        )
245    }
246}
247
248/// Result of an evolution run
249#[derive(Clone, Debug)]
250pub struct EvolutionResult<G, F = f64>
251where
252    G: EvolutionaryGenome,
253    F: FitnessValue,
254{
255    /// The best genome found
256    pub best_genome: G,
257    /// The best fitness value
258    pub best_fitness: F,
259    /// Number of generations completed
260    pub generations: usize,
261    /// Total fitness evaluations
262    pub evaluations: usize,
263    /// Statistics for the run
264    pub stats: EvolutionStats,
265}
266
267impl<G, F> EvolutionResult<G, F>
268where
269    G: EvolutionaryGenome,
270    F: FitnessValue,
271{
272    /// Create a new evolution result
273    pub fn new(best_genome: G, best_fitness: F, generations: usize, evaluations: usize) -> Self {
274        Self {
275            best_genome,
276            best_fitness,
277            generations,
278            evaluations,
279            stats: EvolutionStats::new(),
280        }
281    }
282
283    /// Add statistics to the result
284    pub fn with_stats(mut self, stats: EvolutionStats) -> Self {
285        self.stats = stats;
286        self
287    }
288}
289
290pub mod prelude {
291    pub use super::convergence::{
292        detect_stagnation, evolutionary_ess, evolutionary_ess_log, evolutionary_rhat,
293        fitness_convergence, ConvergenceConfig, ConvergenceDetector, ConvergenceReason,
294        ConvergenceStatus,
295    };
296    pub use super::{EvolutionResult, EvolutionStats, GenerationStats, TimingStats};
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use crate::genome::real_vector::RealVector;
303    use crate::population::individual::Individual;
304
305    fn create_test_population() -> Population<RealVector> {
306        let individuals = vec![
307            Individual::with_fitness(RealVector::new(vec![1.0]), 10.0),
308            Individual::with_fitness(RealVector::new(vec![2.0]), 20.0),
309            Individual::with_fitness(RealVector::new(vec![3.0]), 30.0),
310            Individual::with_fitness(RealVector::new(vec![4.0]), 40.0),
311            Individual::with_fitness(RealVector::new(vec![5.0]), 50.0),
312        ];
313        Population::from_individuals(individuals)
314    }
315
316    #[test]
317    fn test_generation_stats_from_population() {
318        let pop = create_test_population();
319        let stats = GenerationStats::from_population(&pop, 10, 100);
320
321        assert_eq!(stats.generation, 10);
322        assert_eq!(stats.evaluations, 100);
323        assert_eq!(stats.best_fitness, 50.0);
324        assert_eq!(stats.worst_fitness, 10.0);
325        assert_eq!(stats.mean_fitness, 30.0);
326        assert_eq!(stats.median_fitness, 30.0);
327        assert!(stats.fitness_std > 15.0 && stats.fitness_std < 16.0);
328    }
329
330    #[test]
331    fn test_generation_stats_empty_population() {
332        let pop: Population<RealVector> = Population::new();
333        let stats = GenerationStats::from_population(&pop, 0, 0);
334
335        assert_eq!(stats.best_fitness, f64::NEG_INFINITY);
336        assert_eq!(stats.worst_fitness, f64::INFINITY);
337    }
338
339    #[test]
340    fn test_evolution_stats_record() {
341        let mut stats = EvolutionStats::new();
342        let pop = create_test_population();
343
344        for i in 0..5 {
345            let gen_stats = GenerationStats::from_population(&pop, i, i * 10);
346            stats.record(gen_stats);
347        }
348
349        assert_eq!(stats.num_generations(), 5);
350        assert_eq!(stats.best_fitness(), Some(50.0));
351    }
352
353    #[test]
354    fn test_evolution_stats_history() {
355        let mut stats = EvolutionStats::new();
356
357        for i in 0..5 {
358            stats.record(GenerationStats {
359                generation: i,
360                evaluations: i * 10,
361                best_fitness: (i + 1) as f64 * 10.0,
362                worst_fitness: 0.0,
363                mean_fitness: (i + 1) as f64 * 5.0,
364                median_fitness: 0.0,
365                fitness_std: 0.0,
366                diversity: 1.0 / (i + 1) as f64,
367                timing: TimingStats::default(),
368            });
369        }
370
371        let best_history = stats.best_fitness_history();
372        assert_eq!(best_history, vec![10.0, 20.0, 30.0, 40.0, 50.0]);
373
374        let mean_history = stats.mean_fitness_history();
375        assert_eq!(mean_history, vec![5.0, 10.0, 15.0, 20.0, 25.0]);
376    }
377
378    #[test]
379    fn test_evolution_stats_summary() {
380        let mut stats = EvolutionStats::new();
381        stats.record(GenerationStats {
382            generation: 0,
383            evaluations: 100,
384            best_fitness: 50.0,
385            worst_fitness: 10.0,
386            mean_fitness: 30.0,
387            median_fitness: 30.0,
388            fitness_std: 15.0,
389            diversity: 0.5,
390            timing: TimingStats::default(),
391        });
392        stats.set_termination_reason("Target reached");
393        stats.set_runtime(Duration::from_millis(1234));
394
395        let summary = stats.summary();
396        assert!(summary.contains("Generations: 1"));
397        assert!(summary.contains("Best fitness: 50"));
398        assert!(summary.contains("Target reached"));
399    }
400
401    #[test]
402    fn test_timing_stats() {
403        let timing = TimingStats::new()
404            .with_evaluation(Duration::from_millis(100))
405            .with_selection(Duration::from_millis(20))
406            .with_crossover(Duration::from_millis(30))
407            .with_mutation(Duration::from_millis(10))
408            .with_total(Duration::from_millis(160));
409
410        assert!((timing.evaluation_ms - 100.0).abs() < 0.1);
411        assert!((timing.selection_ms - 20.0).abs() < 0.1);
412        assert!((timing.crossover_ms - 30.0).abs() < 0.1);
413        assert!((timing.mutation_ms - 10.0).abs() < 0.1);
414        assert!((timing.total_ms - 160.0).abs() < 0.1);
415    }
416
417    #[test]
418    fn test_evolution_result() {
419        let genome = RealVector::new(vec![1.0, 2.0, 3.0]);
420        let result = EvolutionResult::new(genome, 42.0, 100, 1000);
421
422        assert_eq!(result.best_fitness, 42.0);
423        assert_eq!(result.generations, 100);
424        assert_eq!(result.evaluations, 1000);
425    }
426}