Interactive Evolution Tutorial

Interactive Genetic Algorithms (IGA) incorporate human feedback into the evolutionary process. Instead of an automated fitness function, users evaluate candidates through ratings, comparisons, or selections.

When to Use Interactive Evolution

Ideal for:

  • Aesthetic optimization (art, design, music)
  • Subjective preferences (user interfaces)
  • Hard-to-formalize objectives
  • Creative exploration

Challenges:

  • User fatigue limits evaluations
  • Noisy, inconsistent feedback
  • Slower convergence than automated GA

Evaluation Modes

Fugue-evo supports multiple ways to gather user feedback:

ModeUser ActionBest For
RatingScore each candidate 1-10Absolute quality assessment
PairwiseChoose better of twoRelative comparisons
Batch SelectionPick best N from batchQuick approximate ranking

Complete Example

//! Interactive Genetic Algorithm Example
//!
//! This example demonstrates how to use the Interactive GA for human-in-the-loop
//! evolutionary optimization. Instead of an automated fitness function, users
//! provide feedback by rating, comparing, or selecting candidates.
//!
//! In this example, we simulate user feedback with a simple automated scorer,
//! but in a real application, you would present candidates to users via a UI.

use fugue_evo::genome::bounds::Bounds;
use fugue_evo::interactive::prelude::*;
use fugue_evo::prelude::*;
use rand::rngs::StdRng;
use rand::SeedableRng;

/// Simulates user preference for solutions close to a target
struct SimulatedUserPreference {
    target: Vec<f64>,
}

impl SimulatedUserPreference {
    fn new(dim: usize) -> Self {
        // User prefers solutions where values are around 0.5
        Self {
            target: vec![0.5; dim],
        }
    }

    /// Simulate a user rating (1-10 scale)
    fn rate(&self, genome: &RealVector) -> f64 {
        let distance: f64 = genome
            .genes()
            .iter()
            .zip(self.target.iter())
            .map(|(g, t)| (g - t).powi(2))
            .sum::<f64>()
            .sqrt();

        // Convert distance to rating (closer = higher rating)
        let rating = 10.0 - (distance * 5.0).min(9.0);
        rating.max(1.0)
    }

