optirustic/operators/
crossover.rs

1use rand::prelude::IndexedRandom;
2use rand::{Rng, RngCore};
3
4use serde::{Deserialize, Serialize};
5
6#[cfg(feature = "python")]
7use pyo3::prelude::*;
8
9use crate::core::{Individual, OError, VariableType, VariableValue};
10
11/// Struct containing the offsprings from the crossover operation.
12#[derive(Debug)]
13pub struct CrossoverChildren {
14    /// The first generated child.
15    pub child1: Individual,
16    /// The second generated child.
17    pub child2: Individual,
18}
19
20/// Trait to define a crossover operator to generate a new child by recombining the genetic
21/// material of two parents.
22pub trait Crossover {
23    /// Generate two children from their parents.
24    ///
25    /// # Arguments
26    ///
27    /// * `parent1`: The first parent to use for mating.
28    /// * `parent2`: The second parent to use for mating.
29    /// * `rng`: The random number generator.
30    ///
31    /// returns: `Result<CrossoverChildren, OError>`.
32    fn generate_offsprings(
33        &self,
34        parent1: &Individual,
35        parent2: &Individual,
36        rng: &mut dyn RngCore,
37    ) -> Result<CrossoverChildren, OError>;
38}
39
40/// Input arguments for [`SimulatedBinaryCrossover`].
41#[cfg_attr(feature = "python", pyclass(get_all))]
42#[derive(Serialize, Deserialize, Clone)]
43pub struct SimulatedBinaryCrossoverArgs {
44    /// The distribution index for crossover (this is the eta_c in the paper). This directly
45    /// control the spread of children. If a large value is selected, the resulting children will
46    /// have a higher probability of being close to their parents; a small value generates distant
47    /// offsprings.
48    pub distribution_index: f64,
49    /// The probability that the parents participate in the crossover. If 1.0, the parents always
50    /// participate in the crossover. If the probability is lower, then the children are the exact
51    /// clones of their parents (i.e. all the variable values do not change).
52    pub crossover_probability: f64,
53    /// The probability that a variable belonging to both parents is used in the crossover. The
54    /// paper uses 0.5, meaning that  each variable in a solution has a 50% chance of changing its
55    /// value.
56    pub variable_probability: f64,
57}
58
59impl Default for SimulatedBinaryCrossoverArgs {
60    /// Default parameters for the Simulated Binary Crossover (SBX) with a distribution index of
61    /// 15, crossover probability of `1` and variable probability of `0.5`.
62    fn default() -> Self {
63        Self {
64            distribution_index: 15.0,
65            crossover_probability: 1.0,
66            variable_probability: 0.5,
67        }
68    }
69}
70
71#[cfg(feature = "python")]
72#[pymethods]
73impl SimulatedBinaryCrossoverArgs {
74    #[new]
75    #[pyo3(signature = (distribution_index=None, crossover_probability=None, variable_probability=None))]
76    fn new(
77        distribution_index: Option<f64>,
78        crossover_probability: Option<f64>,
79        variable_probability: Option<f64>,
80    ) -> Self {
81        let defaults = SimulatedBinaryCrossoverArgs::default();
82        SimulatedBinaryCrossoverArgs {
83            distribution_index: distribution_index.unwrap_or(defaults.distribution_index),
84            crossover_probability: crossover_probability.unwrap_or(defaults.crossover_probability),
85            variable_probability: variable_probability.unwrap_or(defaults.variable_probability),
86        }
87    }
88
89    pub fn __repr__(&self) -> PyResult<String> {
90        Ok(format!(
91            "SimulatedBinaryCrossoverArgs(distribution_index={}, crossover_probability={}, variable_probability={})",
92            self.distribution_index, self.crossover_probability, self.variable_probability
93        ))
94    }
95
96    fn __str__(&self) -> String {
97        self.__repr__().unwrap()
98    }
99}
100
101/// Simulated Binary Crossover (SBX) operator for bounded real or integer variables.
102///
103/// Implemented based on:
104/// > Kalyanmoy Deb, Karthik Sindhya, and Tatsuya Okabe. 2007. Self-adaptive
105/// > simulated binary crossover for real-parameter optimization. In Proceedings of the 9th annual
106/// > conference on Genetic and evolutionary computation (GECCO '07). Association for Computing
107/// > Machinery, New York, NY, USA, 1187–1194. <https://doi.org/10.1145/1276958.1277190>
108///
109/// and
110/// > the C implementation available at <https://gist.github.com/Tiagoperes/1779d5f1c89bae0cfdb87b1960bba36d>
111/// > to account for bounded variables.
112///
113/// See: <https://doi.org/10.1145/1276958.1277190>,
114/// full text available at <https://content.wolfram.com/sites/13/2018/02/09-2-2.pdf>. An alternative
115/// shorter description of the algorithm is available in [Deb et al. (2007)](https://www.researchgate.net/publication/220742263_Self-adaptive_simulated_binary_crossover_for_real-parameter_optimization).
116///
117/// # Integer support
118/// Since the original method does not provide support for integer variables, this has been added by
119/// using the approach proposed in:
120/// > Deep, Kusum & Singh, Krishna & Kansal, M. & Mohan, Chander. (2009). A real coded genetic
121/// > algorithm for solving integer and mixed integer optimization problems. Applied Mathematics
122/// > and Computation. 212. 505-518. 10.1016/j.amc.2009.02.044.
123///
124/// 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),
125/// where a probability of `0.5` is applied to ensure randomness in the integer crossover.
126///
127/// # Example
128///
129/// ```
130/// use std::error::Error;
131/// use optirustic::core::{BoundedNumber, Individual, Problem, VariableType, VariableValue,
132/// Objective, Constraint, ObjectiveDirection, RelationalOperator, EvaluationResult, Evaluator};
133/// use optirustic::operators::{Crossover, SimulatedBinaryCrossover, SimulatedBinaryCrossoverArgs};
134/// use std::sync::Arc;
135/// use rand_chacha::ChaCha8Rng;
136/// use rand::SeedableRng;
137///
138/// fn main() -> Result<(), Box<dyn Error>> {
139///     // create a new one-variable problem
140///     let objectives = vec![Objective::new("obj1", ObjectiveDirection::Minimise)];
141///     let variables = vec![VariableType::Real(BoundedNumber::new("var1", 0.0, 1000.0)?)];
142///     let constraints = vec![Constraint::new("c1", RelationalOperator::EqualTo, 1.0)];
143///     
144///     // dummy evaluator function
145///     #[derive(Debug)]
146///     struct UserEvaluator;
147///     impl Evaluator for UserEvaluator {
148///         fn evaluate(&self, _: &Individual) -> Result<EvaluationResult, Box<dyn Error>> {
149///             Ok(EvaluationResult {
150///                 constraints: Default::default(),
151///                 objectives: Default::default(),
152///             })
153///         }
154///     }
155///     let problem = Arc::new(Problem::new(objectives, variables, Some(constraints), Box::new(UserEvaluator))?);
156///
157///     // add new individuals
158///     let mut a = Individual::new(problem.clone());
159///     a.update_variable("var1", VariableValue::Real(0.2))?;
160///     let mut b = Individual::new(problem.clone());
161///     b.update_variable("var1", VariableValue::Real(0.8))?;
162///
163///     // crossover
164///     let parameters = SimulatedBinaryCrossoverArgs {
165///         distribution_index: 1.0,
166///         crossover_probability:1.0,
167///         variable_probability:0.5
168///     };
169///     let sbx = SimulatedBinaryCrossover::new(parameters)?;
170///     let mut rng = ChaCha8Rng::from_seed(Default::default());
171///     let out = sbx.generate_offsprings(&a, &b, &mut rng)?;
172///     println!("{} - {}", out.child1, out.child2);
173///     Ok(())
174/// }
175/// ```
176pub struct SimulatedBinaryCrossover {
177    /// The distribution index for crossover. This is the eta_c in the paper.
178    distribution_index: f64,
179    /// The probability that the parents participate in the crossover. If 1.0, the parents always
180    /// participate in the crossover.
181    crossover_probability: f64,
182    /// The probability that a variable belonging to both parents is used in the crossover.
183    variable_probability: f64,
184}
185
186impl SimulatedBinaryCrossover {
187    /// Initialise the Simulated Binary Crossover (SBX) operator for bounded real and integer
188    /// variables.
189    ///
190    /// # Arguments
191    ///
192    /// * `args.`: The operator input parameters. See [`SimulatedBinaryCrossoverArgs`] for a detail
193    ///    explanation of the parameters.
194    ///
195    /// returns: `Result<SBX, OError>`
196    pub fn new(args: SimulatedBinaryCrossoverArgs) -> Result<Self, OError> {
197        if args.distribution_index < 0.0 {
198            return Err(OError::CrossoverOperator(
199                "SBX".to_string(),
200                format!(
201                    "The distribution index {} must be a positive number",
202                    args.distribution_index
203                ),
204            ));
205        }
206        if !(0.0..=1.0).contains(&args.crossover_probability) {
207            return Err(OError::CrossoverOperator(
208                "SBX".to_string(),
209                format!(
210                    "The crossover probability {} must be a number between 0 and 1",
211                    args.crossover_probability
212                ),
213            ));
214        }
215        if !(0.0..=1.0).contains(&args.variable_probability) {
216            return Err(OError::CrossoverOperator(
217                "SBX".to_string(),
218                format!(
219                    "The variable probability {} must be a number between 0 and 1",
220                    args.variable_probability
221                ),
222            ));
223        }
224
225        Ok(Self {
226            distribution_index: args.distribution_index,
227            variable_probability: args.variable_probability,
228            crossover_probability: args.crossover_probability,
229        })
230    }
231
232    /// Perform the crossover for two real variables from two parents.
233    ///
234    /// # Arguments
235    ///
236    /// * `v1`: The real variable value from the first parent.
237    /// * `v2`: The real variable value from the first parent.
238    /// * `y_lower`: The variable lower bound.
239    /// * `y_upper`: The variable lower bound.
240    /// * `rng`: The random number generator reference.
241    ///
242    /// returns: `Option<(f64, f64)>`. This return two value pairs to assign to the children being
243    /// created during the crossover. If the difference between the two parent's value is too small
244    /// `None` is returned and no crossover is performed.
245    fn crossover_variables(
246        &self,
247        v1: f64,
248        v2: f64,
249        y_lower: f64,
250        y_upper: f64,
251        rng: &mut dyn RngCore,
252    ) -> Option<(f64, f64)> {
253        // do not perform crossover if variables have the same value
254        if f64::abs(v1 - v2) < f64::EPSILON {
255            return None;
256        }
257
258        // get the lowest value between the two parent
259        let (y1, y2) = if v1 < v2 { (v1, v2) } else { (v2, v1) };
260        let delta_y = y2 - y1;
261        let prob = rng.random_range(0.0..=1.0);
262
263        // first child
264        let beta = 1.0 + (2.0 * (y1 - y_lower) / delta_y);
265        let alpha = 2.0 - f64::powf(beta, -(self.distribution_index + 1.0));
266        let mut new_v1 = 0.5 * ((y1 + y2) - self.betaq(prob, alpha) * delta_y);
267        // make sure value is within bounds
268        new_v1 = f64::min(f64::max(new_v1, y_lower), y_upper);
269
270        // second child
271        let beta = 1.0 + (2.0 * (y_upper - y2) / delta_y);
272        let alpha = 2.0 - f64::powf(beta, -(self.distribution_index + 1.0));
273        let mut new_v2 = 0.5 * ((y1 + y2) + self.betaq(prob, alpha) * delta_y);
274        // make sure value is within bounds
275        new_v2 = f64::min(f64::max(new_v2, y_lower), y_upper);
276
277        // randomly swap the values
278        if matches!([0, 1].choose(rng).unwrap(), 0) {
279            (new_v1, new_v2) = (new_v2, new_v1);
280        }
281        Some((new_v1, new_v2))
282    }
283
284    /// Calculate the betaq coefficient.
285    ///
286    /// # Arguments
287    ///
288    /// * `prob`: The probability.
289    /// * `alpha`: The alpha coefficient.
290    ///
291    /// returns: `f64`
292    fn betaq(&self, prob: f64, alpha: f64) -> f64 {
293        if prob <= (1.0 / alpha) {
294            f64::powf(prob * alpha, 1.0 / (self.distribution_index + 1.0))
295        } else {
296            f64::powf(
297                1.0 / (2.0 - prob * alpha),
298                1.0 / (self.distribution_index + 1.0),
299            )
300        }
301    }
302}
303
304impl Crossover for SimulatedBinaryCrossover {
305    fn generate_offsprings(
306        &self,
307        parent1: &Individual,
308        parent2: &Individual,
309        rng: &mut dyn RngCore,
310    ) -> Result<CrossoverChildren, OError> {
311        let mut child1 = parent1.clone_variables();
312        let mut child2 = parent2.clone_variables();
313        let problem = parent1.problem();
314
315        // return error if variable is not a number
316        if !problem
317            .variables()
318            .iter()
319            .all(|(_, v)| v.is_real() | v.is_integer())
320        {
321            return Err(OError::CrossoverOperator(
322                "SBX".to_string(),
323                "The SBX operator only works with real or integer variables".to_string(),
324            ));
325        }
326
327        // do not apply crossover if probability is not reached
328        if rng.random_range(0.0..=1.0) <= self.crossover_probability {
329            for (var_name, var_type) in problem.variables() {
330                // each variable in a solution has a `self.variable_probability` chance of changing
331                // its value
332                if rng.random_range(0.0..=1.0) > self.variable_probability {
333                    continue;
334                }
335
336                // do not process non-number variables
337                let v1 = parent1.get_variable_value(&var_name)?;
338                let v2 = parent2.get_variable_value(&var_name)?;
339                if let (VariableValue::Real(v1), VariableValue::Real(v2), VariableType::Real(vt)) =
340                    (v1, v2, &var_type)
341                {
342                    let (y_lower, y_upper) = vt.bounds();
343                    match self.crossover_variables(*v1, *v2, y_lower, y_upper, rng) {
344                        None => continue,
345                        Some((new_v1, new_v2)) => {
346                            // update the children
347                            child1.update_variable(&var_name, VariableValue::Real(new_v1))?;
348                            child2.update_variable(&var_name, VariableValue::Real(new_v2))?;
349                        }
350                    };
351                } else if let (
352                    VariableValue::Integer(v1),
353                    VariableValue::Integer(v2),
354                    VariableType::Integer(vt),
355                ) = (v1, v2, var_type)
356                {
357                    let (y_lower, y_upper) = vt.bounds();
358                    match self.crossover_variables(
359                        *v1 as f64,
360                        *v2 as f64,
361                        y_lower as f64,
362                        y_upper as f64,
363                        rng,
364                    ) {
365                        None => continue,
366                        Some((new_v1, new_v2)) => {
367                            // truncation procedure for integers. Get the integer part then get same
368                            // or +1 with a probability threshold of 0.5 to add randomness.
369                            let mut new_v1 = new_v1.trunc() as i64;
370                            if rng.random_range(0.0..=1.0) < 0.5 {
371                                new_v1 += 1;
372                                // ensure the new integer is below the variable upper bound
373                                new_v1 = new_v1.min(y_upper);
374                            }
375                            let mut new_v2 = new_v2.trunc() as i64;
376                            if rng.random_range(0.0..=1.0) < 0.5 {
377                                new_v2 += 1;
378                                // ensure the new integer is below the variable upper bound
379                                new_v2 = new_v2.min(y_upper);
380                            }
381
382                            // update the children
383                            child1.update_variable(&var_name, VariableValue::Integer(new_v1))?;
384                            child2.update_variable(&var_name, VariableValue::Integer(new_v2))?;
385                        }
386                    };
387                }
388            }
389        }
390
391        Ok(CrossoverChildren { child1, child2 })
392    }
393}
394
395#[cfg(test)]
396mod test {
397    use std::sync::Arc;
398
399    use crate::core::utils::{dummy_evaluator, get_rng};
400    use crate::core::{
401        BoundedNumber, Individual, Objective, ObjectiveDirection, Problem, VariableType,
402        VariableValue,
403    };
404    use crate::operators::{Crossover, SimulatedBinaryCrossover, SimulatedBinaryCrossoverArgs};
405
406    #[test]
407    /// Check that the input arguments to SBX operator are valid.
408    fn test_new_sbx_panic() {
409        assert!(SimulatedBinaryCrossover::new(SimulatedBinaryCrossoverArgs {
410            distribution_index: -2.0,
411            crossover_probability: 1.0,
412            variable_probability: 0.5,
413        })
414        .is_err());
415        assert!(SimulatedBinaryCrossover::new(SimulatedBinaryCrossoverArgs {
416            distribution_index: 1.0,
417            crossover_probability: 2.0,
418            variable_probability: 0.5,
419        })
420        .is_err());
421        assert!(SimulatedBinaryCrossover::new(SimulatedBinaryCrossoverArgs {
422            distribution_index: 1.0,
423            crossover_probability: 1.0,
424            variable_probability: -0.5,
425        })
426        .is_err());
427    }
428
429    #[test]
430    /// Test that the SBX operator generates variables
431    fn test_sbx_crossover() {
432        let objectives = vec![Objective::new("obj1", ObjectiveDirection::Minimise)];
433
434        let variables = vec![
435            VariableType::Real(BoundedNumber::new("var1", 0.0, 1000.0).unwrap()),
436            VariableType::Integer(BoundedNumber::new("var2", -10, 20).unwrap()),
437        ];
438
439        let problem =
440            Arc::new(Problem::new(objectives, variables, None, dummy_evaluator()).unwrap());
441
442        // add new individuals
443        let mut a = Individual::new(problem.clone());
444        a.update_variable("var1", VariableValue::Real(0.2)).unwrap();
445        a.update_variable("var2", VariableValue::Integer(0))
446            .unwrap();
447        let mut b = Individual::new(problem.clone());
448        b.update_variable("var1", VariableValue::Real(0.8)).unwrap();
449        b.update_variable("var2", VariableValue::Integer(3))
450            .unwrap();
451
452        // crossover
453        let parameters = SimulatedBinaryCrossoverArgs {
454            // ensure different variable value (with integers)
455            distribution_index: 1.0,
456            crossover_probability: 1.0,
457            // always force crossover
458            variable_probability: 1.0,
459        };
460        let sbx = SimulatedBinaryCrossover::new(parameters).unwrap();
461        // seed to try reproducing test results
462        let mut rng = get_rng(Some(20000));
463        let out = sbx.generate_offsprings(&a, &b, &mut rng).unwrap();
464
465        // Crossover always performed because variable_probability is 1
466        assert_ne!(
467            *out.child1.get_variable_value("var1").unwrap(),
468            VariableValue::Real(0.2)
469        );
470        assert_ne!(
471            *out.child1.get_variable_value("var2").unwrap(),
472            VariableValue::Integer(0)
473        );
474        assert_ne!(
475            *out.child2.get_variable_value("var1").unwrap(),
476            VariableValue::Real(0.8)
477        );
478        assert_ne!(
479            *out.child2.get_variable_value("var2").unwrap(),
480            VariableValue::Integer(3)
481        );
482    }
483}