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//! (lower is better), and winners are lifted back to the device via a
10//! single [`Tensor::select`] gather. None of these functions draw
11//! random numbers; they are deterministic given their inputs.
12//!
13//! # Fitness convention
14//!
15//! Fitness is treated as a cost: smaller 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::{backend::Backend, Int, Tensor, TensorData};
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` lowest-fitness members of the current generation
51/// (elites) and the `pop_size − k` lowest-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
78        .select(0, Tensor::<B, 1, Int>::from_data(TensorData::new(elite_idx.clone(), [k]), device));
79
80    let n_offspring_to_keep = pop_size - k;
81    let offspring_keep_idx = truncation_indices_host(offspring_fitness, n_offspring_to_keep);
82    let kept_offspring = offspring_pop.select(
83        0,
84        Tensor::<B, 1, Int>::from_data(
85            TensorData::new(offspring_keep_idx.clone(), [n_offspring_to_keep]),
86            device,
87        ),
88    );
89
90    let combined = Tensor::cat(vec![elites, kept_offspring], 0);
91
92    let mut combined_fitness = Vec::with_capacity(pop_size);
93    for i in elite_idx {
94        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
95        combined_fitness.push(current_fitness[i as usize]);
96    }
97    for i in offspring_keep_idx {
98        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
99        combined_fitness.push(offspring_fitness[i as usize]);
100    }
101
102    (combined, combined_fitness)
103}
104
105/// (μ + λ) replacement: keeps the μ best individuals from the merged parent and offspring pool.
106///
107/// Concatenates the μ parent rows with the λ offspring rows into a
108/// combined pool of size μ + λ, then retains the `mu` members with
109/// the lowest fitness. Parents and offspring compete on equal footing,
110/// so a highly-fit parent can survive indefinitely.
111///
112/// The returned tensor has shape `(mu, genome_dim)` and the returned
113/// fitness vector has length `mu`, both ordered by selection rank
114/// (best first).
115///
116/// # Panics
117///
118/// Panics if `mu > parent_fitness.len() + offspring_fitness.len()`
119/// (the underlying truncation cannot select more winners than the
120/// combined pool contains).
121#[must_use]
122pub fn mu_plus_lambda<B: Backend>(
123    parents: Tensor<B, 2>,
124    parent_fitness: &[f32],
125    offspring: Tensor<B, 2>,
126    offspring_fitness: &[f32],
127    mu: usize,
128    device: &<B as burn::tensor::backend::BackendTypes>::Device,
129) -> (Tensor<B, 2>, Vec<f32>) {
130    let combined = Tensor::cat(vec![parents, offspring], 0);
131    let combined_fitness: Vec<f32> = parent_fitness
132        .iter()
133        .chain(offspring_fitness.iter())
134        .copied()
135        .collect();
136    let winners = truncation_indices_host(&combined_fitness, mu);
137    let next_fitness: Vec<f32> = winners
138        .iter()
139        .map(|&i| {
140            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
141            combined_fitness[i as usize]
142        })
143        .collect();
144    let indices = Tensor::<B, 1, Int>::from_data(TensorData::new(winners, [mu]), device);
145    (combined.select(0, indices), next_fitness)
146}
147
148/// (μ, λ) replacement: discards parents and keeps the μ best offspring.
149///
150/// Parents are not passed to this function; only the λ offspring
151/// compete for the μ survivor slots. This strategy deliberately
152/// discards parent solutions each generation, which can help the
153/// population escape local optima and tracks moving optima better than
154/// (μ + λ). Requires `lambda >= mu` (`offspring_fitness.len() >= mu`).
155///
156/// The returned tensor has shape `(mu, genome_dim)` and the returned
157/// fitness vector has length `mu`, both ordered by selection rank
158/// (best first).
159///
160/// # Panics
161///
162/// Panics if `mu > offspring_fitness.len()` (i.e. `lambda < mu`).
163#[must_use]
164pub fn mu_comma_lambda<B: Backend>(
165    offspring: Tensor<B, 2>,
166    offspring_fitness: &[f32],
167    mu: usize,
168    device: &<B as burn::tensor::backend::BackendTypes>::Device,
169) -> (Tensor<B, 2>, Vec<f32>) {
170    assert!(
171        mu <= offspring_fitness.len(),
172        "(μ, λ): lambda must be >= mu",
173    );
174    let winners = truncation_indices_host(offspring_fitness, mu);
175    let next_fitness: Vec<f32> = winners
176        .iter()
177        .map(|&i| {
178            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
179            offspring_fitness[i as usize]
180        })
181        .collect();
182    let indices = Tensor::<B, 1, Int>::from_data(TensorData::new(winners, [mu]), device);
183    (offspring.select(0, indices), next_fitness)
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use burn::backend::Flex;
190    type TestBackend = Flex;
191
192    #[test]
193    fn generational_discards_current() {
194        let device = Default::default();
195        let current = Tensor::<TestBackend, 2>::from_data(
196            TensorData::new(vec![0.0_f32; 4], [2, 2]),
197            &device,
198        );
199        let offspring = Tensor::<TestBackend, 2>::from_data(
200            TensorData::new(vec![1.0_f32; 4], [2, 2]),
201            &device,
202        );
203        let (next, f) = generational::<TestBackend>(
204            current,
205            &[0.0, 0.0],
206            offspring,
207            vec![1.0, 1.0],
208        );
209        let values = next.into_data().into_vec::<f32>().unwrap();
210        for v in values {
211            approx::assert_relative_eq!(v, 1.0, epsilon = 1e-6);
212        }
213        assert_eq!(f, vec![1.0, 1.0]);
214    }
215
216    #[test]
217    fn mu_plus_lambda_keeps_best_overall() {
218        let device = Default::default();
219        let parents = Tensor::<TestBackend, 2>::from_data(
220            TensorData::new(vec![10.0_f32, 10.0, 10.0, 10.0], [2, 2]),
221            &device,
222        );
223        let offspring = Tensor::<TestBackend, 2>::from_data(
224            TensorData::new(vec![1.0_f32, 1.0, 5.0, 5.0], [2, 2]),
225            &device,
226        );
227        let (next, f) = mu_plus_lambda::<TestBackend>(
228            parents,
229            &[0.5, 100.0],
230            offspring,
231            &[0.1, 50.0],
232            2,
233            &device,
234        );
235        let rows = next.into_data().into_vec::<f32>().unwrap();
236        // best two: offspring row 0 (0.1) and parent row 0 (0.5)
237        assert_eq!(rows.len(), 4);
238        // fitness should be {0.1, 0.5}
239        let mut f_sorted = f;
240        f_sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
241        approx::assert_relative_eq!(f_sorted[0], 0.1, epsilon = 1e-6);
242        approx::assert_relative_eq!(f_sorted[1], 0.5, epsilon = 1e-6);
243    }
244
245    #[test]
246    fn mu_comma_lambda_keeps_best_of_offspring() {
247        let device = Default::default();
248        let offspring = Tensor::<TestBackend, 2>::from_data(
249            TensorData::new(vec![1.0_f32, 1.0, 2.0, 2.0, 3.0, 3.0], [3, 2]),
250            &device,
251        );
252        let (next, f) = mu_comma_lambda::<TestBackend>(
253            offspring,
254            &[5.0, 1.0, 3.0],
255            2,
256            &device,
257        );
258        assert_eq!(next.dims(), [2, 2]);
259        let mut fs = f;
260        fs.sort_by(|a, b| a.partial_cmp(b).unwrap());
261        approx::assert_relative_eq!(fs[0], 1.0, epsilon = 1e-6);
262        approx::assert_relative_eq!(fs[1], 3.0, epsilon = 1e-6);
263    }
264}