feat(operators): add LevyMutation real-valued heavy-tailed mutation
Lévy-flight perturbation: each variable receives a step drawn from a heavy-tailed Lévy(α) distribution rather than a Normal. The result is "mostly small steps with rare big jumps," which gives a more exploratory mutation than Gaussian without abandoning local search. Decision type: Vec<f64>, with optional bounds (clamped per-axis if `bounds` is non-empty). The step is sampled via Mantegna's algorithm which generates Lévy(α) by combining two Normal samples and taking the right power, controlled by the tail exponent `alpha` (typical 1.5; 1 is heavy, 2 collapses to Normal). This is the only genuinely-different mutation kernel from Cuckoo Search and other Lévy-flight metaheuristics; ship it as a Variation operator usable from any algorithm rather than as a separate algorithm.
This commit is contained in:
@@ -289,6 +289,111 @@ impl Variation<Vec<f64>> for BoundedGaussianMutation {
|
||||
}
|
||||
}
|
||||
|
||||
/// Heavy-tailed Lévy-flight mutation for `Vec<f64>` decisions.
|
||||
///
|
||||
/// Adds a Lévy(α)-distributed step to every variable, optionally clamped to
|
||||
/// per-variable bounds. Compared with `GaussianMutation`, the Lévy
|
||||
/// distribution has a heavy tail — most steps are small and local but
|
||||
/// occasional steps are very large, giving a single mutation operator
|
||||
/// that does both refinement and exploration. This is the kernel that
|
||||
/// powers Cuckoo Search and other Lévy-flight metaheuristics.
|
||||
///
|
||||
/// Implementation: Mantegna's algorithm combines two Gaussians to
|
||||
/// produce a Lévy(α) sample. `alpha` is the tail exponent in `(0, 2]`;
|
||||
/// typical value is `1.5`. `1.0` gives the Cauchy distribution (very
|
||||
/// heavy); `2.0` collapses to the Normal.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LevyMutation {
|
||||
/// Tail exponent `α ∈ (0, 2]`. Smaller = heavier tail.
|
||||
pub alpha: f64,
|
||||
/// Step scale.
|
||||
pub scale: f64,
|
||||
/// Optional per-variable bounds. Empty `Vec` → no clamping.
|
||||
pub bounds: Vec<(f64, f64)>,
|
||||
}
|
||||
|
||||
impl LevyMutation {
|
||||
/// Construct a `LevyMutation`.
|
||||
///
|
||||
/// # Panics
|
||||
/// If `alpha` is not in `(0, 2]`, `scale <= 0.0`, or any bound has
|
||||
/// `lo > hi`.
|
||||
pub fn new(alpha: f64, scale: f64, bounds: Vec<(f64, f64)>) -> Self {
|
||||
assert!(
|
||||
alpha > 0.0 && alpha <= 2.0,
|
||||
"LevyMutation alpha must be in (0, 2]",
|
||||
);
|
||||
assert!(scale > 0.0, "LevyMutation scale must be > 0");
|
||||
for (i, &(lo, hi)) in bounds.iter().enumerate() {
|
||||
assert!(
|
||||
lo <= hi,
|
||||
"LevyMutation bound at index {i} has lo > hi: ({lo}, {hi})",
|
||||
);
|
||||
}
|
||||
Self { alpha, scale, bounds }
|
||||
}
|
||||
}
|
||||
|
||||
impl Variation<Vec<f64>> for LevyMutation {
|
||||
fn vary(&mut self, parents: &[Vec<f64>], rng: &mut Rng) -> Vec<Vec<f64>> {
|
||||
assert!(!parents.is_empty(), "LevyMutation requires at least one parent");
|
||||
let alpha = self.alpha;
|
||||
// Mantegna's algorithm σ for the numerator Normal:
|
||||
// sigma_u = (Γ(1+α)·sin(π·α/2) / (Γ((1+α)/2)·α·2^((α-1)/2)))^(1/α)
|
||||
// Denominator Normal has σ = 1.
|
||||
let sigma_u = mantegna_sigma_u(alpha);
|
||||
let normal_u = Normal::new(0.0, sigma_u).expect("Normal::new(0, sigma_u)");
|
||||
let normal_v = Normal::new(0.0, 1.0).expect("Normal::new(0, 1)");
|
||||
let mut child = parents[0].clone();
|
||||
for (j, x) in child.iter_mut().enumerate() {
|
||||
let u: f64 = normal_u.sample(rng);
|
||||
let v: f64 = normal_v.sample(rng);
|
||||
let step = u / v.abs().powf(1.0 / alpha);
|
||||
*x += self.scale * step;
|
||||
if let Some(&(lo, hi)) = self.bounds.get(j) {
|
||||
*x = x.clamp(lo, hi);
|
||||
}
|
||||
}
|
||||
vec![child]
|
||||
}
|
||||
}
|
||||
|
||||
fn mantegna_sigma_u(alpha: f64) -> f64 {
|
||||
// Γ-related constants. We compute Γ(z) via libm if the std::f64::gamma
|
||||
// isn't available; fall back to a small Lanczos approximation.
|
||||
fn gamma(z: f64) -> f64 {
|
||||
// Stirling-ish via the standard recursion + Lanczos coefficients.
|
||||
// For the typical α ∈ [1, 2] range we hit, the expressions Γ(1+α)
|
||||
// and Γ((1+α)/2) are well-behaved.
|
||||
let g = 7.0;
|
||||
let p = [
|
||||
0.999_999_999_999_809_93,
|
||||
676.520_368_121_885_1,
|
||||
-1_259.139_216_722_4023,
|
||||
771.323_428_777_653_13,
|
||||
-176.615_029_162_140_59,
|
||||
12.507_343_278_686_905,
|
||||
-0.138_571_095_265_720_12,
|
||||
9.984_369_578_019_571_6e-6,
|
||||
1.505_632_735_149_311_6e-7,
|
||||
];
|
||||
if z < 0.5 {
|
||||
std::f64::consts::PI / ((std::f64::consts::PI * z).sin() * gamma(1.0 - z))
|
||||
} else {
|
||||
let z = z - 1.0;
|
||||
let mut x = p[0];
|
||||
for (i, &pi) in p.iter().enumerate().skip(1) {
|
||||
x += pi / (z + i as f64);
|
||||
}
|
||||
let t = z + g + 0.5;
|
||||
(2.0 * std::f64::consts::PI).sqrt() * t.powf(z + 0.5) * (-t).exp() * x
|
||||
}
|
||||
}
|
||||
let num = gamma(1.0 + alpha) * (std::f64::consts::PI * alpha / 2.0).sin();
|
||||
let den = gamma((1.0 + alpha) / 2.0) * alpha * 2.0_f64.powf((alpha - 1.0) / 2.0);
|
||||
(num / den).powf(1.0 / alpha)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -426,6 +531,42 @@ mod tests {
|
||||
let _ = SimulatedBinaryCrossover::new(vec![(0.0, 1.0)], -1.0, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levy_mutation_returns_one_child_in_bounds() {
|
||||
let mut m = LevyMutation::new(1.5, 0.1, vec![(-1.0, 1.0); 4]);
|
||||
let mut rng = rng_from_seed(42);
|
||||
let parent = vec![0.0_f64; 4];
|
||||
for _ in 0..50 {
|
||||
let children = m.vary(std::slice::from_ref(&parent), &mut rng);
|
||||
assert_eq!(children.len(), 1);
|
||||
assert_eq!(children[0].len(), 4);
|
||||
for &x in &children[0] {
|
||||
assert!((-1.0..=1.0).contains(&x), "out of bounds: {x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levy_mutation_unbounded_works() {
|
||||
let mut m = LevyMutation::new(1.5, 0.5, Vec::new());
|
||||
let mut rng = rng_from_seed(0);
|
||||
let parent = vec![0.0_f64; 3];
|
||||
let children = m.vary(std::slice::from_ref(&parent), &mut rng);
|
||||
assert_eq!(children[0].len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "alpha must be in (0, 2]")]
|
||||
fn levy_alpha_out_of_range_panics() {
|
||||
let _ = LevyMutation::new(0.0, 0.1, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "scale must be > 0")]
|
||||
fn levy_zero_scale_panics() {
|
||||
let _ = LevyMutation::new(1.5, 0.0, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polynomial_mutation_keeps_child_in_bounds() {
|
||||
let mut m = PolynomialMutation::new(vec![(-1.0, 1.0); 5], 5.0, 1.0);
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ pub use crate::pareto::{
|
||||
|
||||
pub use crate::operators::{
|
||||
BitFlipMutation, BoundedGaussianMutation, CompositeVariation, GaussianMutation,
|
||||
PolynomialMutation, RealBounds, SimulatedBinaryCrossover, SwapMutation,
|
||||
LevyMutation, PolynomialMutation, RealBounds, SimulatedBinaryCrossover, SwapMutation,
|
||||
};
|
||||
|
||||
pub use crate::algorithms::{
|
||||
|
||||
Reference in New Issue
Block a user