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