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}