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::{Int, Tensor, TensorData, backend::Backend};
26use rand::{Rng, RngExt};
27use rlevo_core::probability::Probability;
28use rlevo_core::rate::NonNegativeRate;
29
30/// Builds an `(n·d,)` host vector of `U[0, 1)` draws sized for a
31/// `(n, d)` genome tensor.
32fn unit_uniform_rows(n: usize, d: usize, rng: &mut dyn Rng) -> Vec<f32> {
33    let mut rows = Vec::with_capacity(n * d);
34    for _ in 0..n * d {
35        rows.push(rng.random::<f32>());
36    }
37    rows
38}
39
40/// BLX-α (Blend Crossover α) between two parent populations.
41///
42/// For each gene position `i`, the child's value is drawn uniformly from the
43/// extended interval
44///
45/// ```text
46/// U(min(a_i, b_i) − α·|a_i − b_i|,  max(a_i, b_i) + α·|a_i − b_i|)
47/// ```
48///
49/// When `α = 0` the child lies strictly within the parents' bounding box.
50/// `α = 0.5` is the conventional default and allows mild extrapolation beyond
51/// either parent. All `n·d` draws are taken from the caller-supplied host `rng`
52/// and loaded onto the device via [`Tensor::from_data`]; no backend-global RNG
53/// state is touched.
54///
55/// Both parent tensors must have shape `(N, D)` where `N` is the population
56/// size and `D` is the genome length; the returned offspring tensor has the
57/// same shape.
58///
59/// # Panics
60///
61/// Panics if `parent_a` and `parent_b` do not have identical shapes.
62#[must_use]
63pub fn blx_alpha<B: Backend>(
64    parent_a: Tensor<B, 2>,
65    parent_b: Tensor<B, 2>,
66    alpha: NonNegativeRate,
67    rng: &mut dyn Rng,
68    device: &<B as burn::tensor::backend::BackendTypes>::Device,
69) -> Tensor<B, 2> {
70    assert_eq!(
71        parent_a.dims(),
72        parent_b.dims(),
73        "BLX-α: parents must have identical shapes"
74    );
75    let [n, d] = parent_a.dims();
76
77    let alpha = alpha.get();
78    let min = parent_a.clone().min_pair(parent_b.clone());
79    let max = parent_a.max_pair(parent_b);
80    let diff = max.clone() - min.clone();
81    let lo = min - diff.clone().mul_scalar(alpha);
82    let hi = max + diff.mul_scalar(alpha);
83
84    let u = Tensor::<B, 2>::from_data(
85        TensorData::new(unit_uniform_rows(n, d, rng), [n, d]),
86        device,
87    );
88    lo.clone() + u * (hi - lo)
89}
90
91/// Uniform crossover: per-gene Bernoulli swap between two parents.
92///
93/// For each gene position, the child inherits the value from `parent_a` with
94/// probability `p` and from `parent_b` with probability `1 − p`. No blending
95/// occurs; the child's gene values are drawn exclusively from the two parents'
96/// existing alleles, so the distribution over individual gene values is
97/// exactly preserved.
98///
99/// `p = 0.5` gives an unbiased mix; `p = 1.0` returns a clone of `parent_a`;
100/// `p = 0.0` returns a clone of `parent_b`. All `n·d` Bernoulli draws are
101/// taken from the caller-supplied host `rng` and loaded onto the device via
102/// [`Tensor::from_data`]; no backend-global RNG state is touched.
103///
104/// Both parent tensors must have shape `(N, D)` where `N` is the population
105/// size and `D` is the genome length; the returned offspring tensor has the
106/// same shape.
107///
108/// For binary genomes (`Tensor<B, 2, Int>` with values in `{0, 1}`) see
109/// [`binary_uniform_crossover`].
110///
111/// # Panics
112///
113/// Panics if `parent_a` and `parent_b` do not have identical shapes.
114#[must_use]
115pub fn uniform_crossover<B: Backend>(
116    parent_a: Tensor<B, 2>,
117    parent_b: Tensor<B, 2>,
118    p: Probability,
119    rng: &mut dyn Rng,
120    device: &<B as burn::tensor::backend::BackendTypes>::Device,
121) -> Tensor<B, 2> {
122    assert_eq!(
123        parent_a.dims(),
124        parent_b.dims(),
125        "uniform crossover: parents must have identical shapes"
126    );
127    let [n, d] = parent_a.dims();
128    let u = Tensor::<B, 2>::from_data(
129        TensorData::new(unit_uniform_rows(n, d, rng), [n, d]),
130        device,
131    );
132    let keep_a = u.lower_elem(p.get());
133    parent_a.mask_where(keep_a.bool_not(), parent_b)
134}
135
136/// Binary uniform crossover on `Tensor<B, 2, Int>` populations.
137///
138/// The Int-tensor counterpart of [`uniform_crossover`], intended for binary
139/// genomes. For each gene, the child inherits `parent_a`'s allele with
140/// probability `p` and `parent_b`'s allele with probability `1 − p`. No
141/// blending is performed; the operation is a pure bitwise swap.
142///
143/// Both parents must hold values in `{0, 1}`. The returned tensor has the
144/// same shape and element type as the inputs. All `n·d` Bernoulli draws are
145/// taken from the caller-supplied host `rng` and loaded onto the device via
146/// [`Tensor::from_data`]; no backend-global RNG state is touched.
147///
148/// # Panics
149///
150/// Panics if `parent_a` and `parent_b` do not have identical shapes.
151#[must_use]
152pub fn binary_uniform_crossover<B: Backend>(
153    parent_a: Tensor<B, 2, Int>,
154    parent_b: Tensor<B, 2, Int>,
155    p: Probability,
156    rng: &mut dyn Rng,
157    device: &<B as burn::tensor::backend::BackendTypes>::Device,
158) -> Tensor<B, 2, Int> {
159    assert_eq!(
160        parent_a.dims(),
161        parent_b.dims(),
162        "binary uniform crossover: parents must have identical shapes"
163    );
164    let [n, d] = parent_a.dims();
165    let u = Tensor::<B, 2>::from_data(
166        TensorData::new(unit_uniform_rows(n, d, rng), [n, d]),
167        device,
168    );
169    let keep_a = u.lower_elem(p.get());
170    parent_a.mask_where(keep_a.bool_not(), parent_b)
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use burn::backend::{Flex, flex::FlexDevice};
177    #[allow(unused_imports)]
178    use burn::tensor::backend::Backend as _;
179    use rand::SeedableRng;
180    use rand::rngs::StdRng;
181    type TestBackend = Flex;
182
183    #[test]
184    fn blx_alpha_lies_between_bounds() {
185        let device: FlexDevice = Default::default();
186        let mut rng = StdRng::seed_from_u64(13);
187        let a = Tensor::<TestBackend, 2>::from_data(
188            TensorData::new(vec![0.0_f32, 0.0, 0.0, 0.0], [2, 2]),
189            &device,
190        );
191        let b = Tensor::<TestBackend, 2>::from_data(
192            TensorData::new(vec![1.0_f32, 1.0, 1.0, 1.0], [2, 2]),
193            &device,
194        );
195        let c = blx_alpha(a, b, NonNegativeRate::new(0.0), &mut rng, &device);
196        let values = c
197            .into_data()
198            .into_vec::<f32>()
199            .expect("genome host-read of a tensor this test just built");
200        // α = 0: children lie strictly in [0, 1].
201        for v in values {
202            assert!((0.0..=1.0).contains(&v), "value out of bounds: {v}");
203        }
204    }
205
206    #[test]
207    fn uniform_all_from_a_when_p_is_one() {
208        let device: FlexDevice = Default::default();
209        let mut rng = StdRng::seed_from_u64(5);
210        let a = Tensor::<TestBackend, 2>::from_data(
211            TensorData::new(vec![7.0_f32, 7.0, 7.0, 7.0], [2, 2]),
212            &device,
213        );
214        let b = 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 c = uniform_crossover(a, b, Probability::new(1.0), &mut rng, &device);
219        let values = c
220            .into_data()
221            .into_vec::<f32>()
222            .expect("genome host-read of a tensor this test just built");
223        for v in values {
224            approx::assert_relative_eq!(v, 7.0, epsilon = 1e-6);
225        }
226    }
227
228    #[test]
229    fn uniform_all_from_b_when_p_is_zero() {
230        let device: FlexDevice = Default::default();
231        let mut rng = StdRng::seed_from_u64(5);
232        let a = Tensor::<TestBackend, 2>::from_data(
233            TensorData::new(vec![7.0_f32, 7.0, 7.0, 7.0], [2, 2]),
234            &device,
235        );
236        let b = Tensor::<TestBackend, 2>::from_data(
237            TensorData::new(vec![-7.0_f32, -7.0, -7.0, -7.0], [2, 2]),
238            &device,
239        );
240        let c = uniform_crossover(a, b, Probability::new(0.0), &mut rng, &device);
241        let values = c
242            .into_data()
243            .into_vec::<f32>()
244            .expect("genome host-read of a tensor this test just built");
245        for v in values {
246            approx::assert_relative_eq!(v, -7.0, epsilon = 1e-6);
247        }
248    }
249
250    #[test]
251    fn nan_and_inf_rates_are_unconstructable() {
252        // Regression for #144 (ADR 0031): the crossover/mutation rate scalars
253        // used to be bare `f32`s, so a NaN silently degenerated `u.lower_elem(p)`
254        // to an all-false mask (a no-op / one-parent clone) and a NaN/Inf BLX-α
255        // poisoned the whole offspring tensor. The operators now take validated
256        // newtypes, so those inputs cannot even be constructed — the hazard is
257        // unrepresentable rather than silently mishandled.
258        assert!(Probability::try_new(f32::NAN).is_err());
259        assert!(Probability::try_new(1.5).is_err());
260        assert!(NonNegativeRate::try_new(f32::NAN).is_err());
261        assert!(NonNegativeRate::try_new(f32::INFINITY).is_err());
262        assert!(NonNegativeRate::try_new(-1.0).is_err());
263    }
264}