quantrs2_core/
optimization_stubs.rs

1//! Optimization utilities backed by OptiRS optimizers.
2//!
3//!\n//! This module preserves the historical `Method`/`Options` interface that the
4//! rest of the QuantRS2 codebase depends on, while delegating the actual work to
5//! OptiRS implementations. Gradient-based methods rely on finite-difference
6//! estimates so that existing objective functions can remain unchanged.
7
8use crate::error::{QuantRS2Error, QuantRS2Result};
9use optirs_core::error::OptimError;
10use optirs_core::optimizers::{Adam, Optimizer, LBFGS};
11use scirs2_core::ndarray::{Array1, ArrayView1, Ix1};
12use scirs2_core::random::{rngs::StdRng, seq::SliceRandom, thread_rng, Rng, SeedableRng};
13use std::cmp::Ordering;
14
15const DEFAULT_GRADIENT_STEP: f64 = 1e-5;
16const DEFAULT_MAX_FUNCTION_EVALS: usize = 50_000;
17const DEFAULT_HISTORY_SIZE: usize = 20;
18const DEFAULT_LEARNING_RATE: f64 = 0.1;
19
20/// Optimization method enum
21#[derive(Debug, Clone, Copy)]
22pub enum Method {
23    BFGS,
24    LBFGS,
25    ConjugateGradient,
26    NewtonCG,
27    TrustRegion,
28    NelderMead,
29    Powell,
30}
31
32/// Optimization options
33#[derive(Debug, Clone)]
34pub struct Options {
35    pub max_iter: usize,
36    pub max_iterations: usize, // Alias for compatibility
37    pub tolerance: f64,
38    pub ftol: f64, // Function tolerance
39    pub gtol: f64, // Gradient tolerance
40    pub xtol: f64, // Parameter tolerance
41    pub method: Method,
42}
43
44impl Default for Options {
45    fn default() -> Self {
46        Self {
47            max_iter: 1000,
48            max_iterations: 1000,
49            tolerance: 1e-6,
50            ftol: 1e-6,
51            gtol: 1e-6,
52            xtol: 1e-6,
53            method: Method::LBFGS,
54        }
55    }
56}
57
58impl Options {
59    const fn resolved_max_iter(&self) -> usize {
60        let base = if self.max_iter == 0 {
61            self.max_iterations
62        } else {
63            self.max_iter
64        };
65        if base == 0 {
66            1000
67        } else {
68            base
69        }
70    }
71
72    fn resolved_ftol(&self) -> f64 {
73        if self.ftol > 0.0 {
74            self.ftol
75        } else if self.tolerance > 0.0 {
76            self.tolerance
77        } else {
78            1e-9
79        }
80    }
81
82    fn resolved_gtol(&self) -> f64 {
83        if self.gtol > 0.0 {
84            self.gtol
85        } else if self.tolerance > 0.0 {
86            self.tolerance
87        } else {
88            1e-9
89        }
90    }
91
92    fn resolved_xtol(&self) -> f64 {
93        if self.xtol > 0.0 {
94            self.xtol
95        } else if self.tolerance > 0.0 {
96            self.tolerance
97        } else {
98            1e-9
99        }
100    }
101}
102
103fn default_learning_rate(options: &Options) -> f64 {
104    let tol = options.resolved_gtol().abs();
105    if tol.is_finite() && tol > 0.0 {
106        (tol * 10.0).clamp(1e-3, 0.5)
107    } else {
108        DEFAULT_LEARNING_RATE
109    }
110}
111
112/// Optimization result
113#[derive(Debug, Clone)]
114pub struct OptimizeResult<T = f64> {
115    pub x: Array1<T>,
116    pub fun: T,
117    pub nit: usize,
118    pub iterations: usize, // Alias for nit
119    pub success: bool,
120    pub message: String,
121}
122
123enum OptiRSBackend {
124    LBFGS(LBFGS<f64>),
125    Adam(Adam<f64>),
126}
127
128impl OptiRSBackend {
129    const fn name(&self) -> &'static str {
130        match self {
131            Self::LBFGS(_) => "L-BFGS",
132            Self::Adam(_) => "Adam",
133        }
134    }
135
136    fn set_learning_rate(&mut self, learning_rate: f64) {
137        match self {
138            Self::LBFGS(optimizer) => optimizer.set_lr(learning_rate),
139            Self::Adam(optimizer) => {
140                <Adam<f64> as Optimizer<f64, Ix1>>::set_learning_rate(optimizer, learning_rate);
141            }
142        }
143    }
144
145    fn step(
146        &mut self,
147        params: &Array1<f64>,
148        gradients: &Array1<f64>,
149    ) -> Result<Array1<f64>, OptimError> {
150        match self {
151            Self::LBFGS(optimizer) => optimizer.step(params, gradients),
152            Self::Adam(optimizer) => optimizer.step(params, gradients),
153        }
154    }
155}
156
157fn map_optirs_error(err: OptimError) -> QuantRS2Error {
158    QuantRS2Error::OptimizationFailed(err.to_string())
159}
160
161fn safe_fun_eval<F>(fun: &F, params: &Array1<f64>) -> f64
162where
163    F: Fn(&ArrayView1<f64>) -> f64,
164{
165    let value = fun(&params.view());
166    if value.is_finite() {
167        value
168    } else {
169        f64::INFINITY
170    }
171}
172
173fn numerical_gradient<F>(
174    fun: &F,
175    params: &Array1<f64>,
176    step: f64,
177    eval_counter: &mut usize,
178) -> QuantRS2Result<Array1<f64>>
179where
180    F: Fn(&ArrayView1<f64>) -> f64,
181{
182    if params.is_empty() {
183        return Ok(Array1::zeros(0));
184    }
185
186    let step = if step.is_finite() && step > 0.0 {
187        step
188    } else {
189        DEFAULT_GRADIENT_STEP
190    };
191
192    let mut gradient = Array1::zeros(params.len());
193    let mut forward = params.clone();
194    let mut backward = params.clone();
195
196    for i in 0..params.len() {
197        forward[i] = params[i] + step;
198        backward[i] = params[i] - step;
199
200        let f_plus = safe_fun_eval(fun, &forward);
201        let f_minus = safe_fun_eval(fun, &backward);
202        *eval_counter += 2;
203
204        if !(f_plus.is_finite() && f_minus.is_finite()) {
205            return Err(QuantRS2Error::OptimizationFailed(
206                "Encountered non-finite objective value during gradient estimation".to_string(),
207            ));
208        }
209
210        gradient[i] = (f_plus - f_minus) / (2.0 * step);
211
212        forward[i] = params[i];
213        backward[i] = params[i];
214    }
215
216    Ok(gradient)
217}
218
219fn select_backend(method: Method, options: &Options) -> OptiRSBackend {
220    let learning_rate = default_learning_rate(options);
221    match method {
222        Method::BFGS
223        | Method::LBFGS
224        | Method::ConjugateGradient
225        | Method::NewtonCG
226        | Method::TrustRegion => {
227            let optimizer = LBFGS::new_with_config(
228                learning_rate,
229                DEFAULT_HISTORY_SIZE,
230                options.resolved_gtol(),
231                1e-4,
232                0.9,
233                20,
234            );
235            OptiRSBackend::LBFGS(optimizer)
236        }
237        Method::Powell | Method::NelderMead => {
238            let lr = learning_rate.max(0.01);
239            let mut optimizer = Adam::new(lr);
240            <Adam<f64> as Optimizer<f64, Ix1>>::set_learning_rate(&mut optimizer, lr);
241            OptiRSBackend::Adam(optimizer)
242        }
243    }
244}
245
246fn check_convergence(
247    params: &Array1<f64>,
248    new_params: &Array1<f64>,
249    current_fun: f64,
250    new_fun: f64,
251    grad_norm: f64,
252    options: &Options,
253) -> Option<String> {
254    if grad_norm <= options.resolved_gtol() {
255        return Some("Gradient tolerance reached".to_string());
256    }
257
258    let step_norm = (new_params - params).mapv(|v| v * v).sum().sqrt();
259    if step_norm <= options.resolved_xtol() {
260        return Some("Parameter tolerance reached".to_string());
261    }
262
263    if (current_fun - new_fun).abs() <= options.resolved_ftol() {
264        return Some("Function tolerance reached".to_string());
265    }
266
267    None
268}
269
270fn nelder_mead_minimize<F>(
271    fun: &F,
272    x0: &Array1<f64>,
273    options: &Options,
274) -> QuantRS2Result<OptimizeResult<f64>>
275where
276    F: Fn(&ArrayView1<f64>) -> f64,
277{
278    let dim = x0.len();
279    if dim == 0 {
280        let value = safe_fun_eval(fun, x0);
281        return Ok(OptimizeResult {
282            x: x0.clone(),
283            fun: value,
284            nit: 0,
285            iterations: 0,
286            success: true,
287            message: "Trivial optimization (zero dimension)".to_string(),
288        });
289    }
290
291    let mut simplex = Vec::with_capacity(dim + 1);
292    simplex.push(x0.clone());
293    for i in 0..dim {
294        let mut point = x0.clone();
295        point[i] += DEFAULT_GRADIENT_STEP.max(1e-3);
296        simplex.push(point);
297    }
298
299    let mut values: Vec<f64> = simplex.iter().map(|p| safe_fun_eval(fun, p)).collect();
300    let mut evals = values.len();
301    let mut iterations = 0usize;
302
303    const REFLECTION: f64 = 1.0;
304    const EXPANSION: f64 = 2.0;
305    const CONTRACTION: f64 = 0.5;
306    const SHRINK: f64 = 0.5;
307
308    while iterations < options.resolved_max_iter() && evals < DEFAULT_MAX_FUNCTION_EVALS {
309        let mut order: Vec<usize> = (0..simplex.len()).collect();
310        order.sort_by(|&a, &b| values[a].partial_cmp(&values[b]).unwrap_or(Ordering::Equal));
311
312        let mut ordered_simplex = Vec::with_capacity(simplex.len());
313        let mut ordered_values = Vec::with_capacity(values.len());
314        for idx in order {
315            ordered_simplex.push(simplex[idx].clone());
316            ordered_values.push(values[idx]);
317        }
318        simplex = ordered_simplex;
319        values = ordered_values;
320
321        let best = simplex[0].clone();
322        let worst_index = simplex.len() - 1;
323        let worst = simplex[worst_index].clone();
324
325        // Compute centroid excluding worst point
326        let mut centroid = Array1::zeros(dim);
327        for point in &simplex[..worst_index] {
328            centroid = &centroid + point;
329        }
330        centroid.mapv_inplace(|v| v / dim as f64);
331
332        // Reflection
333        let mut xr = &centroid + &(centroid.clone() - &worst) * REFLECTION;
334        let fr = safe_fun_eval(fun, &xr);
335        evals += 1;
336
337        if fr < values[0] {
338            // Expansion
339            let mut xe = &centroid + &(xr.clone() - &centroid) * EXPANSION;
340            let fe = safe_fun_eval(fun, &xe);
341            evals += 1;
342            if fe < fr {
343                simplex[worst_index] = xe;
344                values[worst_index] = fe;
345            } else {
346                simplex[worst_index] = xr;
347                values[worst_index] = fr;
348            }
349        } else if fr < values[values.len() - 2] {
350            simplex[worst_index] = xr;
351            values[worst_index] = fr;
352        } else {
353            // Contraction
354            let mut xc = &centroid + &(worst - &centroid) * CONTRACTION;
355            let fc = safe_fun_eval(fun, &xc);
356            evals += 1;
357            if fc < values[worst_index] {
358                simplex[worst_index] = xc;
359                values[worst_index] = fc;
360            } else {
361                // Shrink towards best
362                for i in 1..simplex.len() {
363                    simplex[i] = &best + &(simplex[i].clone() - &best) * SHRINK;
364                    values[i] = safe_fun_eval(fun, &simplex[i]);
365                }
366                evals += dim;
367            }
368        }
369
370        iterations += 1;
371
372        let (min_value, max_value) = values
373            .iter()
374            .fold((f64::INFINITY, f64::NEG_INFINITY), |acc, &v| {
375                (acc.0.min(v), acc.1.max(v))
376            });
377        if (max_value - min_value).abs() <= options.resolved_ftol() {
378            break;
379        }
380    }
381
382    let (best_idx, best_val) = values
383        .iter()
384        .enumerate()
385        .min_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(Ordering::Equal))
386        .expect("Simplex values should never be empty");
387
388    Ok(OptimizeResult {
389        x: simplex[best_idx].clone(),
390        fun: *best_val,
391        nit: iterations,
392        iterations,
393        success: true,
394        message: format!("Nelder-Mead completed in {iterations} iterations"),
395    })
396}
397
398/// Minimize function using OptiRS optimizers.
399pub fn minimize<F>(
400    fun: F,
401    x0: &Array1<f64>,
402    method: Method,
403    options: Option<Options>,
404) -> QuantRS2Result<OptimizeResult<f64>>
405where
406    F: Fn(&ArrayView1<f64>) -> f64,
407{
408    let options = options.unwrap_or_default();
409
410    match method {
411        Method::NelderMead | Method::Powell => {
412            let mut result = nelder_mead_minimize(&fun, x0, &options)?;
413            if matches!(method, Method::Powell) {
414                result.message = format!(
415                    "Powell (approximated via Nelder-Mead) in {} iterations",
416                    result.iterations
417                );
418            }
419            return Ok(result);
420        }
421        _ => {}
422    }
423
424    let mut backend = select_backend(method, &options);
425    backend.set_learning_rate(default_learning_rate(&options));
426
427    let mut params = x0.clone();
428    let mut current_fun = safe_fun_eval(&fun, &params);
429    let mut best_params = params.clone();
430    let mut best_fun = current_fun;
431
432    let mut iterations = 0usize;
433    let mut evals = 1usize;
434
435    while iterations < options.resolved_max_iter() && evals < DEFAULT_MAX_FUNCTION_EVALS {
436        let mut gradient = numerical_gradient(&fun, &params, DEFAULT_GRADIENT_STEP, &mut evals)?;
437        let grad_norm = gradient.dot(&gradient).sqrt();
438
439        if grad_norm <= options.resolved_gtol() {
440            return Ok(OptimizeResult {
441                x: params,
442                fun: current_fun,
443                nit: iterations,
444                iterations,
445                success: true,
446                message: format!(
447                    "Optimization converged in {} iterations ({} backend)",
448                    iterations,
449                    backend.name()
450                ),
451            });
452        }
453
454        // Ensure gradients are finite
455        for value in &mut gradient {
456            if !value.is_finite() {
457                *value = 0.0;
458            }
459        }
460
461        let updated_params = backend.step(&params, &gradient).map_err(map_optirs_error)?;
462
463        let new_fun = safe_fun_eval(&fun, &updated_params);
464        evals += 1;
465
466        if new_fun < best_fun {
467            best_fun = new_fun;
468            best_params.clone_from(&updated_params);
469        }
470
471        if let Some(reason) = check_convergence(
472            &params,
473            &updated_params,
474            current_fun,
475            new_fun,
476            grad_norm,
477            &options,
478        ) {
479            return Ok(OptimizeResult {
480                x: updated_params,
481                fun: new_fun,
482                nit: iterations + 1,
483                iterations: iterations + 1,
484                success: true,
485                message: format!("{} ({} backend)", reason, backend.name()),
486            });
487        }
488
489        params = updated_params;
490        current_fun = new_fun;
491        iterations += 1;
492    }
493
494    let success = iterations < options.resolved_max_iter() && evals < DEFAULT_MAX_FUNCTION_EVALS;
495    Ok(OptimizeResult {
496        x: if success { params } else { best_params },
497        fun: if success { current_fun } else { best_fun },
498        nit: iterations,
499        iterations,
500        success,
501        message: if success {
502            format!(
503                "Optimization converged in {} iterations using {}",
504                iterations,
505                backend.name()
506            )
507        } else {
508            format!(
509                "Reached iteration/function evaluation limit ({} iterations, {} evals) using {}",
510                iterations,
511                evals,
512                backend.name()
513            )
514        },
515    })
516}
517
518/// Differential evolution options
519#[derive(Debug, Clone)]
520pub struct DifferentialEvolutionOptions {
521    pub population_size: usize,
522    pub popsize: usize, // Alias for population_size
523    pub max_generations: usize,
524    pub maxiter: usize, // Alias for max_generations
525    pub tolerance: f64,
526    pub tol: f64, // Alias for tolerance
527}
528
529impl Default for DifferentialEvolutionOptions {
530    fn default() -> Self {
531        Self {
532            population_size: 15,
533            popsize: 15,
534            max_generations: 1000,
535            maxiter: 1000,
536            tolerance: 1e-6,
537            tol: 1e-6,
538        }
539    }
540}
541
542/// Differential evolution optimization using SciRS2 random utilities
543pub fn differential_evolution<F>(
544    fun: F,
545    bounds: &[(f64, f64)],
546    options: Option<DifferentialEvolutionOptions>,
547    random_state: Option<u64>,
548) -> QuantRS2Result<OptimizeResult<f64>>
549where
550    F: Fn(&ArrayView1<f64>) -> f64,
551{
552    let options = options.unwrap_or_default();
553    let dim = bounds.len();
554    if dim == 0 {
555        return Err(QuantRS2Error::InvalidParameter(
556            "Differential evolution requires at least one bounded variable".to_string(),
557        ));
558    }
559
560    let pop_size = options
561        .population_size
562        .max(options.popsize)
563        .max(4 * dim)
564        .max(10);
565    let max_generations = if options.max_generations == 0 {
566        options.maxiter.max(1)
567    } else {
568        options.max_generations
569    };
570    let tolerance = if options.tolerance > 0.0 {
571        options.tolerance
572    } else if options.tol > 0.0 {
573        options.tol
574    } else {
575        1e-9
576    };
577
578    let mut rng = if let Some(seed) = random_state {
579        StdRng::seed_from_u64(seed)
580    } else {
581        let mut seed_rng = thread_rng();
582        let seed: u64 = seed_rng.gen();
583        StdRng::seed_from_u64(seed)
584    };
585
586    let mut population = Vec::with_capacity(pop_size);
587    let mut scores = Vec::with_capacity(pop_size);
588
589    for _ in 0..pop_size {
590        let mut candidate = Array1::zeros(dim);
591        for (idx, &(lower, upper)) in bounds.iter().enumerate() {
592            if !(lower.is_finite() && upper.is_finite() && upper > lower) {
593                return Err(QuantRS2Error::InvalidParameter(format!(
594                    "Invalid bounds for dimension {idx}: [{lower}, {upper}]"
595                )));
596            }
597            let span = upper - lower;
598            candidate[idx] = rng.gen::<f64>().mul_add(span, lower);
599        }
600        let score = safe_fun_eval(&fun, &candidate);
601        population.push(candidate);
602        scores.push(score);
603    }
604
605    let mut best_index = 0usize;
606    for (idx, score) in scores.iter().enumerate() {
607        if score < &scores[best_index] {
608            best_index = idx;
609        }
610    }
611    let mut best_candidate = population[best_index].clone();
612    let mut best_score = scores[best_index];
613
614    let mut iterations = 0usize;
615    let mut evals = pop_size;
616
617    while iterations < max_generations {
618        for i in 0..pop_size {
619            let mut indices: Vec<usize> = (0..pop_size).filter(|&idx| idx != i).collect();
620            indices.shuffle(&mut rng);
621            let (r1, r2, r3) = (indices[0], indices[1], indices[2]);
622
623            let mut trial = population[r1].clone();
624            let j_rand = rng.gen_range(0..dim);
625
626            for j in 0..dim {
627                if rng.gen::<f64>() < 0.7 || j == j_rand {
628                    trial[j] =
629                        0.8f64.mul_add(population[r2][j] - population[r3][j], population[r1][j]);
630                }
631                let (lower, upper) = bounds[j];
632                if trial[j] < lower {
633                    trial[j] = lower;
634                }
635                if trial[j] > upper {
636                    trial[j] = upper;
637                }
638            }
639
640            let trial_score = safe_fun_eval(&fun, &trial);
641            evals += 1;
642
643            if trial_score < scores[i] {
644                population[i] = trial;
645                scores[i] = trial_score;
646                if trial_score < best_score {
647                    best_score = trial_score;
648                    best_candidate.clone_from(&population[i]);
649                }
650            }
651        }
652
653        iterations += 1;
654
655        let (min_score, max_score) = scores
656            .iter()
657            .fold((f64::INFINITY, f64::NEG_INFINITY), |acc, &val| {
658                (acc.0.min(val), acc.1.max(val))
659            });
660        if (max_score - min_score).abs() <= tolerance {
661            break;
662        }
663    }
664
665    Ok(OptimizeResult {
666        x: best_candidate,
667        fun: best_score,
668        nit: iterations,
669        iterations,
670        success: true,
671        message: format!(
672            "Differential evolution converged in {iterations} generations ({evals} evaluations)"
673        ),
674    })
675}