Skip to main content

math_audio_optimisation/
isres.rs

1//! Improved Stochastic Ranking Evolution Strategy (ISRES).
2//!
3//! Reference: Runarsson, T.P. & Yao, X., "Search Biases in Constrained
4//! Evolutionary Optimization," IEEE Transactions on Systems, Man, and
5//! Cybernetics, Part C, vol. 35, no. 2, pp. 233-243, 2005.
6//!
7//! Pure-Rust implementation. Replaces NLopt's ISRES (`nlopt:isres`) for
8//! global constrained optimization with nonlinear inequality constraints.
9//!
10//! # Algorithm
11//!
12//! `(μ, λ)` evolution strategy where:
13//! - Each individual carries `(x, σ)` — parameter vector + per-dimension
14//!   log-normal step sizes.
15//! - Each generation produces λ offspring; the top μ by stochastic ranking
16//!   become the next generation's parents.
17//! - Mutation has three components:
18//!   1. **Log-normal step-size adaptation**: σ' = σ * exp(τ' N) * exp(τ Nᵢ)
19//!   2. **Differential variation** with probability γ: helps escape narrow
20//!      feasible regions by adding (x_best − x_random) to candidate.
21//!   3. **Gaussian perturbation**: x' = x + σ' .* Nᵢ(0,1).
22//!   Out-of-bounds components are reflected back into `[lo, hi]`.
23//! - **Stochastic ranking** (the key contribution): bubble-sort the offspring
24//!   by interleaving objective and max-violation comparisons. With
25//!   probability `pf`, compare two adjacent individuals by objective; with
26//!   probability `1 − pf`, compare by maximum constraint violation. This
27//!   biases selection toward the objective when feasibility is roughly
28//!   equal, and toward feasibility otherwise.
29//!
30//! # Constraint convention
31//!
32//! Inequality constraints `g_i(x) ≤ 0` are feasible at zero or below.
33//! `IsresConstraint::fun` should return the violation magnitude — the
34//! optimizer treats anything > 0 as infeasible.
35
36use ndarray::Array1;
37use rand::rngs::StdRng;
38use rand::{Rng, SeedableRng};
39use std::sync::Arc;
40
41use crate::error::{DEError, Result};
42
43/// Erased inequality constraint closure: feasible when `<= 0`.
44pub type IsresConstraintFn = Arc<dyn Fn(&Array1<f64>) -> f64 + Send + Sync>;
45
46/// A single inequality constraint `fun(x) <= 0`.
47#[derive(Clone)]
48pub struct IsresConstraint {
49    /// Constraint closure. Feasible when ≤ 0; positive values count as the
50    /// violation magnitude used in stochastic ranking.
51    pub fun: IsresConstraintFn,
52}
53
54/// Configuration for [`isres`].
55#[derive(Clone)]
56pub struct IsresConfig {
57    /// `(lower, upper)` per dimension.
58    pub bounds: Vec<(f64, f64)>,
59    /// Optional initial guess; placed in the first parent slot if provided.
60    pub x0: Option<Array1<f64>>,
61    /// Parent population size μ. Default 30.
62    pub mu: usize,
63    /// Offspring population size λ. Default 7 * μ.
64    pub lambda: usize,
65    /// Maximum number of objective evaluations. Counts each candidate
66    /// (offspring or initial population member) once.
67    pub maxeval: usize,
68    /// Stochastic ranking parameter — probability of comparing by
69    /// objective rather than by constraint violation. Runarsson & Yao
70    /// recommend `0.4 ≤ pf ≤ 0.5`. Default 0.45.
71    pub pf: f64,
72    /// Differential variation probability γ (Eq. 2 of R&Y 2005). Default 0.85.
73    pub gamma: f64,
74    /// Optional RNG seed for deterministic runs.
75    pub seed: Option<u64>,
76    /// Stop when best objective improves by less than `f_tol` for
77    /// `stagnation_window` consecutive generations. Set non-positive to
78    /// disable stagnation-based termination. Default `1e-8`.
79    pub f_tol: f64,
80    /// Number of generations of < `f_tol` improvement before declaring
81    /// convergence. Default 50.
82    pub stagnation_window: usize,
83}
84
85impl Default for IsresConfig {
86    fn default() -> Self {
87        Self {
88            bounds: Vec::new(),
89            x0: None,
90            mu: 30,
91            lambda: 0, // 0 means "use 7μ"
92            maxeval: 10_000,
93            pf: 0.45,
94            gamma: 0.85,
95            seed: None,
96            f_tol: 1e-8,
97            stagnation_window: 50,
98        }
99    }
100}
101
102/// Result of an [`isres`] run.
103#[derive(Clone)]
104pub struct IsresReport {
105    /// Best parameter vector found across all generations.
106    pub x: Array1<f64>,
107    /// Objective value at `x`.
108    pub fun: f64,
109    /// Maximum constraint violation at `x` (0 if feasible).
110    pub max_violation: f64,
111    /// Whether `x` is feasible (max_violation ≤ 0).
112    pub feasible: bool,
113    /// Whether the run terminated by stagnation (true) or by exhausting
114    /// `maxeval` / generation budget (false).
115    pub success: bool,
116    /// Status message.
117    pub message: String,
118    /// Number of objective evaluations consumed.
119    pub nfev: usize,
120    /// Number of generations completed.
121    pub nit: usize,
122}
123
124impl std::fmt::Debug for IsresReport {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.debug_struct("IsresReport")
127            .field("x_len", &self.x.len())
128            .field("fun", &self.fun)
129            .field("max_violation", &self.max_violation)
130            .field("feasible", &self.feasible)
131            .field("success", &self.success)
132            .field("message", &self.message)
133            .field("nfev", &self.nfev)
134            .field("nit", &self.nit)
135            .finish()
136    }
137}
138
139#[derive(Clone)]
140struct Individual {
141    x: Array1<f64>,
142    sigma: Array1<f64>,
143    fun: f64,
144    max_violation: f64,
145}
146
147/// Run ISRES.
148pub fn isres<F>(
149    f: &F,
150    constraints: &[IsresConstraint],
151    config: IsresConfig,
152) -> Result<IsresReport>
153where
154    F: Fn(&Array1<f64>) -> f64 + Sync,
155{
156    let n = config.bounds.len();
157    if n == 0 {
158        return Err(DEError::BoundsMismatch {
159            lower_len: 0,
160            upper_len: 0,
161        });
162    }
163    for (i, (lo, hi)) in config.bounds.iter().enumerate() {
164        if lo > hi {
165            return Err(DEError::InvalidBounds {
166                index: i,
167                lower: *lo,
168                upper: *hi,
169            });
170        }
171    }
172    if config.mu < 2 {
173        return Err(DEError::PopulationTooSmall { pop_size: config.mu });
174    }
175
176    let mu = config.mu;
177    let lambda = if config.lambda == 0 { 7 * mu } else { config.lambda };
178    if lambda < mu {
179        return Err(DEError::PopulationTooSmall { pop_size: lambda });
180    }
181
182    // Step-size adaptation parameters (R&Y 2005 §III.A).
183    let n_f = n as f64;
184    let tau = 1.0 / (2.0 * n_f.sqrt()).sqrt();
185    let tau_prime = 1.0 / (2.0 * n_f).sqrt();
186    // R&Y suggest σ_max = 0.2 * (hi - lo). Using 1/√n as a clip prevents
187    // total saturation while letting individual steps reach a meaningful
188    // fraction of the bound span.
189    let sigma_max: Vec<f64> = config
190        .bounds
191        .iter()
192        .map(|(lo, hi)| ((hi - lo) * 0.2).max(1e-12))
193        .collect();
194
195    let mut rng: StdRng = match config.seed {
196        Some(s) => StdRng::seed_from_u64(s),
197        None => {
198            let mut thread_rng = rand::rng();
199            StdRng::from_rng(&mut thread_rng)
200        }
201    };
202
203    let mut nfev = 0usize;
204    let mut population: Vec<Individual> = Vec::with_capacity(mu);
205
206    // Initial μ parents — uniform random within bounds, σ initialised to
207    // sigma_max / √n so per-dim Gaussian steps don't immediately blow out
208    // of the box.
209    let initial_sigma: Vec<f64> = sigma_max
210        .iter()
211        .map(|&sm| (sm / n_f.sqrt()).max(1e-12))
212        .collect();
213    for i in 0..mu {
214        let mut x = Array1::<f64>::zeros(n);
215        // Optionally seed the first parent with x0.
216        if i == 0
217            && let Some(ref seed) = config.x0
218            && seed.len() == n
219        {
220            for j in 0..n {
221                let (lo, hi) = config.bounds[j];
222                x[j] = seed[j].clamp(lo, hi);
223            }
224        } else {
225            for j in 0..n {
226                let (lo, hi) = config.bounds[j];
227                x[j] = lo + rng.random::<f64>() * (hi - lo);
228            }
229        }
230        let sigma = Array1::from(initial_sigma.clone());
231        let fun = f(&x);
232        let mv = max_violation_of(&x, constraints);
233        nfev += 1;
234        population.push(Individual {
235            x,
236            sigma,
237            fun,
238            max_violation: mv,
239        });
240        if nfev >= config.maxeval {
241            break;
242        }
243    }
244    // If maxeval was tiny we may have under-filled; pad by cloning best.
245    while population.len() < mu {
246        let last = population.last().cloned().unwrap();
247        population.push(last);
248    }
249
250    // Track global best — ISRES doesn't guarantee monotonic best-of-pop,
251    // so we shadow the best-seen individual independently.
252    let mut best = best_individual(&population).clone();
253    let mut nit = 0usize;
254    let mut stagnation_counter = 0usize;
255    let mut last_best_fun = best.fun;
256    let mut terminated_by_stagnation = false;
257
258    'outer: while nfev < config.maxeval {
259        nit += 1;
260
261        // Generate λ offspring. Each offspring picks a parent uniformly
262        // from the μ-strong parent pool (after sorting by stochastic
263        // ranking, the top μ are the parents — see Step 2 below).
264        let mut offspring: Vec<Individual> = Vec::with_capacity(lambda);
265        for k in 0..lambda {
266            // Differential variation parent (R&Y 2005, Eq. 2): pair the
267            // k-th offspring with the parent at the same index in the
268            // sorted population, then add (x_best − x_paired) at a random
269            // direction with probability γ. We use parent[k mod μ] as the
270            // "main" parent, and a separate random parent as the diff peer.
271            let parent_idx = k % mu;
272            let parent = &population[parent_idx];
273
274            // Step-size mutation (log-normal; clip to sigma_max).
275            let n_global: f64 = standard_normal(&mut rng);
276            let mut sigma_new = parent.sigma.clone();
277            for j in 0..n {
278                let n_local: f64 = standard_normal(&mut rng);
279                let mult = (tau_prime * n_global + tau * n_local).exp();
280                sigma_new[j] = (parent.sigma[j] * mult).min(sigma_max[j]).max(1e-30);
281            }
282
283            // Differential variation: add γ * (x_p1 − x_p2) where p1, p2
284            // are randomly chosen parents (with replacement). R&Y use the
285            // top-1 parent and a random one to bias toward feasibility.
286            let mut x_new = parent.x.clone();
287            if rng.random::<f64>() < config.gamma && mu >= 3 {
288                let p1 = rng.random_range(0..mu.min(mu / 2 + 1).max(2)); // top half
289                let p2 = rng.random_range(0..mu);
290                if p1 != p2 {
291                    let blend: f64 = rng.random::<f64>();
292                    for j in 0..n {
293                        x_new[j] += blend * (population[p1].x[j] - population[p2].x[j]);
294                    }
295                }
296            }
297
298            // Gaussian perturbation by σ'.
299            for j in 0..n {
300                let n_j: f64 = standard_normal(&mut rng);
301                x_new[j] += sigma_new[j] * n_j;
302            }
303
304            // Reflect into bounds (preserve diversity better than clip).
305            for j in 0..n {
306                let (lo, hi) = config.bounds[j];
307                x_new[j] = reflect_into_bounds(x_new[j], lo, hi);
308            }
309
310            let fun = f(&x_new);
311            let mv = max_violation_of(&x_new, constraints);
312            nfev += 1;
313            offspring.push(Individual {
314                x: x_new,
315                sigma: sigma_new,
316                fun,
317                max_violation: mv,
318            });
319            if nfev >= config.maxeval {
320                break;
321            }
322        }
323
324        // Stochastic ranking — bubble sort by interleaving fun-vs-violation.
325        stochastic_rank(&mut offspring, config.pf, &mut rng);
326
327        // Survival: top μ become next-gen parents.
328        offspring.truncate(mu);
329        population = offspring;
330
331        // Update global best.
332        let gen_best = best_individual(&population);
333        if individual_better(gen_best, &best) {
334            best = gen_best.clone();
335        }
336
337        // Stagnation check.
338        if config.f_tol > 0.0 {
339            let delta = (last_best_fun - best.fun).abs();
340            if delta < config.f_tol {
341                stagnation_counter += 1;
342                if stagnation_counter >= config.stagnation_window {
343                    terminated_by_stagnation = true;
344                    break 'outer;
345                }
346            } else {
347                stagnation_counter = 0;
348                last_best_fun = best.fun;
349            }
350        }
351    }
352
353    let feasible = best.max_violation <= 0.0;
354    let message = if terminated_by_stagnation {
355        format!(
356            "Converged: best fitness stagnated within tol={:.1e} for {} generations",
357            config.f_tol, config.stagnation_window
358        )
359    } else if nfev >= config.maxeval {
360        format!("Maximum evaluations reached: {}", config.maxeval)
361    } else {
362        "Iteration limit reached".to_string()
363    };
364
365    Ok(IsresReport {
366        x: best.x,
367        fun: best.fun,
368        max_violation: best.max_violation.max(0.0),
369        feasible,
370        success: terminated_by_stagnation,
371        message,
372        nfev,
373        nit,
374    })
375}
376
377/// Maximum constraint violation across all constraints. Zero means feasible.
378fn max_violation_of(x: &Array1<f64>, constraints: &[IsresConstraint]) -> f64 {
379    let mut worst = 0.0_f64;
380    for c in constraints {
381        let v = (c.fun)(x);
382        if v > worst {
383            worst = v;
384        }
385    }
386    worst
387}
388
389/// Standard normal sample via Box-Muller (only the cosine half is used —
390/// the sine half is discarded for simplicity; for ISRES the small loss in
391/// efficiency is negligible compared to objective cost).
392fn standard_normal<R: Rng + ?Sized>(rng: &mut R) -> f64 {
393    let u1: f64 = rng.random::<f64>().max(1e-300);
394    let u2: f64 = rng.random::<f64>();
395    (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
396}
397
398/// Reflect `v` into `[lo, hi]`. If `v` falls outside, mirror it back across
399/// the violated boundary; if it overshoots after one reflection, clamp.
400fn reflect_into_bounds(v: f64, lo: f64, hi: f64) -> f64 {
401    if !v.is_finite() {
402        return (lo + hi) * 0.5;
403    }
404    if v < lo {
405        let r = lo + (lo - v);
406        if r > hi { (lo + hi) * 0.5 } else { r }
407    } else if v > hi {
408        let r = hi - (v - hi);
409        if r < lo { (lo + hi) * 0.5 } else { r }
410    } else {
411        v
412    }
413}
414
415/// Stochastic ranking (Runarsson & Yao 2000/2005). Bubble-sort the slice;
416/// at each adjacent comparison, with probability `pf` compare by objective,
417/// otherwise by `max_violation`. The pass repeats until no swap occurs in
418/// a full sweep (classic bubble-sort termination).
419fn stochastic_rank<R: Rng + ?Sized>(pop: &mut [Individual], pf: f64, rng: &mut R) {
420    let n = pop.len();
421    if n < 2 {
422        return;
423    }
424    // Cap passes at n − 1 (worst case for bubble sort) to bound time.
425    for _ in 0..n.saturating_sub(1) {
426        let mut swapped = false;
427        for i in 0..(n - 1) {
428            // If both are feasible, always compare by objective; if both are
429            // infeasible, always compare by violation. Otherwise apply the
430            // pf coin flip — this is the original R&Y prescription.
431            let a = &pop[i];
432            let b = &pop[i + 1];
433            let both_feasible = a.max_violation <= 0.0 && b.max_violation <= 0.0;
434            let both_infeasible = a.max_violation > 0.0 && b.max_violation > 0.0;
435            let compare_by_fun = if both_feasible {
436                true
437            } else if both_infeasible {
438                rng.random::<f64>() < pf
439            } else {
440                // One feasible, one not — feasible always wins (feasibility
441                // is treated as an absolute preference outside of ranking).
442                false
443            };
444
445            let need_swap = if compare_by_fun {
446                a.fun > b.fun
447            } else {
448                a.max_violation > b.max_violation
449            };
450
451            if need_swap {
452                pop.swap(i, i + 1);
453                swapped = true;
454            }
455        }
456        if !swapped {
457            break;
458        }
459    }
460}
461
462/// Find the population's "best" individual using the same lexicographic
463/// preference as `individual_better`.
464fn best_individual(pop: &[Individual]) -> &Individual {
465    pop.iter()
466        .min_by(|a, b| {
467            if individual_better(a, b) {
468                std::cmp::Ordering::Less
469            } else if individual_better(b, a) {
470                std::cmp::Ordering::Greater
471            } else {
472                std::cmp::Ordering::Equal
473            }
474        })
475        .expect("non-empty population")
476}
477
478/// Lexicographic better: feasible beats infeasible; among feasible, lower
479/// objective wins; among infeasible, lower violation wins.
480fn individual_better(a: &Individual, b: &Individual) -> bool {
481    let a_feas = a.max_violation <= 0.0;
482    let b_feas = b.max_violation <= 0.0;
483    match (a_feas, b_feas) {
484        (true, false) => true,
485        (false, true) => false,
486        (true, true) => a.fun < b.fun,
487        (false, false) => a.max_violation < b.max_violation,
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    #[test]
496    fn sphere_unconstrained() {
497        // f(x) = sum xi^2 — minimum at origin.
498        let f = |x: &Array1<f64>| x.iter().map(|v| v * v).sum::<f64>();
499        let cfg = IsresConfig {
500            bounds: vec![(-5.0, 5.0); 4],
501            mu: 20,
502            lambda: 100,
503            maxeval: 8_000,
504            seed: Some(42),
505            ..Default::default()
506        };
507        let report = isres(&f, &[], cfg).expect("isres failed");
508        assert!(
509            report.fun < 1e-3,
510            "fun = {} should converge near 0",
511            report.fun
512        );
513        for &xi in report.x.iter() {
514            assert!(xi.abs() < 0.05, "xi = {} should be near 0", xi);
515        }
516    }
517
518    #[test]
519    fn sphere_with_inequality() {
520        // Minimize ||x||^2 subject to x[0] + x[1] >= 1
521        // (i.e. constraint  1 - x[0] - x[1] <= 0).
522        // Optimum at x = (0.5, 0.5, 0, ..., 0) with f = 0.5.
523        let f = |x: &Array1<f64>| x.iter().map(|v| v * v).sum::<f64>();
524        let constraints = vec![IsresConstraint {
525            fun: Arc::new(|x: &Array1<f64>| 1.0 - x[0] - x[1]),
526        }];
527        let cfg = IsresConfig {
528            bounds: vec![(-5.0, 5.0); 4],
529            mu: 30,
530            lambda: 200,
531            maxeval: 30_000,
532            seed: Some(7),
533            ..Default::default()
534        };
535        let report = isres(&f, &constraints, cfg).expect("isres failed");
536        assert!(report.feasible, "best should be feasible");
537        // Generous tolerance — ISRES is stochastic and convergence on
538        // small budgets isn't guaranteed to hit the analytical optimum.
539        assert!(
540            report.fun < 0.7,
541            "fun = {} should converge near 0.5",
542            report.fun
543        );
544    }
545
546    #[test]
547    fn rejects_inverted_bounds() {
548        let f = |x: &Array1<f64>| x[0] * x[0];
549        let cfg = IsresConfig {
550            bounds: vec![(1.0, -1.0)],
551            mu: 10,
552            lambda: 30,
553            maxeval: 100,
554            ..Default::default()
555        };
556        let err = isres(&f, &[], cfg).unwrap_err();
557        matches!(err, DEError::InvalidBounds { .. });
558    }
559
560    #[test]
561    fn rejects_tiny_population() {
562        let f = |x: &Array1<f64>| x[0] * x[0];
563        let cfg = IsresConfig {
564            bounds: vec![(-1.0, 1.0)],
565            mu: 1,
566            lambda: 10,
567            maxeval: 100,
568            ..Default::default()
569        };
570        let err = isres(&f, &[], cfg).unwrap_err();
571        matches!(err, DEError::PopulationTooSmall { .. });
572    }
573
574    #[test]
575    fn deterministic_with_seed() {
576        let f = |x: &Array1<f64>| x.iter().map(|v| v * v).sum::<f64>();
577        let cfg1 = IsresConfig {
578            bounds: vec![(-5.0, 5.0); 3],
579            mu: 15,
580            lambda: 80,
581            maxeval: 2_000,
582            seed: Some(123),
583            ..Default::default()
584        };
585        let cfg2 = cfg1.clone();
586        let r1 = isres(&f, &[], cfg1).unwrap();
587        let r2 = isres(&f, &[], cfg2).unwrap();
588        assert!((r1.fun - r2.fun).abs() < 1e-12, "seeded runs must match");
589    }
590}