Skip to main content

rlevo_evolution/algorithms/eda/
compact_genetic.rs

1//! Compact Genetic Algorithm (cGA) model for binary search spaces.
2//!
3//! cGA represents a virtual binary population as a per-gene probability vector
4//! and emulates a steady-state GA of size `virtual_pop_size` without storing
5//! the population explicitly. [`fit`] competes the **winner** (best fitness)
6//! against the **loser** (worst fitness) of the truncation-selected subset: on
7//! every gene where they disagree the probability is nudged by
8//! `±1 / virtual_pop_size` toward the winner's bit. [`sample`] emits raw
9//! `{0, 1}` `f32` genes; [`EdaParams::bounds`](crate::algorithms::eda::EdaParams::bounds) clamps are therefore no-ops.
10//!
11//! # Deviation from classic cGA
12//!
13//! The textbook cGA (Harik et al., 1999) draws *two* individuals uniformly at
14//! random from the whole population and competes them. Here the winner and
15//! loser are the best and worst of the **truncation-selected subset** handed to
16//! [`fit`](ProbabilityModel::fit) by
17//! [`EdaStrategy`](crate::algorithms::eda::EdaStrategy), so the update is
18//! biased by the selection pressure already applied upstream.
19//!
20//! # References
21//!
22//! - Harik, Lobo & Goldberg (1999), *The compact genetic algorithm*.
23//!
24//! [`fit`]: crate::ProbabilityModel::fit
25//! [`sample`]: crate::ProbabilityModel::sample
26
27use burn::tensor::{Tensor, TensorData, backend::Backend};
28use rand::{Rng, RngExt};
29use rlevo_core::config::{self, ConfigError};
30
31use crate::probability_model::ProbabilityModel;
32
33/// Per-run configuration for the [`CompactGenetic`] model.
34///
35/// Held inside [`EdaParams::model`](crate::algorithms::eda::EdaParams::model)
36/// for the lifetime of a run. Use [`CompactGeneticParams::default_for`] for
37/// typical cGA defaults.
38#[derive(Debug, Clone)]
39pub struct CompactGeneticParams {
40    /// Number of bits per genome; determines the length of
41    /// [`CompactGeneticState::prob`].
42    pub genome_dim: usize,
43    /// Size of the virtual population being emulated; the per-generation
44    /// probability step is `1 / virtual_pop_size`. Larger values slow
45    /// convergence and preserve diversity; the original paper uses values in
46    /// the range `[50, 200]`.
47    pub virtual_pop_size: usize,
48}
49
50impl CompactGeneticParams {
51    /// Sensible cGA defaults for a `genome_dim`-bit problem.
52    #[must_use]
53    pub fn default_for(genome_dim: usize) -> Self {
54        Self {
55            genome_dim,
56            virtual_pop_size: 50,
57        }
58    }
59}
60
61/// Fitted state for the [`CompactGenetic`] model after one call to
62/// [`ProbabilityModel::fit`].
63///
64/// The vector has length `genome_dim`. On the prior path (`prev = None`) it
65/// is uniformly `0.5`; on subsequent calls entries are nudged by
66/// `±1 / virtual_pop_size` and clamped to `[0, 1]`.
67///
68/// The field is private so an out-of-range probability is unrepresentable
69/// from outside this module; build one with
70/// [`try_new`](CompactGeneticState::try_new) and read it via
71/// [`prob`](CompactGeneticState::prob).
72#[derive(Debug, Clone)]
73pub struct CompactGeneticState {
74    /// Per-gene probability of sampling a `1.0` (always in `[0, 1]`).
75    prob: Vec<f32>,
76}
77
78impl CompactGeneticState {
79    /// Builds a cGA state from a per-gene probability vector.
80    ///
81    /// # Errors
82    ///
83    /// Returns a [`ConfigError`] if `prob` is empty or if any entry is outside
84    /// the closed interval `[0, 1]` (or is non-finite).
85    pub fn try_new(prob: Vec<f32>) -> Result<Self, ConfigError> {
86        config::nonzero("CompactGeneticState", "prob", prob.len())?;
87        for &p in &prob {
88            config::in_range("CompactGeneticState", "prob", 0.0, 1.0, f64::from(p))?;
89        }
90        Ok(Self { prob })
91    }
92
93    /// Per-gene probabilities of sampling a `1.0`, each in `[0, 1]`.
94    #[must_use]
95    pub fn prob(&self) -> &[f32] {
96        &self.prob
97    }
98}
99
100/// Compact Genetic Algorithm for binary spaces (cGA).
101///
102/// Implements [`ProbabilityModel`] with a per-gene probability vector updated
103/// by winner/loser competition from the truncation-selected subset. Samples
104/// are raw `{0, 1}` `f32` values; [`EdaParams::bounds`](crate::algorithms::eda::EdaParams::bounds)
105/// clamps are no-ops for this model.
106///
107/// See the [module docs](self) for the update rule, the deviation from classic
108/// cGA, and the reference.
109#[derive(Debug, Clone, Copy, Default)]
110pub struct CompactGenetic;
111
112impl<B: Backend> ProbabilityModel<B> for CompactGenetic {
113    type Params = CompactGeneticParams;
114    type State = CompactGeneticState;
115
116    /// Update the per-gene probability vector by winner/loser competition.
117    ///
118    /// When `prev = None` returns the uniform-`0.5` prior; `population` and
119    /// `fitness` are ignored on that path. Otherwise finds the argmax (winner)
120    /// and argmin (loser) of the fitness vector (canonical maximise: higher is
121    /// better), then nudges each gene where winner and loser disagree by
122    /// `±1 / virtual_pop_size` (toward the winner), clamped to `[0, 1]`.
123    ///
124    /// # Panics
125    ///
126    /// Panics if the `population` or `fitness` tensor cannot be read back as
127    /// `f32` (`.expect("population tensor must be readable as f32")` /
128    /// `.expect("fitness tensor must be readable as f32")`). Also panics with an
129    /// out-of-bounds index if the population column count `d` exceeds
130    /// `prev.prob.len()` (the per-gene probability vector carried in `prev`).
131    fn fit(
132        &self,
133        params: &Self::Params,
134        prev: Option<&Self::State>,
135        population: Tensor<B, 2>,
136        fitness: Tensor<B, 1>,
137        device: &<B as burn::tensor::backend::BackendTypes>::Device,
138    ) -> Self::State {
139        let _ = device;
140        let Some(prev) = prev else {
141            // Prior path: uniform 0.5 per gene; population/fitness ignored.
142            let _ = (population, fitness);
143            return CompactGeneticState {
144                prob: vec![0.5; params.genome_dim],
145            };
146        };
147
148        let [k, d] = population.dims();
149        let rows = population
150            .into_data()
151            .into_vec::<f32>()
152            .expect("population tensor must be readable as f32");
153        let fit_host = fitness
154            .into_data()
155            .into_vec::<f32>()
156            .expect("fitness tensor must be readable as f32");
157
158        // Winner = argmax (best fitness), loser = argmin (worst); ties →
159        // lowest index. Canonical maximise: higher is better.
160        let mut winner_idx = 0_usize;
161        let mut loser_idx = 0_usize;
162        let mut best_f = f32::NEG_INFINITY;
163        let mut worst_f = f32::INFINITY;
164        for i in 0..k {
165            // Sanitize `NaN → −inf` at the seam. `EdaStrategy::tell` already does
166            // this upstream, but `fit` is a public trait method reachable
167            // directly; without this a `NaN` would sort as the largest value
168            // under `total_cmp` and be selected as the winner, nudging the model
169            // toward a meaningless genome.
170            let f = crate::fitness::sanitize_fitness(
171                fit_host.get(i).copied().unwrap_or(f32::NEG_INFINITY),
172            );
173            if f.total_cmp(&best_f) == std::cmp::Ordering::Greater {
174                best_f = f;
175                winner_idx = i;
176            }
177            if f.total_cmp(&worst_f) == std::cmp::Ordering::Less {
178                worst_f = f;
179                loser_idx = i;
180            }
181        }
182
183        // virtual_pop_size is a small tunable count, far below f32's 2^24
184        // exact-integer limit; the cast is lossless in practice.
185        #[allow(clippy::cast_precision_loss)]
186        let step = 1.0 / params.virtual_pop_size as f32;
187        let mut prob = prev.prob.clone();
188        for j in 0..d {
189            let winner = rows[winner_idx * d + j];
190            let loser = rows[loser_idx * d + j];
191            // Only nudge genes where winner and loser disagree.
192            if (winner - loser).abs() > 0.5 {
193                if winner > 0.5 {
194                    prob[j] += step;
195                } else {
196                    prob[j] -= step;
197                }
198                prob[j] = prob[j].clamp(0.0, 1.0);
199            }
200        }
201
202        CompactGeneticState { prob }
203    }
204
205    /// Draw `n` binary genomes from the per-gene Bernoulli probabilities.
206    ///
207    /// Each gene is sampled independently as `1.0` with probability `prob[j]`,
208    /// `0.0` otherwise, using the supplied host RNG (never `Tensor::random` /
209    /// `B::seed`). The returned tensor has shape `(n, D)` and contains only
210    /// `0.0` and `1.0` values.
211    ///
212    /// # Panics
213    ///
214    /// Panics if the `n * d` element count overflows allocation or `TensorData`
215    /// capacity (`Vec::with_capacity(n * d)` / the `(n, D)` `TensorData`).
216    fn sample(
217        &self,
218        state: &Self::State,
219        n: usize,
220        rng: &mut dyn Rng,
221        device: &<B as burn::tensor::backend::BackendTypes>::Device,
222    ) -> Tensor<B, 2> {
223        let d = state.prob.len();
224        let mut rows = Vec::with_capacity(n * d);
225        // Row-major: outer individuals, inner dimensions.
226        for _ in 0..n {
227            for &p in &state.prob {
228                let gene = if rng.random::<f32>() < p { 1.0 } else { 0.0 };
229                rows.push(gene);
230            }
231        }
232        Tensor::<B, 2>::from_data(TensorData::new(rows, [n, d]), device)
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use burn::backend::Flex;
240    use rand::SeedableRng;
241    use rand::rngs::StdRng;
242
243    type TestBackend = Flex;
244
245    fn pop(rows: Vec<f32>, n: usize, d: usize) -> Tensor<TestBackend, 2> {
246        let device = Default::default();
247        Tensor::<TestBackend, 2>::from_data(TensorData::new(rows, [n, d]), &device)
248    }
249
250    fn fitness(values: Vec<f32>) -> Tensor<TestBackend, 1> {
251        let device = Default::default();
252        let n = values.len();
253        Tensor::<TestBackend, 1>::from_data(TensorData::new(values, [n]), &device)
254    }
255
256    fn fit_prior(p: &CompactGeneticParams) -> CompactGeneticState {
257        let device = Default::default();
258        <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
259            &CompactGenetic,
260            p,
261            None,
262            pop(vec![], 0, 0),
263            fitness(vec![]),
264            &device,
265        )
266    }
267
268    #[test]
269    fn prior_is_half() {
270        let p = CompactGeneticParams::default_for(3);
271        assert_eq!(fit_prior(&p).prob, vec![0.5, 0.5, 0.5]);
272    }
273
274    #[test]
275    fn try_new_accepts_valid_and_rejects_out_of_range() {
276        let state = CompactGeneticState::try_new(vec![0.0, 0.5, 1.0]).unwrap();
277        assert_eq!(state.prob(), &[0.0, 0.5, 1.0]);
278        assert!(CompactGeneticState::try_new(vec![]).is_err());
279        assert!(CompactGeneticState::try_new(vec![0.5, 1.5]).is_err());
280        assert!(CompactGeneticState::try_new(vec![-0.1]).is_err());
281        assert!(CompactGeneticState::try_new(vec![f32::NAN]).is_err());
282    }
283
284    #[test]
285    fn nudge_is_exactly_one_over_vps() {
286        let device = Default::default();
287        let p = CompactGeneticParams {
288            genome_dim: 2,
289            virtual_pop_size: 10,
290        };
291        let prior = fit_prior(&p);
292        // winner = row 0 (fitness 0): genes [1, 0]. loser = row 1: genes [0, 1].
293        // Both genes differ: gene 0 winner=1 → +0.1; gene 1 winner=0 → -0.1.
294        let state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
295            &CompactGenetic,
296            &p,
297            Some(&prior),
298            pop(vec![1.0, 0.0, 0.0, 1.0], 2, 2),
299            fitness(vec![1.0, 0.0]),
300            &device,
301        );
302        approx::assert_relative_eq!(state.prob[0], 0.6, epsilon = 1e-6);
303        approx::assert_relative_eq!(state.prob[1], 0.4, epsilon = 1e-6);
304    }
305
306    #[test]
307    fn clamp_at_zero_and_one() {
308        let device = Default::default();
309        let p = CompactGeneticParams {
310            genome_dim: 2,
311            virtual_pop_size: 2,
312        };
313        let mut state = CompactGeneticState {
314            prob: vec![0.9, 0.1],
315        };
316        // gene 0: winner=1 → +0.5 → 1.4 clamped to 1.0.
317        // gene 1: winner=0 → -0.5 → -0.4 clamped to 0.0.
318        for _ in 0..3 {
319            state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
320                &CompactGenetic,
321                &p,
322                Some(&state),
323                pop(vec![1.0, 0.0, 0.0, 1.0], 2, 2),
324                fitness(vec![1.0, 0.0]),
325                &device,
326            );
327        }
328        approx::assert_relative_eq!(state.prob[0], 1.0, epsilon = 1e-6);
329        approx::assert_relative_eq!(state.prob[1], 0.0, epsilon = 1e-6);
330    }
331
332    #[test]
333    fn genes_where_winner_equals_loser_untouched() {
334        let device = Default::default();
335        let p = CompactGeneticParams {
336            genome_dim: 2,
337            virtual_pop_size: 10,
338        };
339        let prior = fit_prior(&p);
340        // gene 0: winner=1, loser=1 (same) → untouched at 0.5.
341        // gene 1: winner=1, loser=0 (differ) → +0.1 → 0.6.
342        let state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
343            &CompactGenetic,
344            &p,
345            Some(&prior),
346            pop(vec![1.0, 1.0, 1.0, 0.0], 2, 2),
347            fitness(vec![1.0, 0.0]),
348            &device,
349        );
350        approx::assert_relative_eq!(state.prob[0], 0.5, epsilon = 1e-6);
351        approx::assert_relative_eq!(state.prob[1], 0.6, epsilon = 1e-6);
352    }
353
354    #[test]
355    fn not_the_column_mean() {
356        let device = Default::default();
357        let p = CompactGeneticParams {
358            genome_dim: 1,
359            virtual_pop_size: 10,
360        };
361        let prior = fit_prior(&p);
362        // Column mean of [1, 0, 0] is 1/3 ≈ 0.333. cGA instead nudges 0.5 by
363        // +0.1 to 0.6 (winner=row 0 with gene 1, loser=row 2 with gene 0).
364        // Canonical maximise: row 0 has the highest fitness (winner), row 2 the
365        // lowest (loser).
366        let state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
367            &CompactGenetic,
368            &p,
369            Some(&prior),
370            pop(vec![1.0, 0.0, 0.0], 3, 1),
371            fitness(vec![2.0, 1.0, 0.0]),
372            &device,
373        );
374        approx::assert_relative_eq!(state.prob[0], 0.6, epsilon = 1e-6);
375        assert!((state.prob[0] - 1.0 / 3.0).abs() > 0.2);
376    }
377
378    #[test]
379    fn samples_are_binary() {
380        let device = Default::default();
381        let state = CompactGeneticState {
382            prob: vec![0.2, 0.8],
383        };
384        let mut rng = StdRng::seed_from_u64(11);
385        let samples = <CompactGenetic as ProbabilityModel<TestBackend>>::sample(
386            &CompactGenetic,
387            &state,
388            300,
389            &mut rng,
390            &device,
391        );
392        for v in samples
393            .into_data()
394            .into_vec::<f32>()
395            .expect("samples host-read of a tensor this test just built")
396        {
397            // Exact float compare is correct: sample() writes literal 0.0/1.0.
398            #[allow(clippy::float_cmp)]
399            let is_binary = v == 0.0 || v == 1.0;
400            assert!(is_binary);
401        }
402    }
403
404    #[test]
405    fn nan_fitness_not_selected_as_winner() {
406        // Row 0 all-ones with NaN fitness, row 1 all-zeros with finite fitness.
407        // Under total_cmp, an unsanitized NaN would sort as the largest value
408        // and be picked as the winner; with the seam guard (#129) the finite
409        // row is the winner and probabilities move toward 0.
410        let device = Default::default();
411        let p = CompactGeneticParams::default_for(2);
412        let prior = fit_prior(&p);
413        let state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
414            &CompactGenetic,
415            &p,
416            Some(&prior),
417            pop(vec![1.0, 1.0, 0.0, 0.0], 2, 2),
418            fitness(vec![f32::NAN, 5.0]),
419            &device,
420        );
421        for &pj in &state.prob {
422            assert!(
423                pj.is_finite() && (0.0..=1.0).contains(&pj),
424                "prob out of range: {pj}"
425            );
426            assert!(
427                pj < 0.5,
428                "winner should be the finite-fitness zero row, got {pj}"
429            );
430        }
431    }
432
433    #[test]
434    fn sample_respects_probabilities() {
435        // §7.2: the empirical per-bit frequency of a large sample must match the
436        // Bernoulli probabilities used to draw it.
437        let device = Default::default();
438        let prob: Vec<f32> = vec![0.1, 0.5, 0.9];
439        let d = prob.len();
440        let state = CompactGeneticState { prob: prob.clone() };
441        let mut rng = StdRng::seed_from_u64(42);
442        let n = 20_000_usize;
443        let samples = <CompactGenetic as ProbabilityModel<TestBackend>>::sample(
444            &CompactGenetic,
445            &state,
446            n,
447            &mut rng,
448            &device,
449        );
450        let data = samples
451            .into_data()
452            .into_vec::<f32>()
453            .expect("samples host-read of a tensor this test just built");
454        // n = 20_000 is far below f32's 2^24 exact-integer limit; the cast is
455        // lossless.
456        #[allow(clippy::cast_precision_loss)]
457        let nf = n as f32;
458        for j in 0..d {
459            let mut sum = 0.0_f32;
460            for i in 0..n {
461                sum += data[i * d + j];
462            }
463            let freq = sum / nf;
464            approx::assert_abs_diff_eq!(freq, prob[j], epsilon = 0.02);
465        }
466    }
467
468    #[test]
469    fn zero_population_with_prev_returns_prev_unchanged() {
470        // §7.1: a 0×0 selected population carries no genes to compete; the update
471        // loop never runs, so the previous probabilities pass through untouched.
472        let device = Default::default();
473        let p = CompactGeneticParams::default_for(3);
474        let prev = CompactGeneticState {
475            prob: vec![0.25, 0.5, 0.75],
476        };
477        let state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
478            &CompactGenetic,
479            &p,
480            Some(&prev),
481            pop(vec![], 0, 0),
482            fitness(vec![]),
483            &device,
484        );
485        assert_eq!(
486            state.prob, prev.prob,
487            "zero population must leave probabilities unchanged"
488        );
489    }
490
491    #[test]
492    fn fit_uses_population_dims_not_params_genome_dim() {
493        // §7.1: on the fit path `params.genome_dim` is unused — the population's
494        // column count and `prev.prob` length are authoritative. A mismatched
495        // genome_dim does not change the output length (dimension-mismatch
496        // contract: population dims win).
497        let device = Default::default();
498        let p = CompactGeneticParams {
499            genome_dim: 9,
500            virtual_pop_size: 10,
501        };
502        let prev = CompactGeneticState {
503            prob: vec![0.5, 0.5],
504        };
505        let state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
506            &CompactGenetic,
507            &p,
508            Some(&prev),
509            pop(vec![1.0, 0.0, 0.0, 1.0], 2, 2),
510            fitness(vec![1.0, 0.0]),
511            &device,
512        );
513        assert_eq!(
514            state.prob.len(),
515            2,
516            "output length follows the population/prev, not params.genome_dim"
517        );
518    }
519
520    #[test]
521    fn sample_is_deterministic_for_seed_and_state() {
522        // §7.4: same seed + same state ⇒ byte-identical sample tensor.
523        let device = Default::default();
524        let state = CompactGeneticState {
525            prob: vec![0.3, 0.6, 0.9],
526        };
527        let mut rng_a = StdRng::seed_from_u64(77);
528        let mut rng_b = StdRng::seed_from_u64(77);
529        let a = <CompactGenetic as ProbabilityModel<TestBackend>>::sample(
530            &CompactGenetic,
531            &state,
532            256,
533            &mut rng_a,
534            &device,
535        );
536        let b = <CompactGenetic as ProbabilityModel<TestBackend>>::sample(
537            &CompactGenetic,
538            &state,
539            256,
540            &mut rng_b,
541            &device,
542        );
543        let data_a = a
544            .into_data()
545            .into_vec::<f32>()
546            .expect("samples host-read of a tensor this test just built");
547        let data_b = b
548            .into_data()
549            .into_vec::<f32>()
550            .expect("samples host-read of a tensor this test just built");
551        assert_eq!(
552            data_a, data_b,
553            "same seed + state must produce identical output"
554        );
555    }
556
557    use proptest::prelude::*;
558
559    proptest! {
560        // §7.3: `prob ⊆ [0, 1]` must hold after ANY sequence of `fit` updates.
561        // Moderate cost (host-side tensor ops, no backend train) → 64 cases.
562        #![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })]
563
564        /// The per-gene probability vector stays inside `[0, 1]` after every
565        /// `fit` update, for arbitrary binary populations and fitness vectors.
566        ///
567        /// RNG boundary (ADR 0029): proptest chooses the config
568        /// `(genome_dim, virtual_pop_size, iters, seed)`; the seed drives a
569        /// `StdRng` that generates the binary population and fitness data.
570        /// `fit` itself is a deterministic MLE-style update and takes no rng.
571        #[test]
572        fn prob_stays_in_unit_interval(
573            genome_dim in 1usize..=16,
574            virtual_pop_size in 1usize..=200,
575            iters in 1u32..=50,
576            seed in any::<u64>(),
577        ) {
578            let device = Default::default();
579            let params = CompactGeneticParams {
580                genome_dim,
581                virtual_pop_size,
582            };
583            let mut state = fit_prior(&params);
584            let mut rng = StdRng::seed_from_u64(seed);
585
586            for _ in 0..iters {
587                // `k >= 2` so the winner/loser argmax/argmin compare a real pair.
588                let k: usize = rng.random_range(2..=16);
589                let rows: Vec<f32> = (0..k * genome_dim)
590                    .map(|_| if rng.random_bool(0.5) { 1.0 } else { 0.0 })
591                    .collect();
592                let fit_values: Vec<f32> = (0..k).map(|_| rng.random::<f32>()).collect();
593
594                state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
595                    &CompactGenetic,
596                    &params,
597                    Some(&state),
598                    pop(rows, k, genome_dim),
599                    fitness(fit_values),
600                    &device,
601                );
602
603                prop_assert!(
604                    state.prob().iter().all(|&p| (0.0..=1.0).contains(&p)),
605                    "prob escaped [0, 1] after fit: {:?}",
606                    state.prob()
607                );
608            }
609        }
610    }
611}