    /// Simulate pairwise comparison
    fn compare(&self, a: &RealVector, b: &RealVector) -> std::cmp::Ordering {
        let rating_a = self.rate(a);
        let rating_b = self.rate(b);
        rating_a.partial_cmp(&rating_b).unwrap()
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    println!("=== Interactive Genetic Algorithm Demo ===\n");

    let mut rng = StdRng::seed_from_u64(42);

    const DIM: usize = 5;
    let bounds = MultiBounds::uniform(Bounds::new(0.0, 1.0), DIM);

    // Create the simulated user preference
    let user = SimulatedUserPreference::new(DIM);

    // Build the Interactive GA
    let mut iga = InteractiveGABuilder::<RealVector, (), (), ()>::new()
        .population_size(12)
        .elitism_count(2)
        .evaluation_mode(EvaluationMode::Rating)
        .batch_size(4)
        .min_coverage(0.8)
        .max_generations(5)
        .aggregation_model(AggregationModel::DirectRating {
            default_rating: 5.0,
        })
        .bounds(bounds)
        .selection(TournamentSelection::new(2))
        .crossover(SbxCrossover::new(15.0))
        .mutation(PolynomialMutation::new(20.0))
        .build()?;

    println!("Starting interactive evolution...\n");
    println!(
        "Configuration: {} individuals, {} mode, {} generations max",
        iga.config().population_size,
        match iga.config().evaluation_mode {
            EvaluationMode::Rating => "rating",
            EvaluationMode::Pairwise => "pairwise",
            EvaluationMode::BatchSelection => "batch selection",
            EvaluationMode::Adaptive => "adaptive",
        },
        iga.config().max_generations
    );
    println!();

    // Main evolution loop
    loop {
        match iga.step(&mut rng) {
            StepResult::NeedsEvaluation(request) => {
                // In a real app, you'd present this to a user via UI
                // Here we simulate user feedback
                let response = simulate_user_response(&user, &request);
                iga.provide_response(response);
            }

            StepResult::GenerationComplete {
                generation,
                best_fitness,
                coverage,
            } => {
                println!(
                    "Generation {} complete: best = {:.2}, coverage = {:.0}%",
                    generation,
                    best_fitness.unwrap_or(0.0),
                    coverage * 100.0
                );
            }

            StepResult::Complete(result) => {
                println!("\n=== Evolution Complete ===");
                println!("Reason: {}", result.termination_reason);
                println!("Generations: {}", result.generations);
                println!("Total evaluations: {}", result.total_evaluations);
                println!("\nTop 3 candidates:");

                for (i, candidate) in result.best_candidates.iter().take(3).enumerate() {
                    println!(
                        "  #{}: fitness = {:.2}, genes = {:?}",
                        i + 1,
                        candidate.fitness_estimate.unwrap_or(0.0),
                        candidate
                            .genome
                            .genes()
                            .iter()
                            .map(|g| format!("{:.3}", g))
                            .collect::<Vec<_>>()
                            .join(", ")
                    );
                }
                break;
            }
        }
    }

    // Demonstrate different evaluation modes
    println!("\n=== Batch Selection Mode Demo ===\n");

    let mut iga_batch = InteractiveGABuilder::<RealVector, (), (), ()>::new()
        .population_size(12)
        .evaluation_mode(EvaluationMode::BatchSelection)
        .batch_size(6)
        .select_count(2)
        .min_coverage(0.5)
        .max_generations(3)
        .aggregation_model(AggregationModel::ImplicitRanking {
            selected_bonus: 1.0,
            not_selected_penalty: 0.3,
            base_fitness: 5.0,
        })
        .bounds(MultiBounds::uniform(Bounds::new(0.0, 1.0), DIM))
        .selection(TournamentSelection::new(2))
        .crossover(SbxCrossover::new(15.0))
        .mutation(PolynomialMutation::new(20.0))
        .build()?;

    loop {
        match iga_batch.step(&mut rng) {
            StepResult::NeedsEvaluation(request) => {
                let response = simulate_user_response(&user, &request);
                iga_batch.provide_response(response);
            }

            StepResult::GenerationComplete {
                generation,
                best_fitness,
                ..
            } => {
                println!(
                    "Generation {}: best = {:.2}",
                    generation,
                    best_fitness.unwrap_or(0.0)
                );
            }

            StepResult::Complete(result) => {
                println!("\nBatch selection mode complete!");
                println!(
                    "Best fitness: {:.2}",
                    result.best_candidates[0].fitness_estimate.unwrap_or(0.0)
                );
                break;
            }
        }
    }

    Ok(())
}

/// Simulate user response to an evaluation request
fn simulate_user_response(
    user: &SimulatedUserPreference,
    request: &EvaluationRequest<RealVector>,
) -> EvaluationResponse {
    match request {
        EvaluationRequest::RateCandidates { candidates, .. } => {
            let ratings: Vec<_> = candidates
                .iter()
                .map(|c| (c.id, user.rate(&c.genome)))
                .collect();
            EvaluationResponse::ratings(ratings)
        }

        EvaluationRequest::PairwiseComparison {
            candidate_a,
            candidate_b,
            ..
        } => {
            use std::cmp::Ordering;
            match user.compare(&candidate_a.genome, &candidate_b.genome) {
                Ordering::Greater => EvaluationResponse::winner(candidate_a.id),
                Ordering::Less => EvaluationResponse::winner(candidate_b.id),
                Ordering::Equal => EvaluationResponse::tie(),
            }
        }

        EvaluationRequest::BatchSelection {
            candidates,
            select_count,
            ..
        } => {
            // Sort by rating and select top N
            let mut rated: Vec<_> = candidates
                .iter()
                .map(|c| (c.id, user.rate(&c.genome)))
                .collect();
            rated.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());

            let selected: Vec<_> = rated
                .iter()
                .take(*select_count)
                .map(|(id, _)| *id)
                .collect();
            EvaluationResponse::selected(selected)
        }
    }
}

Source: examples/interactive_evolution.rs

Running the Example

cargo run --example interactive_evolution

This example simulates user feedback. In a real application, you would replace the simulation with actual UI interaction.

Key Components

Building an Interactive GA

let mut iga = InteractiveGABuilder::<RealVector, (), (), ()>::new()
    .population_size(12)
    .elitism_count(2)
    .evaluation_mode(EvaluationMode::Rating)
    .batch_size(4)
    .min_coverage(0.8)
    .max_generations(5)
    .aggregation_model(AggregationModel::DirectRating {
        default_rating: 5.0,
    })
    .bounds(bounds)
    .selection(TournamentSelection::new(2))
    .crossover(SbxCrossover::new(15.0))
    .mutation(PolynomialMutation::new(20.0))
    .build()?;

