Custom Operators
This guide shows how to create custom selection, crossover, and mutation operators.
Selection Operators
Selection chooses parents for reproduction based on fitness.
Trait Definition
pub trait SelectionOperator<G>: Send + Sync {
/// Select an individual from the population
/// Returns the index of the selected individual
fn select<R: Rng>(
&self,
population: &[(G, f64)], // (genome, fitness) pairs
rng: &mut R,
) -> usize;
}
Example: Boltzmann Selection
Temperature-controlled selection pressure:
use fugue_evo::prelude::*;
pub struct BoltzmannSelection {
temperature: f64,
}
impl BoltzmannSelection {
pub fn new(temperature: f64) -> Self {
Self { temperature }
}
}
impl<G> SelectionOperator<G> for BoltzmannSelection {
fn select<R: Rng>(&self, population: &[(G, f64)], rng: &mut R) -> usize {
// Compute Boltzmann probabilities
let max_fitness = population.iter()
.map(|(_, f)| *f)
.fold(f64::NEG_INFINITY, f64::max);
let weights: Vec<f64> = population.iter()
.map(|(_, f)| ((f - max_fitness) / self.temperature).exp())
.collect();
let total: f64 = weights.iter().sum();
// Roulette wheel selection
let mut target = rng.gen::<f64>() * total;
for (i, w) in weights.iter().enumerate() {
target -= w;
if target <= 0.0 {
return i;
}
}
population.len() - 1
}
}
Usage:
let selection = BoltzmannSelection::new(1.0); // Higher temp = more random
Crossover Operators
Crossover combines two parents to create offspring.
Trait Definition
pub trait CrossoverOperator<G>: Send + Sync {
type Output;
fn crossover<R: Rng>(
&self,
parent1: &G,
parent2: &G,
rng: &mut R,
) -> Self::Output;
}
Example: Blend Crossover (BLX-α)
Creates offspring in an expanded range around parents:
pub struct BlendCrossover {
alpha: f64,
}
impl BlendCrossover {
pub fn new(alpha: f64) -> Self {
Self { alpha }
}
}
impl CrossoverOperator<RealVector> for BlendCrossover {
type Output = CrossoverResult<RealVector>;
fn crossover<R: Rng>(
&self,
parent1: &RealVector,
parent2: &RealVector,
rng: &mut R,
) -> Self::Output {
let g1 = parent1.genes();
let g2 = parent2.genes();
let mut child1_genes = Vec::with_capacity(g1.len());
let mut child2_genes = Vec::with_capacity(g1.len());
for i in 0..g1.len() {
let min_val = g1[i].min(g2[i]);
let max_val = g1[i].max(g2[i]);
let range = max_val - min_val;
// Expanded range: [min - α*range, max + α*range]
let low = min_val - self.alpha * range;
let high = max_val + self.alpha * range;
child1_genes.push(rng.gen_range(low..=high));
child2_genes.push(rng.gen_range(low..=high));
}
CrossoverResult::new(
RealVector::new(child1_genes),
RealVector::new(child2_genes),
)
}
}
Usage:
let crossover = BlendCrossover::new(0.5); // α = 0.5 is common
Bounded Crossover
For crossover that needs bounds information:
pub trait BoundedCrossoverOperator<G>: Send + Sync {
type Output;
fn crossover_bounded<R: Rng>(
&self,
parent1: &G,
parent2: &G,
bounds: &MultiBounds,
rng: &mut R,
) -> Self::Output;
}
impl BoundedCrossoverOperator<RealVector> for BlendCrossover {
type Output = CrossoverResult<RealVector>;
fn crossover_bounded<R: Rng>(
&self,
parent1: &RealVector,
parent2: &RealVector,
bounds: &MultiBounds,
rng: &mut R,
) -> Self::Output {
let result = self.crossover(parent1, parent2, rng);
// Clamp to bounds
let child1 = clamp_to_bounds(result.genome().unwrap().0, bounds);
let child2 = clamp_to_bounds(result.genome().unwrap().1, bounds);
CrossoverResult::new(child1, child2)
}
}
Mutation Operators
Mutation introduces random variation.
Trait Definition
pub trait MutationOperator<G>: Send + Sync {
fn mutate<R: Rng>(&self, genome: &mut G, rng: &mut R);
}
Example: Cauchy Mutation
Heavy-tailed mutation for escaping local optima:
use rand_distr::{Cauchy, Distribution};
pub struct CauchyMutation {
scale: f64,
probability: f64,
}
impl CauchyMutation {
pub fn new(scale: f64) -> Self {
Self {
scale,
probability: 1.0,
}
}
pub fn with_probability(mut self, p: f64) -> Self {
self.probability = p;
self
}
}
impl MutationOperator<RealVector> for CauchyMutation {
fn mutate<R: Rng>(&self, genome: &mut RealVector, rng: &mut R) {
let cauchy = Cauchy::new(0.0, self.scale).unwrap();
for gene in genome.genes_mut() {
if rng.gen::<f64>() < self.probability {
*gene += cauchy.sample(rng);
}
}
}
}
Adaptive Mutation
Mutation that changes based on progress:
pub struct AdaptiveMutation {
initial_sigma: f64,
final_sigma: f64,
current_generation: usize,
max_generations: usize,
}
impl AdaptiveMutation {
pub fn new(initial: f64, final_val: f64, max_gen: usize) -> Self {
Self {
initial_sigma: initial,
final_sigma: final_val,
current_generation: 0,
max_generations: max_gen,
}
}
pub fn set_generation(&mut self, gen: usize) {
self.current_generation = gen;
}
fn current_sigma(&self) -> f64 {
let progress = self.current_generation as f64 / self.max_generations as f64;
self.initial_sigma + (self.final_sigma - self.initial_sigma) * progress
}
}
impl MutationOperator<RealVector> for AdaptiveMutation {
fn mutate<R: Rng>(&self, genome: &mut RealVector, rng: &mut R) {
let sigma = self.current_sigma();
let normal = rand_distr::Normal::new(0.0, sigma).unwrap();
for gene in genome.genes_mut() {
*gene += normal.sample(rng);
}
}
}
Problem-Specific Operators
Permutation: Order Crossover (OX)
pub struct OrderCrossover;
impl CrossoverOperator<Permutation> for OrderCrossover {
type Output = CrossoverResult<Permutation>;
fn crossover<R: Rng>(
&self,
parent1: &Permutation,
parent2: &Permutation,
rng: &mut R,
) -> Self::Output {
let n = parent1.len();
let p1 = parent1.as_slice();
let p2 = parent2.as_slice();
// Select crossover segment
let mut start = rng.gen_range(0..n);
let mut end = rng.gen_range(0..n);
if start > end {
std::mem::swap(&mut start, &mut end);
}
// Child 1: segment from p1, rest from p2 in order
let mut child1 = vec![usize::MAX; n];
let mut used1: HashSet<usize> = HashSet::new();
// Copy segment
for i in start..=end {
child1[i] = p1[i];
used1.insert(p1[i]);
}
// Fill rest from p2
let mut pos = (end + 1) % n;
for &val in p2.iter().cycle().skip(end + 1).take(n) {
if !used1.contains(&val) {
child1[pos] = val;
used1.insert(val);
pos = (pos + 1) % n;
if pos == start {
break;
}
}
}
// Similarly for child2...
let child2 = /* symmetric construction */;
CrossoverResult::new(
Permutation::new(child1),
Permutation::new(child2),
)
}
}
BitString: Intelligent Flip
pub struct IntelligentBitFlip {
/// Probability of flipping 0→1
p_set: f64,
/// Probability of flipping 1→0
p_clear: f64,
}
impl MutationOperator<BitString> for IntelligentBitFlip {
fn mutate<R: Rng>(&self, genome: &mut BitString, rng: &mut R) {
for bit in genome.bits_mut() {
let p = if *bit { self.p_clear } else { self.p_set };
if rng.gen::<f64>() < p {
*bit = !*bit;
}
}
}
}
Combining Operators
Composite Mutation
Apply multiple mutations:
pub struct CompositeMutation<M1, M2> {
mutation1: M1,
mutation2: M2,
p1: f64, // Probability of using mutation1
}
impl<G, M1, M2> MutationOperator<G> for CompositeMutation<M1, M2>
where
M1: MutationOperator<G>,
M2: MutationOperator<G>,
{
fn mutate<R: Rng>(&self, genome: &mut G, rng: &mut R) {
if rng.gen::<f64>() < self.p1 {
self.mutation1.mutate(genome, rng);
} else {
self.mutation2.mutate(genome, rng);
}
}
}
Testing Operators
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_crossover_preserves_genes() {
let p1 = RealVector::new(vec![1.0, 2.0, 3.0]);
let p2 = RealVector::new(vec![4.0, 5.0, 6.0]);
let crossover = BlendCrossover::new(0.0); // No expansion
let mut rng = StdRng::seed_from_u64(42);
let result = crossover.crossover(&p1, &p2, &mut rng);
let (c1, c2) = result.genome().unwrap();
// Children should be within parent ranges
for i in 0..3 {
let min = p1.genes()[i].min(p2.genes()[i]);
let max = p1.genes()[i].max(p2.genes()[i]);
assert!(c1.genes()[i] >= min && c1.genes()[i] <= max);
}
}
#[test]
fn test_mutation_changes_genome() {
let mut genome = RealVector::new(vec![0.0; 10]);
let mutation = CauchyMutation::new(1.0);
let mut rng = StdRng::seed_from_u64(42);
let original = genome.clone();
mutation.mutate(&mut genome, &mut rng);
assert_ne!(genome.genes(), original.genes());
}
}
Next Steps
- Custom Genome Types - Create genomes for your operators
- Hyperparameter Learning - Learn operator parameters