feat(selection): add stochastic_ranking_select for constrained problems

Runarsson & Yao 2000 stochastic ranking: a probabilistic alternative
to feasibility-first tournament selection. Each pairwise comparison
during a bubble-sort pass uses the *objective* value with probability
`pf` even when one or both candidates are infeasible. The classic
recommendation `pf = 0.45` reliably outperforms strict
feasibility-first on heavily-constrained problems where occasionally
exploring the infeasible region helps cross narrow feasible corridors.

New helper: `stochastic_ranking_select` lives next to
`tournament_select_single_objective` in `selection::tournament`.
Single-objective only; same signature pattern (population, objectives,
count, rng, plus the new `pf` knob).
This commit is contained in:
2026-05-05 09:55:43 -06:00
parent 358e441b36
commit 66f6cf6e86
+135
View File
@@ -73,6 +73,105 @@ fn challenger_wins<D>(c: &Candidate<D>, b: &Candidate<D>, dir: Direction) -> boo
}
}
/// Stochastic-ranking selection (Runarsson & Yao 2000) for single-objective
/// constrained problems.
///
/// Performs a probabilistic bubble-sort pass on the population — each
/// pairwise comparison uses the *objective* value with probability `pf`,
/// otherwise it uses the standard feasibility-then-violation-then-objective
/// rule. The classic value is `pf = 0.45`; values close to `0.5` weight
/// objective improvement against constraint satisfaction.
///
/// Returns `count` decisions cloned from the top of the ranked
/// population. Useful when constraint satisfaction is hard and strict
/// feasibility-first selection traps the search outside the feasible
/// region.
///
/// # Panics
/// If `objectives` does not contain exactly one objective, if `pf` is
/// outside `[0.0, 1.0]`, or if the population is empty when `count > 0`.
pub fn stochastic_ranking_select<D: Clone>(
population: &[Candidate<D>],
objectives: &ObjectiveSpace,
pf: f64,
count: usize,
rng: &mut Rng,
) -> Vec<D> {
assert!(
objectives.is_single_objective(),
"stochastic_ranking_select requires exactly one objective",
);
assert!(
(0.0..=1.0).contains(&pf),
"stochastic_ranking_select pf must be in [0.0, 1.0]",
);
if count == 0 {
return Vec::new();
}
assert!(
!population.is_empty(),
"stochastic_ranking_select called on empty population with count > 0",
);
let direction = objectives.objectives[0].direction;
let n = population.len();
let mut order: Vec<usize> = (0..n).collect();
// Bubble-sort with at most n full sweeps (Runarsson & Yao §3).
for _ in 0..n {
let mut swapped = false;
for i in 0..n - 1 {
let a = &population[order[i]].evaluation;
let b = &population[order[i + 1]].evaluation;
let use_objective = rng.random::<f64>() < pf;
let a_first = if use_objective || (a.is_feasible() && b.is_feasible()) {
better_by_objective(a, b, direction)
} else {
better_by_feasibility(a, b, direction)
};
if !a_first {
order.swap(i, i + 1);
swapped = true;
}
}
if !swapped {
break;
}
}
let mut out = Vec::with_capacity(count);
for k in 0..count {
out.push(population[order[k % n]].decision.clone());
}
out
}
fn better_by_objective(
a: &crate::core::evaluation::Evaluation,
b: &crate::core::evaluation::Evaluation,
direction: Direction,
) -> bool {
let av = a.objectives.first().copied().unwrap_or(f64::INFINITY);
let bv = b.objectives.first().copied().unwrap_or(f64::INFINITY);
match direction {
Direction::Minimize => av < bv,
Direction::Maximize => av > bv,
}
}
fn better_by_feasibility(
a: &crate::core::evaluation::Evaluation,
b: &crate::core::evaluation::Evaluation,
direction: Direction,
) -> bool {
match (a.is_feasible(), b.is_feasible()) {
(true, false) => true,
(false, true) => false,
(false, false) => a.constraint_violation < b.constraint_violation,
(true, true) => better_by_objective(a, b, direction),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -127,4 +226,40 @@ mod tests {
let mut rng = rng_from_seed(0);
let _ = tournament_select_single_objective(&pop, &s, 2, 1, &mut rng);
}
#[test]
fn stochastic_ranking_returns_count_decisions() {
let s = ObjectiveSpace::new(vec![Objective::minimize("f")]);
let pop = [cand_min(1, 5.0), cand_min(2, 1.0), cand_min(3, 9.0)];
let mut rng = rng_from_seed(7);
let picks = stochastic_ranking_select(&pop, &s, 0.45, 4, &mut rng);
assert_eq!(picks.len(), 4);
for p in &picks {
assert!([1, 2, 3].contains(p));
}
}
#[test]
fn stochastic_ranking_pf_zero_is_feasibility_first() {
// With pf = 0, the algorithm reduces to strict feasibility-first
// ordering, so the best feasible candidate should top the rank.
let s = ObjectiveSpace::new(vec![Objective::minimize("f")]);
let pop = [
Candidate::new(1u32, Evaluation::constrained(vec![0.0], 5.0)), // infeasible
Candidate::new(2u32, Evaluation::new(vec![10.0])), // feasible, big f
Candidate::new(3u32, Evaluation::new(vec![3.0])), // feasible, small f
];
let mut rng = rng_from_seed(0);
let picks = stochastic_ranking_select(&pop, &s, 0.0, 3, &mut rng);
assert_eq!(picks[0], 3); // best feasible first
}
#[test]
#[should_panic(expected = "pf must be in [0.0, 1.0]")]
fn stochastic_ranking_pf_out_of_range_panics() {
let s = ObjectiveSpace::new(vec![Objective::minimize("f")]);
let pop = [cand_min(1, 1.0)];
let mut rng = rng_from_seed(0);
let _ = stochastic_ranking_select(&pop, &s, 1.5, 1, &mut rng);
}
}