Key parameters:

  • evaluation_mode: How users provide feedback
  • batch_size: Candidates shown per evaluation round
  • min_coverage: Fraction of population needing evaluation
  • aggregation_model: How to combine multiple evaluations

The Step Loop

loop {
    match iga.step(&mut rng) {
        StepResult::NeedsEvaluation(request) => {
            // Present to user, get feedback
            let response = get_user_response(&request);
            iga.provide_response(response);
        }

        StepResult::GenerationComplete { generation, best_fitness, coverage } => {
            println!("Generation {} complete", generation);
        }

        StepResult::Complete(result) => {
            println!("Evolution complete!");
            break;
        }
    }
}

Handling Evaluation Requests

Rating Mode:

EvaluationRequest::RateCandidates { candidates, .. } => {
    // Show candidates to user
    for candidate in candidates {
        display_candidate(&candidate.genome);
    }
    // Collect ratings
    let ratings: Vec<(CandidateId, f64)> = /* user input */;
    EvaluationResponse::ratings(ratings)
}

Pairwise Mode:

EvaluationRequest::PairwiseComparison { candidate_a, candidate_b, .. } => {
    // Show both candidates
    display_comparison(&candidate_a.genome, &candidate_b.genome);
    // Get user's choice
    let winner = /* user choice */;
    EvaluationResponse::winner(winner)
}

Batch Selection:

EvaluationRequest::BatchSelection { candidates, select_count, .. } => {
    // Show all candidates
    for c in candidates { display_candidate(&c.genome); }
    // User selects best N
    let selected: Vec<CandidateId> = /* user picks */;
    EvaluationResponse::selected(selected)
}

Aggregation Models

How to combine feedback into fitness estimates:

Direct Rating

AggregationModel::DirectRating { default_rating: 5.0 }

Uses ratings directly as fitness. Unevaluated candidates get the default.

Implicit Ranking

AggregationModel::ImplicitRanking {
    selected_bonus: 1.0,
    not_selected_penalty: 0.3,
    base_fitness: 5.0,
}

For batch selection mode:

  • Selected candidates get bonus
  • Non-selected get penalty
  • Accumulates over evaluations

Bradley-Terry Model

For pairwise comparisons, estimates latent "skill" from win/loss records using the Bradley-Terry statistical model.

Reducing User Fatigue

Smaller Population

.population_size(12)

Fewer candidates = fewer evaluations needed.

Coverage Threshold

.min_coverage(0.5)  // Only evaluate 50% of population

Not every candidate needs evaluation each generation.

Batch Size Tuning

.batch_size(4)  // Show 4 at a time
  • Too small: Many rounds, tedious
  • Too large: Overwhelming, poor decisions

Evaluation Budget

.max_evaluations(100)  // Stop after 100 user interactions

Simulating User Feedback

For testing, simulate user preferences:

struct SimulatedUser {
    target: Vec<f64>,
}

impl SimulatedUser {
    fn rate(&self, genome: &RealVector) -> f64 {
        let distance: f64 = genome.genes().iter()
            .zip(self.target.iter())
            .map(|(g, t)| (g - t).powi(2))
            .sum::<f64>()
            .sqrt();

        // Closer to target = higher rating
        10.0 - distance.min(9.0)
    }
}

This allows testing IGA logic without human interaction.

Real-World Integration

Web Application

// Pseudocode for web integration
async fn evolution_endpoint(state: &mut IgaState) -> Response {
    match state.iga.step(&mut state.rng) {
        StepResult::NeedsEvaluation(request) => {
            // Return candidates to frontend
            Json(CandidatesForEvaluation::from(request))
        }
        // ...
    }
}

async fn feedback_endpoint(feedback: UserFeedback, state: &mut IgaState) {
    let response = EvaluationResponse::from(feedback);
    state.iga.provide_response(response);
}

GUI Application

fn update(&mut self, message: Message) {
    match message {
        Message::NextStep => {
            if let StepResult::NeedsEvaluation(req) = self.iga.step(&mut self.rng) {
                self.current_request = Some(req);
            }
        }
        Message::UserRated(ratings) => {
            let response = EvaluationResponse::ratings(ratings);
            self.iga.provide_response(response);
        }
    }
}

Exercises

  1. Different modes: Compare Rating vs Batch Selection modes
  2. Noisy preferences: Add randomness to simulated user, observe robustness
  3. Visualization: Display RealVector as colors/shapes for visual evaluation

Next Steps