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//! Tensor ops already compose nicely with Burn's JIT fuser, so these
6//! functions are just thin wrappers around `randn_like`-style patterns.
7
8use burn::tensor::{backend::Backend, Distribution, Int, Tensor};
9
10/// Isotropic Gaussian mutation with scalar σ.
11///
12/// Each gene is perturbed by `σ · N(0, 1)` noise, independently.
13#[must_use]
14pub fn gaussian_mutation<B: Backend>(
15    population: Tensor<B, 2>,
16    sigma: f32,
17    device: &B::Device,
18) -> Tensor<B, 2> {
19    let shape = population.shape();
20    let noise = Tensor::<B, 2>::random(shape, Distribution::Normal(0.0, 1.0), device);
21    population + noise.mul_scalar(sigma)
22}
23
24/// Per-row anisotropic Gaussian mutation.
25///
26/// `sigmas` is a `(N,)` tensor holding the σ for each individual; the
27/// noise tensor is multiplied row-wise before being added to the
28/// population.
29///
30/// # Panics
31///
32/// Panics if `sigmas`'s length does not match the population's first
33/// dimension (the `reshape([n, 1])` step requires exactly `n` σ values).
34#[must_use]
35pub fn gaussian_mutation_per_row<B: Backend>(
36    population: Tensor<B, 2>,
37    sigmas: Tensor<B, 1>,
38    device: &B::Device,
39) -> Tensor<B, 2> {
40    let shape = population.shape();
41    let n = shape.dims[0];
42    let d = shape.dims[1];
43    let noise = Tensor::<B, 2>::random(shape, Distribution::Normal(0.0, 1.0), device);
44    let sigmas_2d = sigmas.reshape([n, 1]).expand([n, d]);
45    population + noise * sigmas_2d
46}
47
48/// Uniform-reset mutation with per-gene probability `p`.
49///
50/// Each gene is replaced by a draw from `U(lo, hi)` with probability
51/// `p`; otherwise it is left unchanged.
52#[must_use]
53pub fn uniform_reset<B: Backend>(
54    population: Tensor<B, 2>,
55    lo: f32,
56    hi: f32,
57    p: f32,
58    device: &B::Device,
59) -> Tensor<B, 2> {
60    let shape = population.shape();
61    let noise =
62        Tensor::<B, 2>::random(shape.clone(), Distribution::Uniform(f64::from(lo), f64::from(hi)), device);
63    let coin = Tensor::<B, 2>::random(shape, Distribution::Uniform(0.0, 1.0), device);
64    let reset = coin.lower_elem(p);
65    population.mask_where(reset, noise)
66}
67
68/// Bit-flip mutation on a binary `Tensor<B, 2, Int>` population.
69///
70/// Each gene is flipped independently with probability `p`. The input
71/// must hold values in `{0, 1}`; the flip is computed arithmetically
72/// as `1 − x` and will produce out-of-range values if that contract is
73/// violated.
74#[must_use]
75pub fn bit_flip_mutation<B: Backend>(
76    population: Tensor<B, 2, Int>,
77    p: f32,
78    device: &B::Device,
79) -> Tensor<B, 2, Int> {
80    let shape = population.shape();
81    let coin = Tensor::<B, 2>::random(shape.clone(), Distribution::Uniform(0.0, 1.0), device);
82    let flip = coin.lower_elem(p);
83    // XOR via arithmetic: new = (1 - old) where flip, else old.
84    let ones = Tensor::<B, 2, Int>::ones(shape, device);
85    let flipped = ones - population.clone();
86    population.mask_where(flip, flipped)
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use burn::backend::NdArray;
93    use burn::backend::ndarray::NdArrayDevice;
94    #[allow(unused_imports)]
95    use burn::tensor::backend::Backend as _;
96    use burn::tensor::TensorData;
97
98    type TestBackend = NdArray;
99
100    #[test]
101    fn gaussian_with_zero_sigma_is_identity() {
102        let device: NdArrayDevice = Default::default();
103        TestBackend::seed(&device, 3);
104        let input = Tensor::<TestBackend, 2>::from_data(
105            TensorData::new(vec![1.0_f32, 2.0, 3.0, 4.0], [2, 2]),
106            &device,
107        );
108        let out = gaussian_mutation(input.clone(), 0.0, &device);
109        let before = input.into_data().into_vec::<f32>().unwrap();
110        let after = out.into_data().into_vec::<f32>().unwrap();
111        for (a, b) in before.iter().zip(after.iter()) {
112            approx::assert_relative_eq!(a, b, epsilon = 1e-6);
113        }
114    }
115
116    #[test]
117    fn gaussian_preserves_shape() {
118        let device: NdArrayDevice = Default::default();
119        TestBackend::seed(&device, 3);
120        let input = Tensor::<TestBackend, 2>::from_data(
121            TensorData::new(vec![0.0_f32; 12], [3, 4]),
122            &device,
123        );
124        let out = gaussian_mutation(input, 1.0, &device);
125        assert_eq!(out.shape().dims, vec![3, 4]);
126    }
127
128    #[test]
129    fn per_row_applies_distinct_sigmas() {
130        let device: NdArrayDevice = Default::default();
131        TestBackend::seed(&device, 4);
132        let input = Tensor::<TestBackend, 2>::from_data(
133            TensorData::new(vec![0.0_f32; 4], [2, 2]),
134            &device,
135        );
136        let sigmas = Tensor::<TestBackend, 1>::from_data(
137            TensorData::new(vec![0.0_f32, 0.0], [2]),
138            &device,
139        );
140        let out = gaussian_mutation_per_row(input, sigmas, &device);
141        let values = out.into_data().into_vec::<f32>().unwrap();
142        for v in values {
143            approx::assert_relative_eq!(v, 0.0, epsilon = 1e-6);
144        }
145    }
146
147    #[test]
148    fn uniform_reset_with_p_zero_is_identity() {
149        let device: NdArrayDevice = Default::default();
150        TestBackend::seed(&device, 9);
151        let input = Tensor::<TestBackend, 2>::from_data(
152            TensorData::new(vec![3.0_f32, 4.0, 5.0, 6.0], [2, 2]),
153            &device,
154        );
155        let out = uniform_reset(input.clone(), -10.0, 10.0, 0.0, &device);
156        let before = input.into_data().into_vec::<f32>().unwrap();
157        let after = out.into_data().into_vec::<f32>().unwrap();
158        for (a, b) in before.iter().zip(after.iter()) {
159            approx::assert_relative_eq!(a, b, epsilon = 1e-6);
160        }
161    }
162}