Skip to main content

rlevo_evolution/ops/
mutation.rs

1//! Mutation operators for real-valued genomes.
2//!
3//! Every mutation is an in-place-ish tensor transform: it consumes a
4//! population tensor and returns a new one with noise added per gene.
5//! The noise is sampled from a caller-supplied host `rng` and
6//! materialised via `Tensor::from_data`, rather than seeding the
7//! 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
12use burn::tensor::{backend::Backend, Int, Tensor, TensorData};
13use rand::{Rng, RngExt};
14use rand_distr::{Distribution as _, Normal};
15
16/// Builds an `(n·d,)` host vector of `N(0, 1)` draws sized for a
17/// `(n, d)` tensor.
18fn standard_normal_rows(n: usize, d: usize, rng: &mut dyn Rng) -> Vec<f32> {
19    let normal = Normal::new(0.0f32, 1.0).expect("unit normal is well-defined");
20    let mut rows = Vec::with_capacity(n * d);
21    for _ in 0..n * d {
22        rows.push(normal.sample(rng));
23    }
24    rows
25}
26
27/// Isotropic Gaussian mutation with a scalar step-size σ.
28///
29/// Each gene in the population is independently perturbed by `σ · N(0, 1)`
30/// noise. The same σ is applied to every individual and every gene dimension.
31/// When `σ = 0` the function is an identity and returns a tensor numerically
32/// equal to the input.
33///
34/// The `n·d` standard-normal draws are taken from the caller-supplied host
35/// `rng` via [`rand_distr::Normal`] and loaded onto the device using
36/// [`Tensor::from_data`]; no backend-global RNG state is touched.
37///
38/// The input tensor must have shape `(N, D)` where `N` is the population size
39/// and `D` is the genome length; the returned tensor has the same shape.
40///
41/// For per-individual step-sizes see [`gaussian_mutation_per_row`].
42#[must_use]
43pub fn gaussian_mutation<B: Backend>(
44    population: Tensor<B, 2>,
45    sigma: f32,
46    rng: &mut dyn Rng,
47    device: &<B as burn::tensor::backend::BackendTypes>::Device,
48) -> Tensor<B, 2> {
49    let [n, d] = population.dims();
50    let noise =
51        Tensor::<B, 2>::from_data(TensorData::new(standard_normal_rows(n, d, rng), [n, d]), device);
52    population + noise.mul_scalar(sigma)
53}
54
55/// Per-individual anisotropic Gaussian mutation.
56///
57/// A generalisation of [`gaussian_mutation`] that applies a distinct step-size
58/// σ to each individual. `sigmas` is a `(N,)` tensor whose `i`-th entry is the
59/// σ for the `i`-th genome row. All `D` genes within a row share the same σ,
60/// so mutation is isotropic within a row but can vary across the population.
61/// This matches the self-adaptive σ convention used by (1+1)-ES and CMA-ES
62/// warm-starts.
63///
64/// When all entries of `sigmas` are zero the function is an identity and
65/// returns a tensor numerically equal to the input. The `n·d` standard-normal
66/// draws are taken from the caller-supplied host `rng` and loaded onto the
67/// device via [`Tensor::from_data`]; no backend-global RNG state is touched.
68///
69/// # Panics
70///
71/// Panics if the length of `sigmas` does not equal the population's first
72/// dimension `N` (the internal `reshape([n, 1])` step requires exactly `n`
73/// σ values).
74#[must_use]
75pub fn gaussian_mutation_per_row<B: Backend>(
76    population: Tensor<B, 2>,
77    sigmas: Tensor<B, 1>,
78    rng: &mut dyn Rng,
79    device: &<B as burn::tensor::backend::BackendTypes>::Device,
80) -> Tensor<B, 2> {
81    let [n, d] = population.dims();
82    let noise =
83        Tensor::<B, 2>::from_data(TensorData::new(standard_normal_rows(n, d, rng), [n, d]), device);
84    let sigmas_2d = sigmas.reshape([n, 1]).expand([n, d]);
85    population + noise * sigmas_2d
86}
87
88/// Uniform-reset mutation: replace each gene with a fresh sample from
89/// `U(lo, hi)` with probability `p`.
90///
91/// For each gene position, an independent Bernoulli trial with success
92/// probability `p` determines whether the gene is replaced by a draw from the
93/// uniform distribution `U(lo, hi)` or left at its current value.
94///
95/// `p = 0.0` is an identity (no genes mutated); `p = 1.0` reinitialises the
96/// entire population uniformly. This operator is suitable for integer-coded or
97/// bounded real-valued genomes where reinitialisation is preferable to additive
98/// noise.
99///
100/// All `n·d` uniform draws and `n·d` Bernoulli coin flips are taken from the
101/// caller-supplied host `rng` and loaded onto the device via
102/// [`Tensor::from_data`]; no backend-global RNG state is touched.
103///
104/// The input tensor must have shape `(N, D)`; the returned tensor has the same
105/// shape.
106#[must_use]
107pub fn uniform_reset<B: Backend>(
108    population: Tensor<B, 2>,
109    lo: f32,
110    hi: f32,
111    p: f32,
112    rng: &mut dyn Rng,
113    device: &<B as burn::tensor::backend::BackendTypes>::Device,
114) -> Tensor<B, 2> {
115    let [n, d] = population.dims();
116    let mut noise_rows = Vec::with_capacity(n * d);
117    let mut coin_rows = Vec::with_capacity(n * d);
118    for _ in 0..n * d {
119        noise_rows.push(lo + (hi - lo) * rng.random::<f32>());
120        coin_rows.push(rng.random::<f32>());
121    }
122    let noise = Tensor::<B, 2>::from_data(TensorData::new(noise_rows, [n, d]), device);
123    let coin = Tensor::<B, 2>::from_data(TensorData::new(coin_rows, [n, d]), device);
124    let reset = coin.lower_elem(p);
125    population.mask_where(reset, noise)
126}
127
128/// Bit-flip mutation on a binary `Tensor<B, 2, Int>` population.
129///
130/// Each gene is independently flipped with probability `p`. The flip is
131/// computed arithmetically as `1 − x`, so the input tensor must hold values
132/// exclusively in `{0, 1}`; any value outside that set will produce
133/// out-of-range results silently.
134///
135/// `p = 0.0` is an identity; `p = 1.0` inverts every gene. The conventional
136/// mutation rate for bit-string GAs is `1 / D` (one expected flip per
137/// individual), but this function does not enforce any particular rate.
138///
139/// All `n·d` Bernoulli coin flips are taken from the caller-supplied host
140/// `rng` and loaded onto the device via [`Tensor::from_data`]; no
141/// backend-global RNG state is touched.
142///
143/// The input tensor must have shape `(N, D)`; the returned tensor has the same
144/// shape and element type.
145#[must_use]
146pub fn bit_flip_mutation<B: Backend>(
147    population: Tensor<B, 2, Int>,
148    p: f32,
149    rng: &mut dyn Rng,
150    device: &<B as burn::tensor::backend::BackendTypes>::Device,
151) -> Tensor<B, 2, Int> {
152    let shape = population.shape();
153    let [n, d] = population.dims();
154    let mut coin_rows = Vec::with_capacity(n * d);
155    for _ in 0..n * d {
156        coin_rows.push(rng.random::<f32>());
157    }
158    let coin = Tensor::<B, 2>::from_data(TensorData::new(coin_rows, [n, d]), device);
159    let flip = coin.lower_elem(p);
160    // XOR via arithmetic: new = (1 - old) where flip, else old.
161    let ones = Tensor::<B, 2, Int>::ones(shape, device);
162    let flipped = ones - population.clone();
163    population.mask_where(flip, flipped)
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use burn::backend::Flex;
170    use burn::backend::flex::FlexDevice;
171    #[allow(unused_imports)]
172    use burn::tensor::backend::Backend as _;
173    use rand::SeedableRng;
174    use rand::rngs::StdRng;
175
176    type TestBackend = Flex;
177
178    #[test]
179    fn gaussian_with_zero_sigma_is_identity() {
180        let device: FlexDevice = Default::default();
181        let mut rng = StdRng::seed_from_u64(3);
182        let input = Tensor::<TestBackend, 2>::from_data(
183            TensorData::new(vec![1.0_f32, 2.0, 3.0, 4.0], [2, 2]),
184            &device,
185        );
186        let out = gaussian_mutation(input.clone(), 0.0, &mut rng, &device);
187        let before = input.into_data().into_vec::<f32>().unwrap();
188        let after = out.into_data().into_vec::<f32>().unwrap();
189        for (a, b) in before.iter().zip(after.iter()) {
190            approx::assert_relative_eq!(a, b, epsilon = 1e-6);
191        }
192    }
193
194    #[test]
195    fn gaussian_preserves_shape() {
196        let device: FlexDevice = Default::default();
197        let mut rng = StdRng::seed_from_u64(3);
198        let input = Tensor::<TestBackend, 2>::from_data(
199            TensorData::new(vec![0.0_f32; 12], [3, 4]),
200            &device,
201        );
202        let out = gaussian_mutation(input, 1.0, &mut rng, &device);
203        assert_eq!(out.dims(), [3, 4]);
204    }
205
206    #[test]
207    fn per_row_applies_distinct_sigmas() {
208        let device: FlexDevice = Default::default();
209        let mut rng = StdRng::seed_from_u64(4);
210        let input = Tensor::<TestBackend, 2>::from_data(
211            TensorData::new(vec![0.0_f32; 4], [2, 2]),
212            &device,
213        );
214        let sigmas = Tensor::<TestBackend, 1>::from_data(
215            TensorData::new(vec![0.0_f32, 0.0], [2]),
216            &device,
217        );
218        let out = gaussian_mutation_per_row(input, sigmas, &mut rng, &device);
219        let values = out.into_data().into_vec::<f32>().unwrap();
220        for v in values {
221            approx::assert_relative_eq!(v, 0.0, epsilon = 1e-6);
222        }
223    }
224
225    #[test]
226    fn uniform_reset_with_p_zero_is_identity() {
227        let device: FlexDevice = Default::default();
228        let mut rng = StdRng::seed_from_u64(9);
229        let input = Tensor::<TestBackend, 2>::from_data(
230            TensorData::new(vec![3.0_f32, 4.0, 5.0, 6.0], [2, 2]),
231            &device,
232        );
233        let out = uniform_reset(input.clone(), -10.0, 10.0, 0.0, &mut rng, &device);
234        let before = input.into_data().into_vec::<f32>().unwrap();
235        let after = out.into_data().into_vec::<f32>().unwrap();
236        for (a, b) in before.iter().zip(after.iter()) {
237            approx::assert_relative_eq!(a, b, epsilon = 1e-6);
238        }
239    }
240}