Skip to main content

rlevo_evolution/ops/
replacement.rs

1//! Survivor-selection / replacement operators.
2//!
3//! Each function takes the current generation's population and fitness
4//! plus the offspring population and fitness, and returns the
5//! `(population, fitness)` pair that becomes the next generation's
6//! starting state.
7//!
8//! All selection logic operates on host-side `&[f32]` fitness slices
9//! (canonical: higher is better), and winners are lifted back to the
10//! device via a single [`Tensor::select`] gather. None of these functions
11//! draw random numbers; they are deterministic given their inputs.
12//!
13//! # Fitness convention
14//!
15//! Fitness is **canonical (maximise)**: larger values are better. This
16//! matches the convention used throughout [`crate::ops::selection`].
17//!
18//! # Choosing a replacement strategy
19//!
20//! | Strategy | Parent survival | Use when |
21//! |---|---|---|
22//! | [`generational`] | none | offspring quality is trusted; GA / CMA-ES |
23//! | [`elitist`] | top-k | preserving known-good solutions matters |
24//! | [`mu_plus_lambda`] | best of μ+λ pool | ES / DE with strong elitism |
25//! | [`mu_comma_lambda`] | none (offspring only) | ES with deliberate age-based forgetting |
26
27use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
28
29use crate::ops::selection::truncation_indices_host;
30
31/// Replaces the entire current generation with the offspring (no elitism).
32///
33/// Discards `_current_pop` and `_current_fitness` entirely; the returned
34/// pair is `(offspring_pop, offspring_fitness)` unchanged. This is the
35/// standard generational replacement used in classic GAs and CMA-ES: the
36/// offspring generation fully succeeds the parent generation with no
37/// carry-over of parent individuals.
38#[must_use]
39pub fn generational<B: Backend>(
40    _current_pop: Tensor<B, 2>,
41    _current_fitness: &[f32],
42    offspring_pop: Tensor<B, 2>,
43    offspring_fitness: Vec<f32>,
44) -> (Tensor<B, 2>, Vec<f32>) {
45    (offspring_pop, offspring_fitness)
46}
47
48/// Elitist replacement: keeps the `k` best parents and the best remaining offspring.
49///
50/// Selects the `k` highest-fitness members of the current generation
51/// (elites) and the `pop_size − k` highest-fitness offspring, then
52/// concatenates them to form the next generation of size `pop_size`.
53/// Both selections use [`truncation_indices_host`] and are therefore
54/// deterministic.
55///
56/// The returned fitness vector has the elites' fitnesses first,
57/// followed by the kept offspring fitnesses, in the same order as the
58/// corresponding rows of the returned tensor.
59///
60/// # Panics
61///
62/// Panics if `k > current_fitness.len()`, or if
63/// `pop_size − k > offspring_fitness.len()` (not enough offspring to
64/// backfill).
65#[must_use]
66pub fn elitist<B: Backend>(
67    current_pop: Tensor<B, 2>,
68    current_fitness: &[f32],
69    offspring_pop: Tensor<B, 2>,
70    offspring_fitness: &[f32],
71    k: usize,
72    device: &<B as burn::tensor::backend::BackendTypes>::Device,
73) -> (Tensor<B, 2>, Vec<f32>) {
74    let pop_size = current_fitness.len();
75    assert!(k <= pop_size, "elite count must be <= population size");
76    let elite_idx = truncation_indices_host(current_fitness, k);
77    let elites = current_pop.select(
78        0,
79        Tensor::<B, 1, Int>::from_data(TensorData::new(elite_idx.clone(), [k]), device),
80    );
81
82    let n_offspring_to_keep = pop_size - k;
83    assert!(
84        n_offspring_to_keep <= offspring_fitness.len(),
85        "elitist: not enough offspring ({}) to backfill {} slots after {k} elites",
86        offspring_fitness.len(),
87        n_offspring_to_keep,
88    );
89    let offspring_keep_idx = truncation_indices_host(offspring_fitness, n_offspring_to_keep);
90    let kept_offspring = offspring_pop.select(
91        0,
92        Tensor::<B, 1, Int>::from_data(
93            TensorData::new(offspring_keep_idx.clone(), [n_offspring_to_keep]),
94            device,
95        ),
96    );
97
98    let combined = Tensor::cat(vec![elites, kept_offspring], 0);
99
100    let mut combined_fitness = Vec::with_capacity(pop_size);
101    for i in elite_idx {
102        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
103        combined_fitness.push(current_fitness[i as usize]);
104    }
105    for i in offspring_keep_idx {
106        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
107        combined_fitness.push(offspring_fitness[i as usize]);
108    }
109
110    (combined, combined_fitness)
111}
112
113/// (μ + λ) replacement: keeps the μ best individuals from the merged parent and offspring pool.
114///
115/// Concatenates the μ parent rows with the λ offspring rows into a
116/// combined pool of size μ + λ, then retains the `mu` members with
117/// the highest fitness. Parents and offspring compete on equal footing,
118/// so a highly-fit parent can survive indefinitely.
119///
120/// The returned tensor has shape `(mu, genome_dim)` and the returned
121/// fitness vector has length `mu`, both ordered by selection rank
122/// (best first).
123///
124/// # Panics
125///
126/// Panics if `mu > parent_fitness.len() + offspring_fitness.len()`
127/// (the underlying truncation cannot select more winners than the
128/// combined pool contains).
129#[must_use]
130pub fn mu_plus_lambda<B: Backend>(
131    parents: Tensor<B, 2>,
132    parent_fitness: &[f32],
133    offspring: Tensor<B, 2>,
134    offspring_fitness: &[f32],
135    mu: usize,
136    device: &<B as burn::tensor::backend::BackendTypes>::Device,
137) -> (Tensor<B, 2>, Vec<f32>) {
138    let combined = Tensor::cat(vec![parents, offspring], 0);
139    let combined_fitness: Vec<f32> = parent_fitness
140        .iter()
141        .chain(offspring_fitness.iter())
142        .copied()
143        .collect();
144    let winners = truncation_indices_host(&combined_fitness, mu);
145    let next_fitness: Vec<f32> = winners
146        .iter()
147        .map(|&i| {
148            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
149            combined_fitness[i as usize]
150        })
151        .collect();
152    let indices = Tensor::<B, 1, Int>::from_data(TensorData::new(winners, [mu]), device);
153    (combined.select(0, indices), next_fitness)
154}
155
156/// (μ, λ) replacement: discards parents and keeps the μ best offspring.
157///
158/// Parents are not passed to this function; only the λ offspring
159/// compete for the μ survivor slots. This strategy deliberately
160/// discards parent solutions each generation, which can help the
161/// population escape local optima and tracks moving optima better than
162/// (μ + λ). Requires `lambda >= mu` (`offspring_fitness.len() >= mu`).
163///
164/// The returned tensor has shape `(mu, genome_dim)` and the returned
165/// fitness vector has length `mu`, both ordered by selection rank
166/// (best first).
167///
168/// # Panics
169///
170/// Panics if `mu > offspring_fitness.len()` (i.e. `lambda < mu`).
171#[must_use]
172pub fn mu_comma_lambda<B: Backend>(
173    offspring: Tensor<B, 2>,
174    offspring_fitness: &[f32],
175    mu: usize,
176    device: &<B as burn::tensor::backend::BackendTypes>::Device,
177) -> (Tensor<B, 2>, Vec<f32>) {
178    assert!(
179        mu <= offspring_fitness.len(),
180        "(μ, λ): lambda must be >= mu",
181    );
182    let winners = truncation_indices_host(offspring_fitness, mu);
183    let next_fitness: Vec<f32> = winners
184        .iter()
185        .map(|&i| {
186            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
187            offspring_fitness[i as usize]
188        })
189        .collect();
190    let indices = Tensor::<B, 1, Int>::from_data(TensorData::new(winners, [mu]), device);
191    (offspring.select(0, indices), next_fitness)
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use burn::backend::Flex;
198    type TestBackend = Flex;
199
200    #[test]
201    fn generational_discards_current() {
202        let device = Default::default();
203        let current =
204            Tensor::<TestBackend, 2>::from_data(TensorData::new(vec![0.0_f32; 4], [2, 2]), &device);
205        let offspring =
206            Tensor::<TestBackend, 2>::from_data(TensorData::new(vec![1.0_f32; 4], [2, 2]), &device);
207        let (next, f) =
208            generational::<TestBackend>(current, &[0.0, 0.0], offspring, vec![1.0, 1.0]);
209        let values = next
210            .into_data()
211            .into_vec::<f32>()
212            .expect("population host-read of a tensor this test just built");
213        for v in values {
214            approx::assert_relative_eq!(v, 1.0, epsilon = 1e-6);
215        }
216        assert_eq!(f, vec![1.0, 1.0]);
217    }
218
219    #[test]
220    fn mu_plus_lambda_keeps_best_overall() {
221        let device = Default::default();
222        let parents = Tensor::<TestBackend, 2>::from_data(
223            TensorData::new(vec![10.0_f32, 10.0, 10.0, 10.0], [2, 2]),
224            &device,
225        );
226        let offspring = Tensor::<TestBackend, 2>::from_data(
227            TensorData::new(vec![1.0_f32, 1.0, 5.0, 5.0], [2, 2]),
228            &device,
229        );
230        let (next, f) = mu_plus_lambda::<TestBackend>(
231            parents,
232            &[0.5, 100.0],
233            offspring,
234            &[0.1, 50.0],
235            2,
236            &device,
237        );
238        let rows = next
239            .into_data()
240            .into_vec::<f32>()
241            .expect("population host-read of a tensor this test just built");
242        // best two (highest fitness): parent row 1 (100.0) and offspring row 1 (50.0)
243        assert_eq!(rows.len(), 4);
244        // fitness should be {50.0, 100.0}
245        let mut f_sorted = f;
246        f_sorted.sort_by(f32::total_cmp);
247        approx::assert_relative_eq!(f_sorted[0], 50.0, epsilon = 1e-6);
248        approx::assert_relative_eq!(f_sorted[1], 100.0, epsilon = 1e-6);
249    }
250
251    #[test]
252    fn mu_comma_lambda_keeps_best_of_offspring() {
253        let device = Default::default();
254        let offspring = Tensor::<TestBackend, 2>::from_data(
255            TensorData::new(vec![1.0_f32, 1.0, 2.0, 2.0, 3.0, 3.0], [3, 2]),
256            &device,
257        );
258        let (next, f) = mu_comma_lambda::<TestBackend>(offspring, &[5.0, 1.0, 3.0], 2, &device);
259        assert_eq!(next.dims(), [2, 2]);
260        // best two of offspring (highest fitness): 5.0 and 3.0
261        let mut fs = f;
262        fs.sort_by(f32::total_cmp);
263        approx::assert_relative_eq!(fs[0], 3.0, epsilon = 1e-6);
264        approx::assert_relative_eq!(fs[1], 5.0, epsilon = 1e-6);
265    }
266}