Skip to main content

rlevo_evolution/ops/
crossover.rs

1//! Recombination / crossover operators for real-valued genomes.
2//!
3//! Operators in this module consume two parent tensors of shape
4//! `(N, D)` and produce an offspring tensor of the same shape. Each
5//! operator draws its randomness from a caller-supplied host `rng` and
6//! materialises the draws via `Tensor::from_data`, rather than seeding
7//! the process-wide backend RNG (`B::seed` + `Tensor::random`). Host
8//! sampling keeps results reproducible across thread schedules: the
9//! global Flex RNG mutex would otherwise interleave draws with sibling
10//! tests under the parallel runner.
11//!
12//! # BLX-α
13//!
14//! For each gene, child ∈ `U(min(a,b) − α·|a−b|, max(a,b) + α·|a−b|)`.
15//! A common default is α = 0.5.
16//!
17//! # Uniform
18//!
19//! For each gene, child takes parent A's value with probability `p` and
20//! parent B's otherwise. Pure swap crossover — no blending — so the
21//! distribution is exactly preserved. A binary-genome variant
22//! ([`binary_uniform_crossover`]) operates on `Tensor<B, 2, Int>` with
23//! values in `{0, 1}`.
24
25use burn::tensor::{backend::Backend, Int, Tensor, TensorData};
26use rand::{Rng, RngExt};
27
28/// Builds an `(n·d,)` host vector of `U[0, 1)` draws sized for a
29/// `(n, d)` genome tensor.
30fn unit_uniform_rows(n: usize, d: usize, rng: &mut dyn Rng) -> Vec<f32> {
31    let mut rows = Vec::with_capacity(n * d);
32    for _ in 0..n * d {
33        rows.push(rng.random::<f32>());
34    }
35    rows
36}
37
38/// BLX-α (Blend Crossover α) between two parent populations.
39///
40/// For each gene position `i`, the child's value is drawn uniformly from the
41/// extended interval
42///
43/// ```text
44/// U(min(a_i, b_i) − α·|a_i − b_i|,  max(a_i, b_i) + α·|a_i − b_i|)
45/// ```
46///
47/// When `α = 0` the child lies strictly within the parents' bounding box.
48/// `α = 0.5` is the conventional default and allows mild extrapolation beyond
49/// either parent. All `n·d` draws are taken from the caller-supplied host `rng`
50/// and loaded onto the device via [`Tensor::from_data`]; no backend-global RNG
51/// state is touched.
52///
53/// Both parent tensors must have shape `(N, D)` where `N` is the population
54/// size and `D` is the genome length; the returned offspring tensor has the
55/// same shape.
56///
57/// # Panics
58///
59/// Panics if `parent_a` and `parent_b` do not have identical shapes.
60#[must_use]
61pub fn blx_alpha<B: Backend>(
62    parent_a: Tensor<B, 2>,
63    parent_b: Tensor<B, 2>,
64    alpha: f32,
65    rng: &mut dyn Rng,
66    device: &<B as burn::tensor::backend::BackendTypes>::Device,
67) -> Tensor<B, 2> {
68    assert_eq!(
69        parent_a.dims(),
70        parent_b.dims(),
71        "BLX-α: parents must have identical shapes"
72    );
73    let [n, d] = parent_a.dims();
74
75    let min = parent_a.clone().min_pair(parent_b.clone());
76    let max = parent_a.max_pair(parent_b);
77    let diff = max.clone() - min.clone();
78    let lo = min - diff.clone().mul_scalar(alpha);
79    let hi = max + diff.mul_scalar(alpha);
80
81    let u = Tensor::<B, 2>::from_data(TensorData::new(unit_uniform_rows(n, d, rng), [n, d]), device);
82    lo.clone() + u * (hi - lo)
83}
84
85/// Uniform crossover: per-gene Bernoulli swap between two parents.
86///
87/// For each gene position, the child inherits the value from `parent_a` with
88/// probability `p` and from `parent_b` with probability `1 − p`. No blending
89/// occurs; the child's gene values are drawn exclusively from the two parents'
90/// existing alleles, so the distribution over individual gene values is
91/// exactly preserved.
92///
93/// `p = 0.5` gives an unbiased mix; `p = 1.0` returns a clone of `parent_a`;
94/// `p = 0.0` returns a clone of `parent_b`. All `n·d` Bernoulli draws are
95/// taken from the caller-supplied host `rng` and loaded onto the device via
96/// [`Tensor::from_data`]; no backend-global RNG state is touched.
97///
98/// Both parent tensors must have shape `(N, D)` where `N` is the population
99/// size and `D` is the genome length; the returned offspring tensor has the
100/// same shape.
101///
102/// For binary genomes (`Tensor<B, 2, Int>` with values in `{0, 1}`) see
103/// [`binary_uniform_crossover`].
104///
105/// # Panics
106///
107/// Panics if `parent_a` and `parent_b` do not have identical shapes.
108#[must_use]
109pub fn uniform_crossover<B: Backend>(
110    parent_a: Tensor<B, 2>,
111    parent_b: Tensor<B, 2>,
112    p: f32,
113    rng: &mut dyn Rng,
114    device: &<B as burn::tensor::backend::BackendTypes>::Device,
115) -> Tensor<B, 2> {
116    assert_eq!(
117        parent_a.dims(),
118        parent_b.dims(),
119        "uniform crossover: parents must have identical shapes"
120    );
121    let [n, d] = parent_a.dims();
122    let u = Tensor::<B, 2>::from_data(TensorData::new(unit_uniform_rows(n, d, rng), [n, d]), device);
123    let keep_a = u.lower_elem(p);
124    parent_a.mask_where(keep_a.bool_not(), parent_b)
125}
126
127/// Binary uniform crossover on `Tensor<B, 2, Int>` populations.
128///
129/// The Int-tensor counterpart of [`uniform_crossover`], intended for binary
130/// genomes. For each gene, the child inherits `parent_a`'s allele with
131/// probability `p` and `parent_b`'s allele with probability `1 − p`. No
132/// blending is performed; the operation is a pure bitwise swap.
133///
134/// Both parents must hold values in `{0, 1}`. The returned tensor has the
135/// same shape and element type as the inputs. All `n·d` Bernoulli draws are
136/// taken from the caller-supplied host `rng` and loaded onto the device via
137/// [`Tensor::from_data`]; no backend-global RNG state is touched.
138///
139/// # Panics
140///
141/// Panics if `parent_a` and `parent_b` do not have identical shapes.
142#[must_use]
143pub fn binary_uniform_crossover<B: Backend>(
144    parent_a: Tensor<B, 2, Int>,
145    parent_b: Tensor<B, 2, Int>,
146    p: f32,
147    rng: &mut dyn Rng,
148    device: &<B as burn::tensor::backend::BackendTypes>::Device,
149) -> Tensor<B, 2, Int> {
150    assert_eq!(
151        parent_a.dims(),
152        parent_b.dims(),
153        "binary uniform crossover: parents must have identical shapes"
154    );
155    let [n, d] = parent_a.dims();
156    let u = Tensor::<B, 2>::from_data(TensorData::new(unit_uniform_rows(n, d, rng), [n, d]), device);
157    let keep_a = u.lower_elem(p);
158    parent_a.mask_where(keep_a.bool_not(), parent_b)
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use burn::backend::{flex::FlexDevice, Flex};
165    #[allow(unused_imports)]
166    use burn::tensor::backend::Backend as _;
167    use rand::SeedableRng;
168    use rand::rngs::StdRng;
169    type TestBackend = Flex;
170
171    #[test]
172    fn blx_alpha_lies_between_bounds() {
173        let device: FlexDevice = Default::default();
174        let mut rng = StdRng::seed_from_u64(13);
175        let a = Tensor::<TestBackend, 2>::from_data(
176            TensorData::new(vec![0.0_f32, 0.0, 0.0, 0.0], [2, 2]),
177            &device,
178        );
179        let b = Tensor::<TestBackend, 2>::from_data(
180            TensorData::new(vec![1.0_f32, 1.0, 1.0, 1.0], [2, 2]),
181            &device,
182        );
183        let c = blx_alpha(a, b, 0.0, &mut rng, &device);
184        let values = c.into_data().into_vec::<f32>().unwrap();
185        // α = 0: children lie strictly in [0, 1].
186        for v in values {
187            assert!((0.0..=1.0).contains(&v), "value out of bounds: {v}");
188        }
189    }
190
191    #[test]
192    fn uniform_all_from_a_when_p_is_one() {
193        let device: FlexDevice = Default::default();
194        let mut rng = StdRng::seed_from_u64(5);
195        let a = Tensor::<TestBackend, 2>::from_data(
196            TensorData::new(vec![7.0_f32, 7.0, 7.0, 7.0], [2, 2]),
197            &device,
198        );
199        let b = Tensor::<TestBackend, 2>::from_data(
200            TensorData::new(vec![-7.0_f32, -7.0, -7.0, -7.0], [2, 2]),
201            &device,
202        );
203        let c = uniform_crossover(a, b, 1.0, &mut rng, &device);
204        let values = c.into_data().into_vec::<f32>().unwrap();
205        for v in values {
206            approx::assert_relative_eq!(v, 7.0, epsilon = 1e-6);
207        }
208    }
209
210    #[test]
211    fn uniform_all_from_b_when_p_is_zero() {
212        let device: FlexDevice = Default::default();
213        let mut rng = StdRng::seed_from_u64(5);
214        let a = Tensor::<TestBackend, 2>::from_data(
215            TensorData::new(vec![7.0_f32, 7.0, 7.0, 7.0], [2, 2]),
216            &device,
217        );
218        let b = Tensor::<TestBackend, 2>::from_data(
219            TensorData::new(vec![-7.0_f32, -7.0, -7.0, -7.0], [2, 2]),
220            &device,
221        );
222        let c = uniform_crossover(a, b, 0.0, &mut rng, &device);
223        let values = c.into_data().into_vec::<f32>().unwrap();
224        for v in values {
225            approx::assert_relative_eq!(v, -7.0, epsilon = 1e-6);
226        }
227    }
228}