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>(f: &F, constraints: &[IsresConstraint], config: IsresConfig) -> Result<IsresReport>
149where
150    F: Fn(&Array1<f64>) -> f64 + Sync,
151{
152    let n = config.bounds.len();
153    if n == 0 {
154        return Err(DEError::BoundsMismatch {
155            lower_len: 0,
156            upper_len: 0,
157        });
158    }
159    for (i, (lo, hi)) in config.bounds.iter().enumerate() {
160        if lo > hi {
161            return Err(DEError::InvalidBounds {
162                index: i,
163                lower: *lo,
164                upper: *hi,
165            });
166        }
167    }
168    if config.mu < 2 {
169        return Err(DEError::PopulationTooSmall {
170            pop_size: config.mu,
171        });
172    }
173
174    let mu = config.mu;
175    let lambda = if config.lambda == 0 {
176        7 * mu
177    } else {
178        config.lambda
179    };
180    if lambda < mu {
181        return Err(DEError::PopulationTooSmall { pop_size: lambda });
182    }
183
184    // Step-size adaptation parameters (R&Y 2005 §III.A).
185    let n_f = n as f64;
186    let tau = 1.0 / (2.0 * n_f.sqrt()).sqrt();
187    let tau_prime = 1.0 / (2.0 * n_f).sqrt();
188    // R&Y suggest σ_max = 0.2 * (hi - lo). Using 1/√n as a clip prevents
189    // total saturation while letting individual steps reach a meaningful
190    // fraction of the bound span.
191    let sigma_max: Vec<f64> = config
192        .bounds
193        .iter()
194        .map(|(lo, hi)| ((hi - lo) * 0.2).max(1e-12))
195        .collect();
196
197    let mut rng: StdRng = match config.seed {
198        Some(s) => StdRng::seed_from_u64(s),
199        None => {
200            let mut thread_rng = rand::rng();
201            StdRng::from_rng(&mut thread_rng)
202        }
203    };
204
205    let mut nfev = 0usize;
206    let mut population: Vec<Individual> = Vec::with_capacity(mu);
207
208    // Initial μ parents — uniform random within bounds, σ initialised to
209    // sigma_max / √n so per-dim Gaussian steps don't immediately blow out
210    // of the box.
211    let initial_sigma: Vec<f64> = sigma_max
212        .iter()
213        .map(|&sm| (sm / n_f.sqrt()).max(1e-12))
214        .collect();
215    for i in 0..mu {
216        let mut x = Array1::<f64>::zeros(n);
217        // Optionally seed the first parent with x0.
218        if i == 0
219            && let Some(ref seed) = config.x0
220            && seed.len() == n
221        {
222            for j in 0..n {
223                let (lo, hi) = config.bounds[j];
224                x[j] = seed[j].clamp(lo, hi);
225            }
226        } else {
227            for j in 0..n {
228                let (lo, hi) = config.bounds[j];
229                x[j] = lo + rng.random::<f64>() * (hi - lo);
230            }
231        }
232        let sigma = Array1::from(initial_sigma.clone());
233        let fun = f(&x);
234        let mv = max_violation_of(&x, constraints);
235        nfev += 1;
236        population.push(Individual {
237            x,
238            sigma,
239            fun,
240            max_violation: mv,
241        });
242        if nfev >= config.maxeval {
243            break;
244        }
245    }
246    // If maxeval was tiny we may have under-filled; pad by cloning best.
247    while population.len() < mu {
248        let last = population.last().cloned().unwrap();
249        population.push(last);
250    }
251
252    // Track global best — ISRES doesn't guarantee monotonic best-of-pop,
253    // so we shadow the best-seen individual independently.
254    let mut best = best_individual(&population).clone();
255    let mut nit = 0usize;
256    let mut stagnation_counter = 0usize;
257    let mut last_best_fun = best.fun;
258    let mut terminated_by_stagnation = false;
259
260    'outer: while nfev < config.maxeval {
261        nit += 1;
262
263        // Generate λ offspring. Each offspring picks a parent uniformly
264        // from the μ-strong parent pool (after sorting by stochastic
265        // ranking, the top μ are the parents — see Step 2 below).
266        let mut offspring: Vec<Individual> = Vec::with_capacity(lambda);
267        for k in 0..lambda {
268            // Differential variation parent (R&Y 2005, Eq. 2): pair the
269            // k-th offspring with the parent at the same index in the
270            // sorted population, then add (x_best − x_paired) at a random
271            // direction with probability γ. We use parent[k mod μ] as the
272            // "main" parent, and a separate random parent as the diff peer.
273            let parent_idx = k % mu;
274            let parent = &population[parent_idx];
275
276            // Step-size mutation (log-normal; clip to sigma_max).
277            let n_global: f64 = standard_normal(&mut rng);
278            let mut sigma_new = parent.sigma.clone();
279            for j in 0..n {
280                let n_local: f64 = standard_normal(&mut rng);
281                let mult = (tau_prime * n_global + tau * n_local).exp();
282                sigma_new[j] = (parent.sigma[j] * mult).min(sigma_max[j]).max(1e-30);
283            }
284
285            // Differential variation: add γ * (x_p1 − x_p2) where p1, p2
286            // are randomly chosen parents (with replacement). R&Y use the
287            // top-1 parent and a random one to bias toward feasibility.
288            let mut x_new = parent.x.clone();
289            if rng.random::<f64>() < config.gamma && mu >= 3 {
290                let p1 = rng.random_range(0..mu.min(mu / 2 + 1).max(2)); // top half
291                let p2 = rng.random_range(0..mu);
292                if p1 != p2 {
293                    let blend: f64 = rng.random::<f64>();
294                    for j in 0..n {
295                        x_new[j] += blend * (population[p1].x[j] - population[p2].x[j]);
296                    }
297                }
298            }
299
300            // Gaussian perturbation by σ'.
301            for j in 0..n {
302                let n_j: f64 = standard_normal(&mut rng);
303                x_new[j] += sigma_new[j] * n_j;
304            }
305
306            // Reflect into bounds (preserve diversity better than clip).
307            for j in 0..n {
308                let (lo, hi) = config.bounds[j];
309                x_new[j] = reflect_into_bounds(x_new[j], lo, hi);
310            }
311
312            let fun = f(&x_new);
313            let mv = max_violation_of(&x_new, constraints);
314            nfev += 1;
315            offspring.push(Individual {
316                x: x_new,
317                sigma: sigma_new,
318                fun,
319                max_violation: mv,
320            });
321            if nfev >= config.maxeval {
322                break;
323            }
324        }
325
326        // Stochastic ranking — bubble sort by interleaving fun-vs-violation.
327        stochastic_rank(&mut offspring, config.pf, &mut rng);
328
329        // Survival: top μ become next-gen parents.
330        offspring.truncate(mu);
331        population = offspring;
332
333        // Update global best.
334        let gen_best = best_individual(&population);
335        if individual_better(gen_best, &best) {
336            best = gen_best.clone();
337        }
338
339        // Stagnation check.
340        if config.f_tol > 0.0 {
341            let delta = (last_best_fun - best.fun).abs();
342            if delta < config.f_tol {
343                stagnation_counter += 1;
344                if stagnation_counter >= config.stagnation_window {
345                    terminated_by_stagnation = true;
346                    break 'outer;
347                }
348            } else {
349                stagnation_counter = 0;
350                last_best_fun = best.fun;
351            }
352        }
353    }
354
355    let feasible = best.max_violation <= 0.0;
356    let message = if terminated_by_stagnation {
357        format!(
358            "Converged: best fitness stagnated within tol={:.1e} for {} generations",
359            config.f_tol, config.stagnation_window
360        )
361    } else if nfev >= config.maxeval {
362        format!("Maximum evaluations reached: {}", config.maxeval)
363    } else {
364        "Iteration limit reached".to_string()
365    };
366
367    Ok(IsresReport {
368        x: best.x,
369        fun: best.fun,
370        max_violation: best.max_violation.max(0.0),
371        feasible,
372        success: terminated_by_stagnation,
373        message,
374        nfev,
375        nit,
376    })
377}
378
379/// Maximum constraint violation across all constraints. Zero means feasible.
380fn max_violation_of(x: &Array1<f64>, constraints: &[IsresConstraint]) -> f64 {
381    let mut worst = 0.0_f64;
382    for c in constraints {
383        let v = (c.fun)(x);
384        if v > worst {
385            worst = v;
386        }
387    }
388    worst
389}
390
391/// Standard normal sample via Box-Muller (only the cosine half is used —
392/// the sine half is discarded for simplicity; for ISRES the small loss in
393/// efficiency is negligible compared to objective cost).
394fn standard_normal<R: Rng + ?Sized>(rng: &mut R) -> f64 {
395    let u1: f64 = rng.random::<f64>().max(1e-300);
396    let u2: f64 = rng.random::<f64>();
397    (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
398}
399
400/// Reflect `v` into `[lo, hi]`. If `v` falls outside, mirror it back across
401/// the violated boundary; if it overshoots after one reflection, clamp.
402fn reflect_into_bounds(v: f64, lo: f64, hi: f64) -> f64 {
403    if !v.is_finite() {
404        return (lo + hi) * 0.5;
405    }
406    if v < lo {
407        let r = lo + (lo - v);
408        if r > hi { (lo + hi) * 0.5 } else { r }
409    } else if v > hi {
410        let r = hi - (v - hi);
411        if r < lo { (lo + hi) * 0.5 } else { r }
412    } else {
413        v
414    }
415}
416
417/// Stochastic ranking (Runarsson & Yao 2000/2005). Bubble-sort the slice;
418/// at each adjacent comparison, with probability `pf` compare by objective,
419/// otherwise by `max_violation`. The pass repeats until no swap occurs in
420/// a full sweep (classic bubble-sort termination).
421fn stochastic_rank<R: Rng + ?Sized>(pop: &mut [Individual], pf: f64, rng: &mut R) {
422    let n = pop.len();
423    if n < 2 {
424        return;
425    }
426    // Cap passes at n − 1 (worst case for bubble sort) to bound time.
427    for _ in 0..n.saturating_sub(1) {
428        let mut swapped = false;
429        for i in 0..(n - 1) {
430            // If both are feasible, always compare by objective; if both are
431            // infeasible, always compare by violation. Otherwise apply the
432            // pf coin flip — this is the original R&Y prescription.
433            let a = &pop[i];
434            let b = &pop[i + 1];
435            let both_feasible = a.max_violation <= 0.0 && b.max_violation <= 0.0;
436            let both_infeasible = a.max_violation > 0.0 && b.max_violation > 0.0;
437            let compare_by_fun = if both_feasible {
438                true
439            } else if both_infeasible {
440                rng.random::<f64>() < pf
441            } else {
442                // One feasible, one not — feasible always wins (feasibility
443                // is treated as an absolute preference outside of ranking).
444                false
445            };
446
447            let need_swap = if compare_by_fun {
448                a.fun > b.fun
449            } else {
450                a.max_violation > b.max_violation
451            };
452
453            if need_swap {
454                pop.swap(i, i + 1);
455                swapped = true;
456            }
457        }
458        if !swapped {
459            break;
460        }
461    }
462}
463
464/// Find the population's "best" individual using the same lexicographic
465/// preference as `individual_better`.
466fn best_individual(pop: &[Individual]) -> &Individual {
467    pop.iter()
468        .min_by(|a, b| {
469            if individual_better(a, b) {
470                std::cmp::Ordering::Less
471            } else if individual_better(b, a) {
472                std::cmp::Ordering::Greater
473            } else {
474                std::cmp::Ordering::Equal
475            }
476        })
477        .expect("non-empty population")
478}
479
480/// Lexicographic better: feasible beats infeasible; among feasible, lower
481/// objective wins; among infeasible, lower violation wins.
482fn individual_better(a: &Individual, b: &Individual) -> bool {
483    let a_feas = a.max_violation <= 0.0;
484    let b_feas = b.max_violation <= 0.0;
485    match (a_feas, b_feas) {
486        (true, false) => true,
487        (false, true) => false,
488        (true, true) => a.fun < b.fun,
489        (false, false) => a.max_violation < b.max_violation,
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    #[test]
498    fn sphere_unconstrained() {
499        // f(x) = sum xi^2 — minimum at origin.
500        let f = |x: &Array1<f64>| x.iter().map(|v| v * v).sum::<f64>();
501        let cfg = IsresConfig {
502            bounds: vec![(-5.0, 5.0); 4],
503            mu: 20,
504            lambda: 100,
505            maxeval: 8_000,
506            seed: Some(42),
507            ..Default::default()
508        };
509        let report = isres(&f, &[], cfg).expect("isres failed");
510        assert!(
511            report.fun < 1e-3,
512            "fun = {} should converge near 0",
513            report.fun
514        );
515        for &xi in report.x.iter() {
516            assert!(xi.abs() < 0.05, "xi = {} should be near 0", xi);
517        }
518    }
519
520    #[test]
521    fn sphere_with_inequality() {
522        // Minimize ||x||^2 subject to x[0] + x[1] >= 1
523        // (i.e. constraint  1 - x[0] - x[1] <= 0).
524        // Optimum at x = (0.5, 0.5, 0, ..., 0) with f = 0.5.
525        let f = |x: &Array1<f64>| x.iter().map(|v| v * v).sum::<f64>();
526        let constraints = vec![IsresConstraint {
527            fun: Arc::new(|x: &Array1<f64>| 1.0 - x[0] - x[1]),
528        }];
529        let cfg = IsresConfig {
530            bounds: vec![(-5.0, 5.0); 4],
531            mu: 30,
532            lambda: 200,
533            maxeval: 30_000,
534            seed: Some(7),
535            ..Default::default()
536        };
537        let report = isres(&f, &constraints, cfg).expect("isres failed");
538        assert!(report.feasible, "best should be feasible");
539        // Generous tolerance — ISRES is stochastic and convergence on
540        // small budgets isn't guaranteed to hit the analytical optimum.
541        assert!(
542            report.fun < 0.7,
543            "fun = {} should converge near 0.5",
544            report.fun
545        );
546    }
547
548    #[test]
549    fn rejects_inverted_bounds() {
550        let f = |x: &Array1<f64>| x[0] * x[0];
551        let cfg = IsresConfig {
552            bounds: vec![(1.0, -1.0)],
553            mu: 10,
554            lambda: 30,
555            maxeval: 100,
556            ..Default::default()
557        };
558        let err = isres(&f, &[], cfg).unwrap_err();
559        matches!(err, DEError::InvalidBounds { .. });
560    }
561
562    #[test]
563    fn rejects_tiny_population() {
564        let f = |x: &Array1<f64>| x[0] * x[0];
565        let cfg = IsresConfig {
566            bounds: vec![(-1.0, 1.0)],
567            mu: 1,
568            lambda: 10,
569            maxeval: 100,
570            ..Default::default()
571        };
572        let err = isres(&f, &[], cfg).unwrap_err();
573        matches!(err, DEError::PopulationTooSmall { .. });
574    }
575
576    #[test]
577    fn deterministic_with_seed() {
578        let f = |x: &Array1<f64>| x.iter().map(|v| v * v).sum::<f64>();
579        let cfg1 = IsresConfig {
580            bounds: vec![(-5.0, 5.0); 3],
581            mu: 15,
582            lambda: 80,
583            maxeval: 2_000,
584            seed: Some(123),
585            ..Default::default()
586        };
587        let cfg2 = cfg1.clone();
588        let r1 = isres(&f, &[], cfg1).unwrap();
589        let r2 = isres(&f, &[], cfg2).unwrap();
590        assert!((r1.fun - r2.fun).abs() < 1e-12, "seeded runs must match");
591    }
592}