Skip to main content

scirs2_optimize/global/
differential_evolution.rs

1//! Differential Evolution algorithm for global optimization
2//!
3//! The differential evolution method is a stochastic global optimization
4//! algorithm that does not use gradient methods to find the minimum and
5//! can search large areas of candidate space.
6
7use crate::error::OptimizeError;
8use crate::global::qmc::SobolGenerator;
9use crate::parallel::{parallel_evaluate_batch, ParallelOptions};
10use crate::unconstrained::{
11    minimize, Bounds as UnconstrainedBounds, Method, OptimizeResult, Options,
12};
13use scirs2_core::ndarray::{Array1, ArrayView1};
14use scirs2_core::random::prelude::SliceRandom;
15use scirs2_core::random::rngs::StdRng;
16use scirs2_core::random::Random;
17use scirs2_core::random::Uniform;
18use scirs2_core::random::{Rng, RngExt, SeedableRng};
19
20/// Options for Differential Evolution algorithm
21#[derive(Debug, Clone)]
22pub struct DifferentialEvolutionOptions {
23    /// Maximum number of generations
24    pub maxiter: usize,
25    /// Population size (as a multiple of the number of parameters or absolute size)
26    pub popsize: usize,
27    /// The tolerance for convergence
28    pub tol: f64,
29    /// The mutation coefficient (F) or tuple of (lower_bound, upper_bound)
30    pub mutation: (f64, f64),
31    /// The recombination coefficient (CR)
32    pub recombination: f64,
33    /// Whether to polish the best solution with local optimization
34    pub polish: bool,
35    /// Initial population method: "latinhypercube", "halton", "sobol", or "random"
36    pub init: String,
37    /// Absolute tolerance for convergence
38    pub atol: f64,
39    /// Strategy for updating the population: "immediate" or "deferred"
40    pub updating: String,
41    /// Random seed for reproducibility
42    pub seed: Option<u64>,
43    /// Initial guess
44    pub x0: Option<Array1<f64>>,
45    /// Parallel computation options
46    pub parallel: Option<ParallelOptions>,
47}
48
49impl Default for DifferentialEvolutionOptions {
50    fn default() -> Self {
51        Self {
52            maxiter: 1000,
53            popsize: 15,
54            tol: 0.01,
55            mutation: (0.5, 1.0),
56            recombination: 0.7,
57            polish: true,
58            init: "latinhypercube".to_string(),
59            atol: 0.0,
60            updating: "immediate".to_string(),
61            seed: None,
62            x0: None,
63            parallel: None,
64        }
65    }
66}
67
68/// Strategy names for mutation
69#[derive(Debug, Clone, Copy, PartialEq)]
70pub enum Strategy {
71    Best1Bin,
72    Best1Exp,
73    Rand1Bin,
74    Rand1Exp,
75    Best2Bin,
76    Best2Exp,
77    Rand2Bin,
78    Rand2Exp,
79    CurrentToBest1Bin,
80    CurrentToBest1Exp,
81}
82
83impl Strategy {
84    fn from_str(s: &str) -> Option<Self> {
85        match s {
86            "best1bin" => Some(Strategy::Best1Bin),
87            "best1exp" => Some(Strategy::Best1Exp),
88            "rand1bin" => Some(Strategy::Rand1Bin),
89            "rand1exp" => Some(Strategy::Rand1Exp),
90            "best2bin" => Some(Strategy::Best2Bin),
91            "best2exp" => Some(Strategy::Best2Exp),
92            "rand2bin" => Some(Strategy::Rand2Bin),
93            "rand2exp" => Some(Strategy::Rand2Exp),
94            "currenttobest1bin" => Some(Strategy::CurrentToBest1Bin),
95            "currenttobest1exp" => Some(Strategy::CurrentToBest1Exp),
96            _ => None,
97        }
98    }
99}
100
101/// Bounds for variables in differential evolution
102pub type Bounds = Vec<(f64, f64)>;
103
104/// Differential Evolution solver
105pub struct DifferentialEvolution<F>
106where
107    F: Fn(&ArrayView1<f64>) -> f64 + Clone + Sync,
108{
109    func: F,
110    bounds: Bounds,
111    options: DifferentialEvolutionOptions,
112    strategy: Strategy,
113    ndim: usize,
114    population: Array2<f64>,
115    energies: Array1<f64>,
116    best_energy: f64,
117    best_idx: usize,
118    rng: Random<StdRng>,
119    nfev: usize,
120}
121
122use scirs2_core::ndarray::Array2;
123
124impl<F> DifferentialEvolution<F>
125where
126    F: Fn(&ArrayView1<f64>) -> f64 + Clone + Sync,
127{
128    /// Create new Differential Evolution solver
129    pub fn new(
130        func: F,
131        bounds: Bounds,
132        options: DifferentialEvolutionOptions,
133        strategy: &str,
134    ) -> Self {
135        let ndim = bounds.len();
136        let popsize = if options.popsize < ndim {
137            options.popsize * ndim
138        } else {
139            options.popsize
140        };
141
142        let seed = options
143            .seed
144            .unwrap_or_else(|| scirs2_core::random::rng().random());
145        let rng = Random::seed(seed);
146
147        let strategy_enum = Strategy::from_str(strategy).unwrap_or(Strategy::Best1Bin);
148
149        let mut solver = Self {
150            func,
151            bounds,
152            options,
153            strategy: strategy_enum,
154            ndim,
155            population: Array2::zeros((popsize, ndim)),
156            energies: Array1::zeros(popsize),
157            best_energy: f64::INFINITY,
158            best_idx: 0,
159            rng,
160            nfev: 0,
161        };
162
163        // Validate bounds
164        solver.validate_bounds();
165        solver.init_population();
166        solver
167    }
168
169    /// Validate that bounds are properly specified
170    fn validate_bounds(&self) {
171        for (i, &(lb, ub)) in self.bounds.iter().enumerate() {
172            if !lb.is_finite() || !ub.is_finite() {
173                panic!(
174                    "Bounds must be finite values. Variable {}: bounds = ({}, {})",
175                    i, lb, ub
176                );
177            }
178            if lb >= ub {
179                panic!(
180                    "Lower bound must be less than upper bound. Variable {}: lb = {}, ub = {}",
181                    i, lb, ub
182                );
183            }
184            if (ub - lb) < 1e-12 {
185                panic!(
186                    "Bounds range is too small. Variable {}: range = {}",
187                    i,
188                    ub - lb
189                );
190            }
191        }
192    }
193
194    /// Initialize the population
195    fn init_population(&mut self) {
196        let popsize = self.population.nrows();
197
198        // Initialize population based on initialization method
199        match self.options.init.as_str() {
200            "latinhypercube" => self.init_latinhypercube(),
201            "halton" => self.init_halton(),
202            "sobol" => self.init_sobol(),
203            _ => self.init_random(),
204        }
205
206        // If x0 is provided, replace one member with it (bounds-checked)
207        if let Some(x0) = self.options.x0.clone() {
208            for (i, &val) in x0.iter().enumerate() {
209                self.population[[0, i]] = self.ensure_bounds(i, val);
210            }
211        }
212
213        // Evaluate initial population
214        if self.options.parallel.is_some() {
215            // Parallel evaluation
216            let candidates: Vec<Array1<f64>> = (0..popsize)
217                .map(|i| self.population.row(i).to_owned())
218                .collect();
219
220            // Extract parallel options for evaluation
221            let parallel_opts = self.options.parallel.as_ref().expect("Operation failed");
222
223            let energies = parallel_evaluate_batch(&self.func, &candidates, parallel_opts);
224            self.energies = Array1::from_vec(energies);
225            self.nfev += popsize;
226
227            // Find best
228            for i in 0..popsize {
229                if self.energies[i] < self.best_energy {
230                    self.best_energy = self.energies[i];
231                    self.best_idx = i;
232                }
233            }
234        } else {
235            // Sequential evaluation
236            for i in 0..popsize {
237                let candidate = self.population.row(i);
238                self.energies[i] = (self.func)(&candidate);
239                self.nfev += 1;
240
241                if self.energies[i] < self.best_energy {
242                    self.best_energy = self.energies[i];
243                    self.best_idx = i;
244                }
245            }
246        }
247    }
248
249    /// Initialize population with random values
250    fn init_random(&mut self) {
251        let popsize = self.population.nrows();
252
253        for i in 0..popsize {
254            for j in 0..self.ndim {
255                let (lb, ub) = self.bounds[j];
256                let uniform = Uniform::new(lb, ub).expect("Operation failed");
257                self.population[[i, j]] = self.rng.sample(uniform);
258            }
259        }
260    }
261
262    /// Initialize population using Latin hypercube sampling
263    fn init_latinhypercube(&mut self) {
264        let popsize = self.population.nrows();
265
266        // Create segments for each dimension
267        for j in 0..self.ndim {
268            let (lb, ub) = self.bounds[j];
269            let segment_size = (ub - lb) / popsize as f64;
270
271            let mut segments: Vec<usize> = (0..popsize).collect();
272            segments.shuffle(&mut self.rng);
273
274            for (i, &seg) in segments.iter().enumerate() {
275                let segment_lb = lb + seg as f64 * segment_size;
276                let segment_ub = segment_lb + segment_size;
277                let uniform = Uniform::new(segment_lb, segment_ub).expect("Operation failed");
278                self.population[[i, j]] = self.rng.sample(uniform);
279            }
280        }
281    }
282
283    /// Initialize population using Halton sequence
284    fn init_halton(&mut self) {
285        let primes: [u64; 25] = [
286            2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83,
287            89, 97,
288        ];
289
290        let popsize = self.population.nrows();
291        for i in 0..popsize {
292            for j in 0..self.ndim {
293                // Use the (i+1)th term of the Halton sequence for base primes[j % primes.len()]
294                let base = primes[j % primes.len()];
295                let halton_value = crate::global::qmc::halton_radical_inverse(i + 1, base);
296
297                // Scale to bounds
298                let (lb, ub) = self.bounds[j];
299                self.population[[i, j]] = lb + halton_value * (ub - lb);
300            }
301        }
302    }
303
304    /// Initialize population using a Sobol sequence (validated direction
305    /// numbers for the first [`crate::global::qmc::MAX_SOBOL_DIM`]
306    /// dimensions, falling back to a genuine Halton sequence -- not plain
307    /// randomness -- beyond that; see [`crate::global::qmc`]).
308    fn init_sobol(&mut self) {
309        let mut sobol_gen = SobolGenerator::new(self.ndim);
310
311        let popsize = self.population.nrows();
312        for i in 0..popsize {
313            let sobol_point = sobol_gen.next_point();
314            for j in 0..self.ndim {
315                let (lb, ub) = self.bounds[j];
316                self.population[[i, j]] = lb + sobol_point[j] * (ub - lb);
317            }
318        }
319    }
320
321    /// Ensure bounds for a parameter using reflection method
322    fn ensure_bounds(&mut self, idx: usize, val: f64) -> f64 {
323        let (lb, ub) = self.bounds[idx];
324
325        if val >= lb && val <= ub {
326            // Value is within bounds
327            val
328        } else if val < lb {
329            // Reflect around lower bound
330            let excess = lb - val;
331            let range = ub - lb;
332            if excess <= range {
333                lb + excess
334            } else {
335                // If reflection goes beyond upper bound, use random value in range
336                self.rng.random_range(lb..ub)
337            }
338        } else {
339            // val > ub..reflect around upper bound
340            let excess = val - ub;
341            let range = ub - lb;
342            if excess <= range {
343                ub - excess
344            } else {
345                // If reflection goes beyond lower bound, use random value in range
346                self.rng.random_range(lb..ub)
347            }
348        }
349    }
350
351    /// Create mutant vector using differential evolution
352    fn create_mutant(&mut self, candidate_idx: usize) -> Array1<f64> {
353        let popsize = self.population.nrows();
354        let mut mutant = Array1::zeros(self.ndim);
355
356        // Select indices for mutation
357        let mut indices: Vec<usize> = Vec::with_capacity(5);
358        while indices.len() < 5 {
359            let idx = self.rng.random_range(0..popsize);
360            if idx != candidate_idx && !indices.contains(&idx) {
361                indices.push(idx);
362            }
363        }
364
365        let mutation_factor = if self.options.mutation.0 == self.options.mutation.1 {
366            self.options.mutation.0
367        } else {
368            self.rng
369                .random_range(self.options.mutation.0..self.options.mutation.1)
370        };
371
372        match self.strategy {
373            Strategy::Best1Bin | Strategy::Best1Exp => {
374                // mutant = best + F * (r1 - r2)
375                let best = self.population.row(self.best_idx);
376                let r1 = self.population.row(indices[0]);
377                let r2 = self.population.row(indices[1]);
378                for i in 0..self.ndim {
379                    mutant[i] = best[i] + mutation_factor * (r1[i] - r2[i]);
380                }
381            }
382            Strategy::Rand1Bin | Strategy::Rand1Exp => {
383                // mutant = r0 + F * (r1 - r2)
384                let r0 = self.population.row(indices[0]);
385                let r1 = self.population.row(indices[1]);
386                let r2 = self.population.row(indices[2]);
387                for i in 0..self.ndim {
388                    mutant[i] = r0[i] + mutation_factor * (r1[i] - r2[i]);
389                }
390            }
391            Strategy::Best2Bin | Strategy::Best2Exp => {
392                // mutant = best + F * (r1 - r2) + F * (r3 - r4)
393                let best = self.population.row(self.best_idx);
394                let r1 = self.population.row(indices[0]);
395                let r2 = self.population.row(indices[1]);
396                let r3 = self.population.row(indices[2]);
397                let r4 = self.population.row(indices[3]);
398                for i in 0..self.ndim {
399                    mutant[i] = best[i]
400                        + mutation_factor * (r1[i] - r2[i])
401                        + mutation_factor * (r3[i] - r4[i]);
402                }
403            }
404            Strategy::Rand2Bin | Strategy::Rand2Exp => {
405                // mutant = r0 + F * (r1 - r2) + F * (r3 - r4)
406                let r0 = self.population.row(indices[0]);
407                let r1 = self.population.row(indices[1]);
408                let r2 = self.population.row(indices[2]);
409                let r3 = self.population.row(indices[3]);
410                let r4 = self.population.row(indices[4]);
411                for i in 0..self.ndim {
412                    mutant[i] = r0[i]
413                        + mutation_factor * (r1[i] - r2[i])
414                        + mutation_factor * (r3[i] - r4[i]);
415                }
416            }
417            Strategy::CurrentToBest1Bin | Strategy::CurrentToBest1Exp => {
418                // mutant = current + F * (best - current) + F * (r1 - r2)
419                let current = self.population.row(candidate_idx);
420                let best = self.population.row(self.best_idx);
421                let r1 = self.population.row(indices[0]);
422                let r2 = self.population.row(indices[1]);
423                for i in 0..self.ndim {
424                    mutant[i] = current[i]
425                        + mutation_factor * (best[i] - current[i])
426                        + mutation_factor * (r1[i] - r2[i]);
427                }
428            }
429        }
430
431        // Apply bounds checking after all calculations are done
432        for i in 0..self.ndim {
433            mutant[i] = self.ensure_bounds(i, mutant[i]);
434        }
435
436        mutant
437    }
438
439    /// Create trial vector using crossover
440    fn create_trial(&mut self, candidate_idx: usize, mutant: &Array1<f64>) -> Array1<f64> {
441        let candidate = self.population.row(candidate_idx).to_owned();
442        let mut trial = candidate.clone();
443
444        match self.strategy {
445            Strategy::Best1Bin
446            | Strategy::Rand1Bin
447            | Strategy::Best2Bin
448            | Strategy::Rand2Bin
449            | Strategy::CurrentToBest1Bin => {
450                // Binomial crossover
451                let randn = self.rng.random_range(0..self.ndim);
452                for i in 0..self.ndim {
453                    if i == randn || self.rng.random_range(0.0..1.0) < self.options.recombination {
454                        trial[i] = mutant[i];
455                    }
456                }
457            }
458            Strategy::Best1Exp
459            | Strategy::Rand1Exp
460            | Strategy::Best2Exp
461            | Strategy::Rand2Exp
462            | Strategy::CurrentToBest1Exp => {
463                // Exponential crossover
464                let randn = self.rng.random_range(0..self.ndim);
465                let mut i = randn;
466                loop {
467                    trial[i] = mutant[i];
468                    i = (i + 1) % self.ndim;
469                    if i == randn || self.rng.random_range(0.0..1.0) >= self.options.recombination {
470                        break;
471                    }
472                }
473            }
474        }
475
476        // Ensure all trial elements are within bounds after crossover
477        for i in 0..self.ndim {
478            trial[i] = self.ensure_bounds(i, trial[i]);
479        }
480
481        trial
482    }
483
484    /// Run one generation of the algorithm
485    fn evolve(&mut self) -> bool {
486        let popsize = self.population.nrows();
487        let mut converged = true;
488
489        if self.options.parallel.is_some() {
490            // First, generate all mutants and trials
491            let mut trials_and_indices: Vec<(Array1<f64>, usize)> = Vec::with_capacity(popsize);
492            for idx in 0..popsize {
493                let mutant = self.create_mutant(idx);
494                let trial = self.create_trial(idx, &mutant);
495                trials_and_indices.push((trial, idx));
496            }
497
498            // Extract just the trials for batch evaluation
499            let trials: Vec<Array1<f64>> = trials_and_indices
500                .iter()
501                .map(|(trial_, _)| trial_.clone())
502                .collect();
503
504            // Extract the parallel options for evaluation
505            let parallel_opts = self.options.parallel.as_ref().expect("Operation failed");
506
507            // Evaluate all trials in parallel
508            let trial_energies = parallel_evaluate_batch(&self.func, &trials, parallel_opts);
509            self.nfev += popsize;
510
511            // Process results
512            for ((trial, idx), trial_energy) in
513                trials_and_indices.into_iter().zip(trial_energies.iter())
514            {
515                if *trial_energy < self.energies[idx] && self.options.updating == "immediate" {
516                    for i in 0..self.ndim {
517                        self.population[[idx, i]] = trial[i];
518                    }
519                    self.energies[idx] = *trial_energy;
520
521                    if *trial_energy < self.best_energy {
522                        self.best_energy = *trial_energy;
523                        self.best_idx = idx;
524                    }
525                }
526
527                // Check convergence
528                let diff = (self.energies[idx] - self.best_energy).abs();
529                if diff > self.options.tol + self.options.atol {
530                    converged = false;
531                }
532            }
533        } else {
534            // Sequential evolution
535            for idx in 0..popsize {
536                let mutant = self.create_mutant(idx);
537                let trial = self.create_trial(idx, &mutant);
538
539                let trial_energy = (self.func)(&trial.view());
540                self.nfev += 1;
541
542                if trial_energy < self.energies[idx] && self.options.updating == "immediate" {
543                    for i in 0..self.ndim {
544                        self.population[[idx, i]] = trial[i];
545                    }
546                    self.energies[idx] = trial_energy;
547
548                    if trial_energy < self.best_energy {
549                        self.best_energy = trial_energy;
550                        self.best_idx = idx;
551                    }
552                }
553
554                // Check convergence
555                let diff = (self.energies[idx] - self.best_energy).abs();
556                if diff > self.options.tol + self.options.atol {
557                    converged = false;
558                }
559            }
560        }
561
562        converged
563    }
564
565    /// Run the differential evolution algorithm
566    pub fn run(&mut self) -> OptimizeResult<f64> {
567        let mut converged = false;
568        let mut nit = 0;
569
570        for _ in 0..self.options.maxiter {
571            converged = self.evolve();
572            nit += 1;
573
574            if converged {
575                break;
576            }
577        }
578
579        let mut result = OptimizeResult {
580            x: self.population.row(self.best_idx).to_owned(),
581            fun: self.best_energy,
582            nfev: self.nfev,
583            func_evals: self.nfev,
584            nit,
585            success: converged,
586            message: if converged {
587                "Optimization converged successfully"
588            } else {
589                "Maximum number of iterations reached"
590            }
591            .to_string(),
592            ..Default::default()
593        };
594
595        // Polish the result with local optimization if requested
596        if self.options.polish {
597            let bounds_vec: Vec<(f64, f64)> = self.bounds.clone();
598            let local_result = minimize(
599                |x| (self.func)(x),
600                &result.x.to_vec(),
601                Method::LBFGS,
602                Some(Options {
603                    bounds: Some(
604                        UnconstrainedBounds::from_vecs(
605                            bounds_vec.iter().map(|b| Some(b.0)).collect(),
606                            bounds_vec.iter().map(|b| Some(b.1)).collect(),
607                        )
608                        .expect("Operation failed"),
609                    ),
610                    ..Default::default()
611                }),
612            )
613            .expect("Operation failed");
614            if local_result.success && local_result.fun < result.fun {
615                // Ensure polished result respects bounds
616                let mut polished_x = local_result.x;
617                for (i, &(lb, ub)) in self.bounds.iter().enumerate() {
618                    polished_x[i] = polished_x[i].max(lb).min(ub);
619                }
620
621                // Only accept if still better after bounds enforcement
622                let polished_fun = (self.func)(&polished_x.view());
623                if polished_fun < result.fun {
624                    result.x = polished_x;
625                    result.fun = polished_fun;
626                    result.nfev += local_result.nfev + 1; // +1 for our re-evaluation
627                    result.func_evals = result.nfev;
628                }
629            }
630        }
631
632        result
633    }
634}
635
636/// Perform global optimization using differential evolution
637///
638/// # Arguments
639///
640/// * `func` - Objective function to minimize
641/// * `bounds` - Variable bounds as Vec<(lower, upper)>
642/// * `options` - DE configuration options
643/// * `strategy` - Mutation strategy name (e.g., "best1bin", "rand1bin")
644///
645/// # Returns
646///
647/// `OptimizeResult<f64>` with the optimization result
648#[allow(dead_code)]
649pub fn differential_evolution<F>(
650    func: F,
651    bounds: Bounds,
652    options: Option<DifferentialEvolutionOptions>,
653    strategy: Option<&str>,
654) -> Result<OptimizeResult<f64>, OptimizeError>
655where
656    F: Fn(&ArrayView1<f64>) -> f64 + Clone + Sync,
657{
658    if bounds.is_empty() {
659        return Err(OptimizeError::InvalidInput(
660            "Bounds must not be empty".to_string(),
661        ));
662    }
663    for (i, &(lb, ub)) in bounds.iter().enumerate() {
664        if !lb.is_finite() || !ub.is_finite() {
665            return Err(OptimizeError::InvalidInput(format!(
666                "Bounds must be finite for dimension {} ({}, {})",
667                i, lb, ub
668            )));
669        }
670        if lb >= ub {
671            return Err(OptimizeError::InvalidInput(format!(
672                "Lower bound must be less than upper bound for dimension {} ({} >= {})",
673                i, lb, ub
674            )));
675        }
676    }
677
678    let options = options.unwrap_or_default();
679    let strategy = strategy.unwrap_or("best1bin");
680
681    let mut solver = DifferentialEvolution::new(func, bounds, options, strategy);
682    Ok(solver.run())
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688    use approx::assert_abs_diff_eq;
689
690    fn sphere(x: &ArrayView1<f64>) -> f64 {
691        x.iter().map(|xi| xi * xi).sum()
692    }
693
694    fn rastrigin(x: &ArrayView1<f64>) -> f64 {
695        let n = x.len() as f64;
696        10.0 * n
697            + x.iter()
698                .map(|xi| xi * xi - 10.0 * (2.0 * std::f64::consts::PI * xi).cos())
699                .sum::<f64>()
700    }
701
702    fn rosenbrock(x: &ArrayView1<f64>) -> f64 {
703        let mut sum = 0.0;
704        for i in 0..x.len() - 1 {
705            sum += 100.0 * (x[i + 1] - x[i].powi(2)).powi(2) + (1.0 - x[i]).powi(2);
706        }
707        sum
708    }
709
710    #[test]
711    fn test_de_sphere_best1bin() {
712        let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
713        let mut opts = DifferentialEvolutionOptions::default();
714        opts.seed = Some(42);
715        opts.maxiter = 200;
716        opts.polish = false;
717
718        let result = differential_evolution(sphere, bounds, Some(opts), Some("best1bin"));
719        assert!(result.is_ok());
720        let res = result.expect("should succeed");
721        assert!(res.fun < 0.01, "Sphere min should be ~0, got {}", res.fun);
722    }
723
724    #[test]
725    fn test_de_sphere_rand1bin() {
726        let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
727        let mut opts = DifferentialEvolutionOptions::default();
728        opts.seed = Some(42);
729        opts.maxiter = 300;
730        opts.polish = false;
731
732        let result = differential_evolution(sphere, bounds, Some(opts), Some("rand1bin"));
733        assert!(result.is_ok());
734        let res = result.expect("should succeed");
735        assert!(
736            res.fun < 0.1,
737            "Sphere with rand1bin should be near 0, got {}",
738            res.fun
739        );
740    }
741
742    #[test]
743    fn test_de_currenttobest1bin() {
744        let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
745        let mut opts = DifferentialEvolutionOptions::default();
746        opts.seed = Some(42);
747        opts.maxiter = 300;
748        opts.polish = false;
749
750        let result = differential_evolution(sphere, bounds, Some(opts), Some("currenttobest1bin"));
751        assert!(result.is_ok());
752        let res = result.expect("should succeed");
753        assert!(
754            res.fun < 0.1,
755            "currenttobest1bin should be near 0, got {}",
756            res.fun
757        );
758    }
759
760    #[test]
761    fn test_de_rastrigin() {
762        let bounds = vec![(-5.12, 5.12), (-5.12, 5.12)];
763        let mut opts = DifferentialEvolutionOptions::default();
764        opts.seed = Some(42);
765        opts.popsize = 30;
766        opts.maxiter = 500;
767        opts.polish = false;
768
769        let result = differential_evolution(rastrigin, bounds, Some(opts), None);
770        assert!(result.is_ok());
771        let res = result.expect("should succeed");
772        assert!(
773            res.fun < 10.0,
774            "DE should find reasonable Rastrigin value, got {}",
775            res.fun
776        );
777    }
778
779    #[test]
780    fn test_de_rosenbrock() {
781        let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
782        let mut opts = DifferentialEvolutionOptions::default();
783        opts.seed = Some(42);
784        opts.popsize = 30;
785        opts.maxiter = 500;
786        opts.polish = false;
787
788        let result = differential_evolution(rosenbrock, bounds, Some(opts), None);
789        assert!(result.is_ok());
790        let res = result.expect("should succeed");
791        assert!(
792            res.fun < 5.0,
793            "DE should find reasonable Rosenbrock value, got {}",
794            res.fun
795        );
796    }
797
798    #[test]
799    fn test_de_with_initial_guess() {
800        let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
801        let mut opts = DifferentialEvolutionOptions::default();
802        opts.seed = Some(42);
803        opts.maxiter = 100;
804        opts.polish = false;
805        opts.x0 = Some(Array1::from_vec(vec![0.1, 0.1]));
806
807        let result = differential_evolution(sphere, bounds, Some(opts), None);
808        assert!(result.is_ok());
809        let res = result.expect("should succeed");
810        assert!(res.fun < 0.1);
811    }
812
813    #[test]
814    fn test_de_sobol_init_matches_validated_sobol_sequence() {
815        // Regression test for the ad-hoc "polynomial recurrence" SobolState
816        // this crate used to build its own direction numbers with (not a
817        // real Sobol sequence). `init_sobol` now delegates to
818        // `crate::global::qmc::SobolGenerator`, whose first two points are
819        // always exactly (0, 0, ...) and (0.5, 0.5, ...) -- verified
820        // bit-for-bit against `scipy.stats.qmc.Sobol` in the `qmc` module's
821        // own tests. The old ad-hoc generator's first two points did NOT
822        // land on these exact values (it used `1u32 << 31` as its first
823        // direction number, not the same recurrence), so this is a real,
824        // non-trivial check on the actual initial-population contents, not
825        // merely "did it fail to panic".
826        let bounds = vec![(0.0, 10.0), (0.0, 20.0), (-4.0, 4.0)];
827        let mut opts = DifferentialEvolutionOptions::default();
828        opts.seed = Some(1);
829        opts.init = "sobol".to_string();
830
831        let solver = DifferentialEvolution::new(sphere, bounds.clone(), opts, "best1bin");
832
833        // Sobol point 0 is exactly the origin of the unit cube.
834        for j in 0..bounds.len() {
835            assert_abs_diff_eq!(solver.population[[0, j]], bounds[j].0, epsilon = 1e-9);
836        }
837        // Sobol point 1 is exactly the center of the unit cube.
838        for j in 0..bounds.len() {
839            let mid = (bounds[j].0 + bounds[j].1) / 2.0;
840            assert_abs_diff_eq!(solver.population[[1, j]], mid, epsilon = 1e-9);
841        }
842    }
843
844    #[test]
845    fn test_de_init_methods() {
846        let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
847
848        for init_method in &["latinhypercube", "halton", "sobol", "random"] {
849            let mut opts = DifferentialEvolutionOptions::default();
850            opts.seed = Some(42);
851            opts.maxiter = 50;
852            opts.polish = false;
853            opts.init = init_method.to_string();
854
855            let result = differential_evolution(sphere, bounds.clone(), Some(opts), None);
856            assert!(
857                result.is_ok(),
858                "Init method {} should not fail",
859                init_method
860            );
861        }
862    }
863
864    #[test]
865    fn test_de_invalid_bounds() {
866        // Empty bounds
867        let result = differential_evolution(sphere, vec![], None, None);
868        assert!(result.is_err());
869
870        // Lower >= upper
871        let bounds = vec![(5.0, 2.0)];
872        let result = differential_evolution(sphere, bounds, None, None);
873        assert!(result.is_err());
874    }
875
876    #[test]
877    fn test_de_convergence() {
878        let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
879        let mut opts = DifferentialEvolutionOptions::default();
880        opts.seed = Some(42);
881        opts.maxiter = 500;
882        opts.tol = 1.0; // Very relaxed tolerance so it converges quickly
883        opts.polish = false;
884
885        let result = differential_evolution(sphere, bounds, Some(opts), None);
886        assert!(result.is_ok());
887        let res = result.expect("should succeed");
888        assert!(res.nit > 0);
889        assert!(res.nfev > 0);
890    }
891
892    #[test]
893    fn test_de_options_default() {
894        let opts = DifferentialEvolutionOptions::default();
895        assert_eq!(opts.maxiter, 1000);
896        assert_eq!(opts.popsize, 15);
897        assert!((opts.tol - 0.01).abs() < 1e-12);
898        assert!((opts.recombination - 0.7).abs() < 1e-12);
899        assert!(opts.polish);
900        assert_eq!(opts.init, "latinhypercube");
901    }
902
903    #[test]
904    fn test_de_strategy_from_str() {
905        assert_eq!(Strategy::from_str("best1bin"), Some(Strategy::Best1Bin));
906        assert_eq!(Strategy::from_str("rand1bin"), Some(Strategy::Rand1Bin));
907        assert_eq!(
908            Strategy::from_str("currenttobest1bin"),
909            Some(Strategy::CurrentToBest1Bin)
910        );
911        assert_eq!(Strategy::from_str("best2bin"), Some(Strategy::Best2Bin));
912        assert_eq!(Strategy::from_str("rand2bin"), Some(Strategy::Rand2Bin));
913        assert_eq!(Strategy::from_str("best1exp"), Some(Strategy::Best1Exp));
914        assert_eq!(Strategy::from_str("rand1exp"), Some(Strategy::Rand1Exp));
915        assert_eq!(Strategy::from_str("unknown"), None);
916    }
917
918    #[test]
919    fn test_de_high_dimensional() {
920        let n = 5;
921        let bounds: Vec<(f64, f64)> = vec![(-5.0, 5.0); n];
922        let mut opts = DifferentialEvolutionOptions::default();
923        opts.seed = Some(42);
924        opts.popsize = 30;
925        opts.maxiter = 500;
926        opts.polish = false;
927
928        let result = differential_evolution(sphere, bounds, Some(opts), None);
929        assert!(result.is_ok());
930        let res = result.expect("should succeed");
931        assert!(
932            res.fun < 1.0,
933            "High-dim DE should converge reasonably, got {}",
934            res.fun
935        );
936    }
937}