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 for the next generation.
6//!
7//! The helpers work on host-side fitness slices for selection logic and
8//! lift winners back to the device via [`Tensor::select`].
9
10use burn::tensor::{backend::Backend, Int, Tensor, TensorData};
11
12use crate::ops::selection::truncation_indices_host;
13
14/// Replace the entire current generation with offspring (no elitism).
15#[must_use]
16pub fn generational<B: Backend>(
17    _current_pop: Tensor<B, 2>,
18    _current_fitness: &[f32],
19    offspring_pop: Tensor<B, 2>,
20    offspring_fitness: Vec<f32>,
21) -> (Tensor<B, 2>, Vec<f32>) {
22    (offspring_pop, offspring_fitness)
23}
24
25/// Elitist replacement: keep the `k` best members of the current
26/// generation and fill the rest from offspring, preserving the best
27/// offspring first.
28///
29/// # Panics
30///
31/// Panics if `k > current_fitness.len()`, or if
32/// `pop_size − k > offspring_fitness.len()` (not enough offspring to
33/// backfill).
34#[must_use]
35pub fn elitist<B: Backend>(
36    current_pop: Tensor<B, 2>,
37    current_fitness: &[f32],
38    offspring_pop: Tensor<B, 2>,
39    offspring_fitness: &[f32],
40    k: usize,
41    device: &B::Device,
42) -> (Tensor<B, 2>, Vec<f32>) {
43    let pop_size = current_fitness.len();
44    assert!(k <= pop_size, "elite count must be <= population size");
45    let elite_idx = truncation_indices_host(current_fitness, k);
46    let elites = current_pop
47        .select(0, Tensor::<B, 1, Int>::from_data(TensorData::new(elite_idx.clone(), [k]), device));
48
49    let n_offspring_to_keep = pop_size - k;
50    let offspring_keep_idx = truncation_indices_host(offspring_fitness, n_offspring_to_keep);
51    let kept_offspring = offspring_pop.select(
52        0,
53        Tensor::<B, 1, Int>::from_data(
54            TensorData::new(offspring_keep_idx.clone(), [n_offspring_to_keep]),
55            device,
56        ),
57    );
58
59    let combined = Tensor::cat(vec![elites, kept_offspring], 0);
60
61    let mut combined_fitness = Vec::with_capacity(pop_size);
62    for i in elite_idx {
63        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
64        combined_fitness.push(current_fitness[i as usize]);
65    }
66    for i in offspring_keep_idx {
67        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
68        combined_fitness.push(offspring_fitness[i as usize]);
69    }
70
71    (combined, combined_fitness)
72}
73
74/// (μ + λ) replacement: merge the μ parents with the λ offspring and
75/// keep the μ lowest-fitness members overall.
76///
77/// # Panics
78///
79/// Panics if `mu > parent_fitness.len() + offspring_fitness.len()`
80/// (the underlying truncation cannot select more winners than the
81/// combined pool contains).
82#[must_use]
83pub fn mu_plus_lambda<B: Backend>(
84    parents: Tensor<B, 2>,
85    parent_fitness: &[f32],
86    offspring: Tensor<B, 2>,
87    offspring_fitness: &[f32],
88    mu: usize,
89    device: &B::Device,
90) -> (Tensor<B, 2>, Vec<f32>) {
91    let combined = Tensor::cat(vec![parents, offspring], 0);
92    let combined_fitness: Vec<f32> = parent_fitness
93        .iter()
94        .chain(offspring_fitness.iter())
95        .copied()
96        .collect();
97    let winners = truncation_indices_host(&combined_fitness, mu);
98    let next_fitness: Vec<f32> = winners
99        .iter()
100        .map(|&i| {
101            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
102            combined_fitness[i as usize]
103        })
104        .collect();
105    let indices = Tensor::<B, 1, Int>::from_data(TensorData::new(winners, [mu]), device);
106    (combined.select(0, indices), next_fitness)
107}
108
109/// (μ, λ) replacement: discard parents; keep the μ best of the λ
110/// offspring. Requires `lambda >= mu`.
111///
112/// # Panics
113///
114/// Panics if `mu > offspring_fitness.len()` (i.e. `lambda < mu`).
115#[must_use]
116pub fn mu_comma_lambda<B: Backend>(
117    offspring: Tensor<B, 2>,
118    offspring_fitness: &[f32],
119    mu: usize,
120    device: &B::Device,
121) -> (Tensor<B, 2>, Vec<f32>) {
122    assert!(
123        mu <= offspring_fitness.len(),
124        "(μ, λ): lambda must be >= mu",
125    );
126    let winners = truncation_indices_host(offspring_fitness, mu);
127    let next_fitness: Vec<f32> = winners
128        .iter()
129        .map(|&i| {
130            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
131            offspring_fitness[i as usize]
132        })
133        .collect();
134    let indices = Tensor::<B, 1, Int>::from_data(TensorData::new(winners, [mu]), device);
135    (offspring.select(0, indices), next_fitness)
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use burn::backend::NdArray;
142    type TestBackend = NdArray;
143
144    #[test]
145    fn generational_discards_current() {
146        let device = Default::default();
147        let current = Tensor::<TestBackend, 2>::from_data(
148            TensorData::new(vec![0.0_f32; 4], [2, 2]),
149            &device,
150        );
151        let offspring = Tensor::<TestBackend, 2>::from_data(
152            TensorData::new(vec![1.0_f32; 4], [2, 2]),
153            &device,
154        );
155        let (next, f) = generational::<TestBackend>(
156            current,
157            &[0.0, 0.0],
158            offspring,
159            vec![1.0, 1.0],
160        );
161        let values = next.into_data().into_vec::<f32>().unwrap();
162        for v in values {
163            approx::assert_relative_eq!(v, 1.0, epsilon = 1e-6);
164        }
165        assert_eq!(f, vec![1.0, 1.0]);
166    }
167
168    #[test]
169    fn mu_plus_lambda_keeps_best_overall() {
170        let device = Default::default();
171        let parents = Tensor::<TestBackend, 2>::from_data(
172            TensorData::new(vec![10.0_f32, 10.0, 10.0, 10.0], [2, 2]),
173            &device,
174        );
175        let offspring = Tensor::<TestBackend, 2>::from_data(
176            TensorData::new(vec![1.0_f32, 1.0, 5.0, 5.0], [2, 2]),
177            &device,
178        );
179        let (next, f) = mu_plus_lambda::<TestBackend>(
180            parents,
181            &[0.5, 100.0],
182            offspring,
183            &[0.1, 50.0],
184            2,
185            &device,
186        );
187        let rows = next.into_data().into_vec::<f32>().unwrap();
188        // best two: offspring row 0 (0.1) and parent row 0 (0.5)
189        assert_eq!(rows.len(), 4);
190        // fitness should be {0.1, 0.5}
191        let mut f_sorted = f;
192        f_sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
193        approx::assert_relative_eq!(f_sorted[0], 0.1, epsilon = 1e-6);
194        approx::assert_relative_eq!(f_sorted[1], 0.5, epsilon = 1e-6);
195    }
196
197    #[test]
198    fn mu_comma_lambda_keeps_best_of_offspring() {
199        let device = Default::default();
200        let offspring = Tensor::<TestBackend, 2>::from_data(
201            TensorData::new(vec![1.0_f32, 1.0, 2.0, 2.0, 3.0, 3.0], [3, 2]),
202            &device,
203        );
204        let (next, f) = mu_comma_lambda::<TestBackend>(
205            offspring,
206            &[5.0, 1.0, 3.0],
207            2,
208            &device,
209        );
210        assert_eq!(next.shape().dims, vec![2, 2]);
211        let mut fs = f;
212        fs.sort_by(|a, b| a.partial_cmp(b).unwrap());
213        approx::assert_relative_eq!(fs[0], 1.0, epsilon = 1e-6);
214        approx::assert_relative_eq!(fs[1], 3.0, epsilon = 1e-6);
215    }
216}