optirustic/operators/
mutation.rs

1use rand::{Rng, RngCore};
2use serde::{Deserialize, Serialize};
3
4#[cfg(feature = "python")]
5use pyo3::prelude::*;
6
7use crate::core::{Individual, OError, Problem, VariableType, VariableValue};
8
9/// The trait to implement a mutation operator to modify the genetic material of an individual.
10pub trait Mutation {
11    /// Mutate a population individual.
12    ///
13    /// # Arguments
14    ///
15    /// * `individual`: The individual to mutate.
16    /// * `rng`: The random number generator.
17    ///
18    /// returns: `Result<Individual, OError>`. The mutated individual.
19    fn mutate_offspring(
20        &self,
21        individual: &Individual,
22        rng: &mut dyn RngCore,
23    ) -> Result<Individual, OError>;
24}
25
26/// Input arguments for [`PolynomialMutation`].
27#[cfg_attr(feature = "python", pyclass(get_all))]
28#[derive(Serialize, Deserialize, Clone)]
29pub struct PolynomialMutationArgs {
30    /// A user-defined parameter to control the mutation. This is eta_m in the paper, and it is
31    /// suggested its value to be in the [20, 100] range.
32    pub index_parameter: f64,
33    /// The probability of mutating a parent variable.
34    pub variable_probability: f64,
35}
36
37impl PolynomialMutationArgs {
38    /// Initialise the Polynomial mutation (PM) operator with the default parameters. With a
39    /// distribution index or index parameter of `20` and variable probability equal `1` divided by
40    /// the number of real variables in the problem (i.e. each variable will have the same
41    /// probability of being mutated).
42    ///
43    /// # Arguments
44    ///
45    /// * `problem`: THe problem being solved.
46    ///
47    /// returns: `Self`
48    pub fn default(problem: &Problem) -> Self {
49        let num_real_vars = problem
50            .variables()
51            .iter()
52            .filter(|(_, v)| v.is_real())
53            .count() as f64;
54        let variable_probability = 1.0 / num_real_vars;
55        Self {
56            index_parameter: 20.0,
57            variable_probability,
58        }
59    }
60}
61
62#[cfg(feature = "python")]
63#[pymethods]
64impl PolynomialMutationArgs {
65    #[new]
66    #[pyo3(signature = (variable_probability, index_parameter=None))]
67    fn new(variable_probability: f64, index_parameter: Option<f64>) -> Self {
68        PolynomialMutationArgs {
69            index_parameter: index_parameter.unwrap_or(20.0),
70            variable_probability,
71        }
72    }
73
74    pub fn __repr__(&self) -> PyResult<String> {
75        Ok(format!(
76            "PolynomialMutationArgs(index_parameter={}, variable_probability={})",
77            self.index_parameter, self.variable_probability
78        ))
79    }
80
81    fn __str__(&self) -> String {
82        self.__repr__().unwrap()
83    }
84}
85
86/// The Polynomial mutation (PM) operator.
87///
88/// Adapted from [Deb & Deb (2014)](https://dl.acm.org/doi/10.1504/IJAISC.2014.059280), full
89/// text available at <https://www.egr.msu.edu/~kdeb/papers/k2012016.pdf>.
90///
91/// # Integer support
92/// Since the original method does not provide support for integer variables, this has been added by
93/// using the approach proposed in:
94/// > Deep, Kusum & Singh, Krishna & Kansal, M. & Mohan, Chander. (2009). A real coded genetic
95/// > algorithm for solving integer and mixed integer optimization problems. Applied Mathematics
96/// > and Computation. 212. 505-518. 10.1016/j.amc.2009.02.044.
97///
98/// See the truncation procedure in section 2.4 in the [full text](https://www.researchgate.net/publication/220557819_A_real_coded_genetic_algorithm_for_solving_integer_and_mixed_integer_optimization_problems),
99/// where a probability of `0.5` is applied to ensure randomness in the integer crossover.
100///
101/// # Example
102///
103/// ```
104/// use std::error::Error;
105/// use optirustic::core::{BoundedNumber, Individual, Problem, VariableType, VariableValue,
106/// Objective, Constraint, ObjectiveDirection, RelationalOperator, EvaluationResult, Evaluator};
107/// use optirustic::operators::{Crossover, PolynomialMutationArgs, PolynomialMutation, Mutation};
108/// use std::sync::Arc;
109/// use rand_chacha::ChaCha8Rng;
110/// use rand::SeedableRng;
111///
112/// fn main() -> Result<(), Box<dyn Error>> {
113///     // create a new one-variable
114///     let objectives = vec![Objective::new("obj1", ObjectiveDirection::Minimise)];
115///     let variables = vec![
116///         VariableType::Real(BoundedNumber::new("var1", 0.0, 1000.0)?),
117///         VariableType::Integer(BoundedNumber::new("var2", -1, 1)?)
118///     ];
119///     
120///     // dummy evaluator function
121///     #[derive(Debug)]
122///     struct UserEvaluator;
123///     impl Evaluator for UserEvaluator {
124///         fn evaluate(&self, _: &Individual) -> Result<EvaluationResult, Box<dyn Error>> {
125///             Ok(EvaluationResult {
126///                 constraints: Default::default(),
127///                 objectives: Default::default(),
128///             })
129///         }
130///     }
131///     let problem = Arc::new(Problem::new(objectives, variables, None, Box::new(UserEvaluator))?);
132///
133///     // add new individuals
134///     let mut a = Individual::new(problem.clone());
135///     a.update_variable("var1", VariableValue::Real(0.2))?;
136///     a.update_variable("var2", VariableValue::Integer(1))?;
137///
138///     // crossover
139///     let parameters = PolynomialMutationArgs {
140///         index_parameter: 1.0 ,
141///         variable_probability: 1.0,
142///     };
143///     let pm = PolynomialMutation::new(parameters)?;
144///     let mut rng = ChaCha8Rng::from_seed(Default::default());
145///     let out = pm.mutate_offspring(&a, &mut rng)?;
146///     println!("{}", out);
147///     Ok(())
148/// }
149/// ```
150pub struct PolynomialMutation {
151    /// The user-defined parameter to control the mutation.
152    index_parameter: f64,
153    /// The probability of mutating a parent variable.
154    variable_probability: f64,
155}
156
157impl PolynomialMutation {
158    /// Initialise the Polynomial mutation (PM) operator. This returns an error if the probability
159    /// is outside the [0, 1] range.
160    ///
161    /// # Arguments
162    ///
163    /// * `index_parameter`:
164    /// * `variable_probability`: The probability of mutating a parent variable.
165    ///
166    /// returns: `Result<PolynomialMutation, OError>`
167    pub fn new(args: PolynomialMutationArgs) -> Result<Self, OError> {
168        if !(0.0..=1.0).contains(&args.variable_probability) {
169            return Err(OError::MutationOperator(
170                "PolynomialMutation".to_string(),
171                format!(
172                    "The variable probability {} must be a number between 0 and 1",
173                    args.variable_probability
174                ),
175            ));
176        }
177        Ok(Self {
178            index_parameter: args.index_parameter,
179            variable_probability: args.variable_probability,
180        })
181    }
182
183    /// Perform the mutation of a real variable for an offspring.
184    ///
185    /// # Arguments
186    ///
187    /// * `y`: The real variable value to mutate.
188    /// * `y_lower`: The variable lower bound.
189    /// * `y_upper`: The variable lower bound.
190    /// * `rng`: The random number generator reference.
191    ///
192    /// returns: `f64`
193    fn mutate_variable(&self, y: f64, y_lower: f64, y_upper: f64, rng: &mut dyn RngCore) -> f64 {
194        let delta_y = y_upper - y_lower;
195        let prob = rng.random_range(0.0..=1.0);
196
197        // this is delta_l or delta_r
198        let delta = if prob <= 0.5 {
199            let bl = (y - y_lower) / delta_y;
200            let b =
201                2.0 * prob + (1.0 - 2.0 * prob) * f64::powf(1.0 - bl, self.index_parameter + 1.0);
202            f64::powf(b, 1.0 / (self.index_parameter + 1.0)) - 1.0
203        } else {
204            let bu = (y_upper - y) / delta_y;
205            let b = 2.0 * (1.0 - prob)
206                + 2.0 * (prob - 0.5) * f64::powf(1.0 - bu, self.index_parameter + 1.0);
207            1.0 - f64::powf(b, 1.0 / (self.index_parameter + 1.0))
208        };
209
210        // adjust the variable
211        let new_y = y + delta * delta_y;
212        f64::min(f64::max(new_y, y_lower), y_upper)
213    }
214}
215
216impl Mutation for PolynomialMutation {
217    fn mutate_offspring(
218        &self,
219        individual: &Individual,
220        rng: &mut dyn RngCore,
221    ) -> Result<Individual, OError> {
222        let mut mutated_individual = individual.clone_variables();
223        let problem = individual.problem();
224
225        // return error if variable is not a number
226        if !problem
227            .variables()
228            .iter()
229            .all(|(_, v)| v.is_real() | v.is_integer())
230        {
231            return Err(OError::CrossoverOperator(
232                "PolynomialMutation".to_string(),
233                "The PM operator only works with real or integer variables".to_string(),
234            ));
235        }
236
237        // do not apply crossover if probability is not reached
238        for (var_name, var_type) in problem.variables() {
239            if rng.random_range(0.0..=1.0) <= self.variable_probability {
240                let y = individual.get_variable_value(&var_name)?;
241                if let (VariableValue::Real(y), VariableType::Real(vt)) = (y, &var_type) {
242                    let (y_lower, y_upper) = vt.bounds();
243                    let new_y = self.mutate_variable(*y, y_lower, y_upper, rng);
244                    mutated_individual.update_variable(&var_name, VariableValue::Real(new_y))?;
245                } else if let (VariableValue::Integer(y), VariableType::Integer(vt)) = (y, var_type)
246                {
247                    let (y_lower, y_upper) = vt.bounds();
248                    let new_y =
249                        self.mutate_variable(*y as f64, y_lower as f64, y_upper as f64, rng);
250
251                    // truncate
252                    let mut new_y = new_y.trunc() as i64;
253                    if rng.random_range(0.0..=1.0) < 0.5 {
254                        new_y += 1;
255                        // ensure the new integer is below the variable upper bound
256                        new_y = new_y.min(y_upper);
257                    }
258                    mutated_individual.update_variable(&var_name, VariableValue::Integer(new_y))?;
259                }
260            }
261        }
262
263        Ok(mutated_individual)
264    }
265}
266
267#[cfg(test)]
268mod test {
269    use std::sync::Arc;
270
271    use crate::core::utils::{dummy_evaluator, get_rng};
272    use crate::core::{
273        BoundedNumber, Individual, Objective, ObjectiveDirection, Problem, VariableType,
274        VariableValue,
275    };
276    use crate::operators::{Mutation, PolynomialMutation, PolynomialMutationArgs};
277
278    #[test]
279    /// Test that the PM operator mutates variables
280    fn test_sbx_crossover() {
281        let objectives = vec![Objective::new("obj1", ObjectiveDirection::Minimise)];
282
283        let variables = vec![
284            VariableType::Real(BoundedNumber::new("var1", 0.0, 1000.0).unwrap()),
285            VariableType::Integer(BoundedNumber::new("var2", -10, 20).unwrap()),
286        ];
287
288        let problem =
289            Arc::new(Problem::new(objectives, variables, None, dummy_evaluator()).unwrap());
290
291        // add new individuals
292        let mut a = Individual::new(problem.clone());
293        a.update_variable("var1", VariableValue::Real(0.2)).unwrap();
294        a.update_variable("var2", VariableValue::Integer(0))
295            .unwrap();
296        let mut b = Individual::new(problem.clone());
297        b.update_variable("var1", VariableValue::Real(0.8)).unwrap();
298        b.update_variable("var2", VariableValue::Integer(3))
299            .unwrap();
300
301        let args = PolynomialMutationArgs {
302            // ensure different variable value (with integers)
303            index_parameter: 1.0,
304            // always force mutation
305            variable_probability: 1.0,
306        };
307        let pm = PolynomialMutation::new(args).unwrap();
308        let mut rng = get_rng(Some(1));
309        let mutated_offspring = pm.mutate_offspring(&a, &mut rng).unwrap();
310
311        // Mutation always performed because variable_probability is 1
312        assert_ne!(
313            *mutated_offspring.get_variable_value("var1").unwrap(),
314            VariableValue::Real(0.2)
315        );
316        assert_ne!(
317            *mutated_offspring.get_variable_value("var2").unwrap(),
318            VariableValue::Integer(0)
319        );
320    }
321}