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}