Skip to main content

scirs2_optimize/bayesian/
multi_fidelity.rs

1//! Multi-Fidelity Bayesian Optimization.
2//!
3//! Implements multi-fidelity BO using an auto-regressive (AR(1)) coregionalization
4//! model across fidelity levels.  Cheap, low-fidelity evaluations are used to
5//! inform the surrogate of the expensive, high-fidelity objective, letting the
6//! optimizer spend its budget efficiently.
7//!
8//! # Approach
9//!
10//! The AR(1) coregionalization model (Kennedy & O'Hagan 2000) assumes:
11//!
12//! ```text
13//!   f_{l}(x) = rho_l * f_{l-1}(x) + delta_l(x)
14//! ```
15//!
16//! where `delta_l` is an independent GP correction at each fidelity level.
17//! Each level `l` is fitted as a GP whose training target is `y_l - rho_l * mu_{l-1}(x)`.
18//!
19//! The acquisition function is the cost-normalized Expected Improvement
20//!
21//! ```text
22//!   EI_cost(x, l) = EI(x, model_l) / cost_l
23//! ```
24//!
25//! which automatically selects both the next evaluation point *and* the fidelity
26//! level to use.
27//!
28//! # Quick Start
29//!
30//! ```rust
31//! use scirs2_optimize::bayesian::multi_fidelity::{
32//!     MultiFidelityBo, FidelityLevel, MultiFidelityConfig,
33//! };
34//!
35//! // Two-level example: level 0 is cheap (cost 1), level 1 is expensive (cost 10)
36//! let levels = vec![
37//!     FidelityLevel { cost: 1.0, noise: 0.05, correlation: 1.0 },
38//!     FidelityLevel { cost: 10.0, noise: 0.001, correlation: 0.95 },
39//! ];
40//!
41//! let bounds = vec![(-2.0_f64, 2.0_f64), (-2.0, 2.0)];
42//!
43//! let mut mfbo = MultiFidelityBo::new(levels, bounds, MultiFidelityConfig::default())
44//!     .expect("build mfbo");
45//!
46//! // Low-fidelity oracle (cheap)
47//! let lf_fn = |x: &[f64]| x[0].powi(2) + x[1].powi(2) + 0.1 * (x[0] * x[1]);
48//! // High-fidelity oracle (expensive)
49//! let hf_fn = |x: &[f64]| x[0].powi(2) + x[1].powi(2);
50//!
51//! let fns: Vec<Box<dyn Fn(&[f64]) -> f64>> = vec![
52//!     Box::new(lf_fn),
53//!     Box::new(hf_fn),
54//! ];
55//!
56//! let result = mfbo.optimize(&fns, 30.0).expect("optimize");
57//! println!("Best x: {:?}, f: {:.4}", result.x_best, result.f_best);
58//! ```
59
60use scirs2_core::ndarray::{Array1, Array2};
61use scirs2_core::random::rngs::StdRng;
62use scirs2_core::random::{Rng, RngExt, SeedableRng};
63
64use crate::error::{OptimizeError, OptimizeResult};
65
66use super::gp::{GpSurrogate, GpSurrogateConfig, RbfKernel};
67use super::sampling::{generate_samples, SamplingConfig, SamplingStrategy};
68
69// ---------------------------------------------------------------------------
70// Fidelity descriptor
71// ---------------------------------------------------------------------------
72
73/// Descriptor of a single fidelity level.
74#[derive(Debug, Clone)]
75pub struct FidelityLevel {
76    /// Cost of one evaluation at this fidelity (relative units).
77    pub cost: f64,
78    /// Observation noise variance for this fidelity.
79    pub noise: f64,
80    /// Auto-regressive correlation coefficient rho with the *previous* level.
81    /// For the lowest fidelity level this field is ignored.
82    pub correlation: f64,
83}
84
85// ---------------------------------------------------------------------------
86// AR(1) multi-output GP
87// ---------------------------------------------------------------------------
88
89/// AR(1) coregionalization multi-output GP.
90///
91/// Maintains one independent GP per fidelity level; level `l` is fitted to the
92/// residual `y_l - rho_l * mu_{l-1}(x_l)`.
93pub struct AutoRegressiveGp {
94    /// One GP per fidelity level.
95    gps: Vec<GpSurrogate>,
96    /// Auto-regressive coefficients (length == n_levels; index 0 unused).
97    rhos: Vec<f64>,
98    /// Observed input/output data per level.
99    data: Vec<(Array2<f64>, Array1<f64>)>,
100    /// Fidelity descriptors.
101    levels: Vec<FidelityLevel>,
102}
103
104impl AutoRegressiveGp {
105    /// Construct an unfitted AR-GP for the given fidelity levels.
106    pub fn new(levels: &[FidelityLevel]) -> Self {
107        let n = levels.len();
108        let gps = (0..n)
109            .map(|i| {
110                let noise = levels[i].noise.max(1e-8);
111                let gp_config = GpSurrogateConfig {
112                    noise_variance: noise,
113                    optimize_hyperparams: true,
114                    n_restarts: 2,
115                    max_opt_iters: 30,
116                };
117                GpSurrogate::new(Box::new(RbfKernel::default()), gp_config)
118            })
119            .collect();
120
121        let rhos = levels
122            .iter()
123            .map(|l| l.correlation.clamp(-5.0, 5.0))
124            .collect();
125
126        Self {
127            gps,
128            rhos,
129            data: vec![(Array2::zeros((0, 1)), Array1::zeros(0)); n],
130            levels: levels.to_vec(),
131        }
132    }
133
134    /// Fit the AR-GP to multi-fidelity data.
135    ///
136    /// `x_list[l]` and `y_list[l]` are the observed inputs/outputs at level `l`.
137    /// Levels must be ordered from lowest (0) to highest fidelity.
138    pub fn fit(&mut self, x_list: &[Array2<f64>], y_list: &[Array1<f64>]) -> OptimizeResult<()> {
139        if x_list.len() != self.levels.len() || y_list.len() != self.levels.len() {
140            return Err(OptimizeError::InvalidInput(format!(
141                "Expected {} fidelity levels, got x_list={} y_list={}",
142                self.levels.len(),
143                x_list.len(),
144                y_list.len()
145            )));
146        }
147
148        for (l, (xl, yl)) in x_list.iter().zip(y_list.iter()).enumerate() {
149            if xl.nrows() != yl.len() {
150                return Err(OptimizeError::InvalidInput(format!(
151                    "Level {}: x has {} rows but y has {} elements",
152                    l,
153                    xl.nrows(),
154                    yl.len()
155                )));
156            }
157            self.data[l] = (xl.clone(), yl.clone());
158        }
159
160        // Fit level 0 directly.
161        if !x_list[0].is_empty() {
162            self.gps[0].fit(&x_list[0], &y_list[0])?;
163        }
164
165        // Fit levels 1..n as residual GPs.
166        for l in 1..self.levels.len() {
167            let (xl, yl) = (&x_list[l], &y_list[l]);
168            if xl.is_empty() {
169                continue;
170            }
171
172            // Compute residuals: y_l - rho_l * mu_{l-1}(x_l)
173            let rho = self.rhos[l];
174            let mut residuals = Array1::zeros(yl.len());
175            for i in 0..xl.nrows() {
176                let x_row = xl.row(i).to_owned();
177                let x_mat = x_row
178                    .into_shape_with_order((1, xl.ncols()))
179                    .map_err(|e| OptimizeError::ComputationError(format!("Shape: {}", e)))?;
180                let mu_prev = self.predict_mean_level(l - 1, &x_mat)?;
181                residuals[i] = yl[i] - rho * mu_prev[0];
182            }
183
184            self.gps[l].fit(xl, &residuals)?;
185        }
186
187        Ok(())
188    }
189
190    /// Add a single new observation at a given fidelity level and refit.
191    pub fn update(&mut self, fidelity: usize, x: Array1<f64>, y: f64) -> OptimizeResult<()> {
192        if fidelity >= self.levels.len() {
193            return Err(OptimizeError::InvalidInput(format!(
194                "Fidelity {} out of range (max {})",
195                fidelity,
196                self.levels.len() - 1
197            )));
198        }
199        let n_dims = x.len();
200        let (ref mut xs, ref mut ys) = self.data[fidelity];
201        // Append
202        let new_n = xs.nrows() + 1;
203        let mut new_x = Array2::zeros((new_n, n_dims));
204        for i in 0..xs.nrows() {
205            for j in 0..n_dims {
206                new_x[[i, j]] = xs[[i, j]];
207            }
208        }
209        for j in 0..n_dims {
210            new_x[[xs.nrows(), j]] = x[j];
211        }
212        let mut new_y = Array1::zeros(new_n);
213        for i in 0..ys.len() {
214            new_y[i] = ys[i];
215        }
216        new_y[ys.len()] = y;
217
218        *xs = new_x;
219        *ys = new_y;
220
221        // Clone data out so we can pass slices to fit().
222        let x_list: Vec<Array2<f64>> = self.data.iter().map(|(x, _)| x.clone()).collect();
223        let y_list: Vec<Array1<f64>> = self.data.iter().map(|(_, y)| y.clone()).collect();
224
225        self.fit(&x_list, &y_list)
226    }
227
228    /// Predict mean and variance at query points for a given fidelity level.
229    ///
230    /// The prediction is obtained by propagating the AR(1) recursion upward from
231    /// level 0 to the requested level.
232    pub fn predict(
233        &self,
234        x: &Array2<f64>,
235        fidelity: usize,
236    ) -> OptimizeResult<(Array1<f64>, Array1<f64>)> {
237        if fidelity >= self.levels.len() {
238            return Err(OptimizeError::InvalidInput(format!(
239                "Fidelity {} out of range",
240                fidelity
241            )));
242        }
243
244        // Iterative AR propagation.
245        let (mut mean, mut var) = self.predict_gp_level(0, x)?;
246
247        for l in 1..=fidelity {
248            let rho = self.rhos[l];
249            if self.gps[l].n_train() == 0 {
250                // No data at this level yet; scale mean/var by rho.
251                mean = mean.mapv(|m| rho * m);
252                var = var.mapv(|v| rho * rho * v);
253                continue;
254            }
255            let (delta_mean, delta_var) = self.predict_gp_level(l, x)?;
256            mean = mean.mapv(|m| rho * m) + &delta_mean;
257            var = var.mapv(|v| rho * rho * v) + &delta_var;
258        }
259
260        Ok((mean, var))
261    }
262
263    // -----------------------------------------------------------------------
264    // Internal helpers
265    // -----------------------------------------------------------------------
266
267    /// Predict the mean at a single level using that level's GP (the delta GP).
268    fn predict_gp_level(
269        &self,
270        level: usize,
271        x: &Array2<f64>,
272    ) -> OptimizeResult<(Array1<f64>, Array1<f64>)> {
273        if self.gps[level].n_train() == 0 {
274            let n = x.nrows();
275            return Ok((Array1::zeros(n), Array1::ones(n)));
276        }
277        self.gps[level].predict(x)
278    }
279
280    /// Convenience: predict mean only for level `l`.
281    fn predict_mean_level(&self, level: usize, x: &Array2<f64>) -> OptimizeResult<Array1<f64>> {
282        let (mean, _) = self.predict_gp_level(level, x)?;
283        Ok(mean)
284    }
285}
286
287// ---------------------------------------------------------------------------
288// Acquisition: cost-normalized Expected Improvement
289// ---------------------------------------------------------------------------
290
291/// Compute the cost-normalized Expected Improvement at a candidate point.
292///
293/// ```text
294///   EI_cost(x, l) = EI(x, mu_l, sigma_l, best_y) / cost_l
295/// ```
296///
297/// where EI uses the standard Gaussian formula.
298pub fn extended_ei(
299    x: &Array2<f64>,
300    model: &AutoRegressiveGp,
301    fidelity: usize,
302    xi: f64,
303    best_y: f64,
304) -> OptimizeResult<f64> {
305    if fidelity >= model.levels.len() {
306        return Err(OptimizeError::InvalidInput(format!(
307            "Fidelity {} out of range",
308            fidelity
309        )));
310    }
311    let cost = model.levels[fidelity].cost.max(1e-12);
312
313    let (mean, var) = model.predict(x, fidelity)?;
314    let mu = mean[0];
315    let sigma = var[0].max(0.0).sqrt();
316
317    if sigma < 1e-12 {
318        return Ok(0.0);
319    }
320
321    let z = (best_y - mu - xi) / sigma;
322    let ei = (best_y - mu - xi) * norm_cdf(z) + sigma * norm_pdf(z);
323    let ei_normalized = ei.max(0.0) / cost;
324
325    Ok(ei_normalized)
326}
327
328// ---------------------------------------------------------------------------
329// Optimizer configuration and result
330// ---------------------------------------------------------------------------
331
332/// Configuration for multi-fidelity Bayesian optimization.
333#[derive(Debug, Clone)]
334pub struct MultiFidelityConfig {
335    /// Number of initial samples per fidelity level (applied at the cheapest level).
336    pub n_initial: usize,
337    /// Exploration bonus xi for EI.
338    pub xi: f64,
339    /// Number of random candidates evaluated when optimizing the acquisition.
340    pub n_candidates: usize,
341    /// Random seed.
342    pub seed: Option<u64>,
343    /// Verbosity (0 = silent).
344    pub verbose: usize,
345}
346
347impl Default for MultiFidelityConfig {
348    fn default() -> Self {
349        Self {
350            n_initial: 8,
351            xi: 0.01,
352            n_candidates: 300,
353            seed: None,
354            verbose: 0,
355        }
356    }
357}
358
359/// Result of multi-fidelity Bayesian optimization.
360#[derive(Debug, Clone)]
361pub struct MultiFidelityResult {
362    /// Best input point found (at highest fidelity).
363    pub x_best: Array1<f64>,
364    /// Best objective value found.
365    pub f_best: f64,
366    /// Number of evaluations at each fidelity level.
367    pub n_evals_per_level: Vec<usize>,
368    /// Total budget spent.
369    pub budget_spent: f64,
370    /// Trajectory of (budget_spent, fidelity, f_value) triples.
371    pub history: Vec<(f64, usize, f64)>,
372}
373
374// ---------------------------------------------------------------------------
375// Main optimizer
376// ---------------------------------------------------------------------------
377
378/// Multi-Fidelity Bayesian Optimizer.
379///
380/// Manages a collection of fidelity levels, an AR(1) GP model, and orchestrates
381/// the optimization loop subject to a cost budget.
382pub struct MultiFidelityBo {
383    levels: Vec<FidelityLevel>,
384    bounds: Vec<(f64, f64)>,
385    config: MultiFidelityConfig,
386}
387
388impl MultiFidelityBo {
389    /// Create a new multi-fidelity optimizer.
390    pub fn new(
391        levels: Vec<FidelityLevel>,
392        bounds: Vec<(f64, f64)>,
393        config: MultiFidelityConfig,
394    ) -> OptimizeResult<Self> {
395        if levels.is_empty() {
396            return Err(OptimizeError::InvalidInput(
397                "At least one fidelity level is required".to_string(),
398            ));
399        }
400        if bounds.is_empty() {
401            return Err(OptimizeError::InvalidInput(
402                "Search space bounds must not be empty".to_string(),
403            ));
404        }
405        for (i, l) in levels.iter().enumerate() {
406            if l.cost <= 0.0 {
407                return Err(OptimizeError::InvalidInput(format!(
408                    "Level {} has non-positive cost {}",
409                    i, l.cost
410                )));
411            }
412        }
413        Ok(Self {
414            levels,
415            bounds,
416            config,
417        })
418    }
419
420    /// Run multi-fidelity optimization.
421    ///
422    /// `objectives[l]` is the oracle function for fidelity level `l`.
423    /// `budget` is the total evaluation cost to spend.
424    pub fn optimize(
425        &mut self,
426        objectives: &[Box<dyn Fn(&[f64]) -> f64>],
427        budget: f64,
428    ) -> OptimizeResult<MultiFidelityResult> {
429        if objectives.len() != self.levels.len() {
430            return Err(OptimizeError::InvalidInput(format!(
431                "Expected {} objective functions, got {}",
432                self.levels.len(),
433                objectives.len()
434            )));
435        }
436        if budget <= 0.0 {
437            return Err(OptimizeError::InvalidInput(
438                "Budget must be positive".to_string(),
439            ));
440        }
441
442        let n_levels = self.levels.len();
443        let highest = n_levels - 1;
444        let mut model = AutoRegressiveGp::new(&self.levels);
445
446        let seed = self.config.seed.unwrap_or(42);
447        let mut rng = StdRng::seed_from_u64(seed);
448
449        let mut budget_spent = 0.0;
450        let mut n_evals_per_level = vec![0usize; n_levels];
451        let mut history: Vec<(f64, usize, f64)> = Vec::new();
452        let mut best_y = f64::INFINITY;
453        let mut best_x: Option<Array1<f64>> = None;
454
455        // ---------------------------------------------------------------
456        // Initial design at the cheapest level.
457        // ---------------------------------------------------------------
458        let n_initial = self.config.n_initial.max(2);
459        let lhs_cfg = SamplingConfig {
460            seed: Some(seed),
461            ..SamplingConfig::default()
462        };
463        let x_init = generate_samples(
464            n_initial,
465            &self.bounds,
466            SamplingStrategy::LatinHypercube,
467            Some(lhs_cfg),
468        )?;
469
470        let mut init_data: Vec<(Array2<f64>, Array1<f64>)> = (0..n_levels)
471            .map(|_| (Array2::zeros((0, self.bounds.len())), Array1::zeros(0)))
472            .collect();
473
474        for i in 0..x_init.nrows() {
475            let xi = x_init.row(i).to_owned();
476            let x_slice: Vec<f64> = xi.iter().copied().collect();
477
478            // Evaluate at cheapest level.
479            let y0 = (objectives[0])(&x_slice);
480            let cost0 = self.levels[0].cost;
481            budget_spent += cost0;
482            n_evals_per_level[0] += 1;
483
484            // Accumulate into init_data[0].
485            let (ref mut xs0, ref mut ys0) = init_data[0];
486            let old_n = xs0.nrows();
487            let n_dims = self.bounds.len();
488            let mut new_xs = Array2::zeros((old_n + 1, n_dims));
489            for r in 0..old_n {
490                for c in 0..n_dims {
491                    new_xs[[r, c]] = xs0[[r, c]];
492                }
493            }
494            for c in 0..n_dims {
495                new_xs[[old_n, c]] = xi[c];
496            }
497            let mut new_ys = Array1::zeros(old_n + 1);
498            for r in 0..old_n {
499                new_ys[r] = ys0[r];
500            }
501            new_ys[old_n] = y0;
502            *xs0 = new_xs;
503            *ys0 = new_ys;
504
505            history.push((budget_spent, 0, y0));
506
507            if budget_spent >= budget {
508                break;
509            }
510        }
511
512        // Also evaluate a few initial points at the highest fidelity.
513        let n_init_hf = (n_initial / 2).max(2);
514        let lhs_cfg2 = SamplingConfig {
515            seed: Some(seed + 1),
516            ..SamplingConfig::default()
517        };
518        let x_init_hf = generate_samples(
519            n_init_hf,
520            &self.bounds,
521            SamplingStrategy::LatinHypercube,
522            Some(lhs_cfg2),
523        )?;
524
525        for i in 0..x_init_hf.nrows() {
526            if budget_spent >= budget {
527                break;
528            }
529            let xi = x_init_hf.row(i).to_owned();
530            let x_slice: Vec<f64> = xi.iter().copied().collect();
531            let y = (objectives[highest])(&x_slice);
532            let cost = self.levels[highest].cost;
533            budget_spent += cost;
534            n_evals_per_level[highest] += 1;
535
536            let (ref mut xs, ref mut ys) = init_data[highest];
537            let old_n = xs.nrows();
538            let n_dims = self.bounds.len();
539            let mut new_xs = Array2::zeros((old_n + 1, n_dims));
540            for r in 0..old_n {
541                for c in 0..n_dims {
542                    new_xs[[r, c]] = xs[[r, c]];
543                }
544            }
545            for c in 0..n_dims {
546                new_xs[[old_n, c]] = xi[c];
547            }
548            let mut new_ys = Array1::zeros(old_n + 1);
549            for r in 0..old_n {
550                new_ys[r] = ys[r];
551            }
552            new_ys[old_n] = y;
553            *xs = new_xs;
554            *ys = new_ys;
555
556            history.push((budget_spent, highest, y));
557
558            if y < best_y {
559                best_y = y;
560                best_x = Some(xi);
561            }
562        }
563
564        // Initial fit.
565        let x_list: Vec<Array2<f64>> = init_data.iter().map(|(x, _)| x.clone()).collect();
566        let y_list: Vec<Array1<f64>> = init_data.iter().map(|(_, y)| y.clone()).collect();
567
568        // Perform fit only if there is data at level 0.
569        if !x_list[0].is_empty() {
570            model.fit(&x_list, &y_list)?;
571        }
572
573        // Update best from init.
574        for i in 0..init_data[highest].0.nrows() {
575            let y = init_data[highest].1[i];
576            if y < best_y {
577                best_y = y;
578                best_x = Some(init_data[highest].0.row(i).to_owned());
579            }
580        }
581
582        // ---------------------------------------------------------------
583        // Main BO loop.
584        // ---------------------------------------------------------------
585        while budget_spent < budget {
586            let n_dims = self.bounds.len();
587            let n_cands = self.config.n_candidates;
588
589            // Sample random candidates.
590            let mut candidates = Array2::zeros((n_cands, n_dims));
591            for r in 0..n_cands {
592                for c in 0..n_dims {
593                    let lo = self.bounds[c].0;
594                    let hi = self.bounds[c].1;
595                    candidates[[r, c]] = lo + rng.random::<f64>() * (hi - lo);
596                }
597            }
598
599            // Choose the best (candidate, fidelity) pair by EI/cost.
600            let mut best_acq = f64::NEG_INFINITY;
601            let mut best_row = 0;
602            let mut best_level = highest;
603
604            let current_best = if best_y.is_finite() { best_y } else { 1.0 };
605
606            for r in 0..n_cands {
607                let x_cand = candidates.row(r).to_owned();
608                let x_mat = match x_cand.into_shape_with_order((1, n_dims)) {
609                    Ok(m) => m,
610                    Err(_) => continue,
611                };
612
613                for l in 0..n_levels {
614                    // Skip if this level alone would exceed remaining budget.
615                    let remaining = budget - budget_spent;
616                    if self.levels[l].cost > remaining + 1e-9 {
617                        continue;
618                    }
619
620                    let acq = match extended_ei(&x_mat, &model, l, self.config.xi, current_best) {
621                        Ok(v) => v,
622                        Err(_) => continue,
623                    };
624
625                    if acq > best_acq {
626                        best_acq = acq;
627                        best_row = r;
628                        best_level = l;
629                    }
630                }
631            }
632
633            // Evaluate.
634            let x_next = candidates.row(best_row).to_owned();
635            let x_slice: Vec<f64> = x_next.iter().copied().collect();
636            let cost = self.levels[best_level].cost;
637
638            if budget_spent + cost > budget + 1e-9 {
639                break;
640            }
641
642            let y_next = (objectives[best_level])(&x_slice);
643            budget_spent += cost;
644            n_evals_per_level[best_level] += 1;
645            history.push((budget_spent, best_level, y_next));
646
647            if self.config.verbose >= 2 {
648                println!(
649                    "[MFBO] budget={:.1}/{:.1} level={} f={:.6}",
650                    budget_spent, budget, best_level, y_next
651                );
652            }
653
654            // Update model.
655            model.update(best_level, x_next.clone(), y_next)?;
656
657            // Track best at highest fidelity.
658            if best_level == highest && y_next < best_y {
659                best_y = y_next;
660                best_x = Some(x_next);
661            }
662        }
663
664        if self.config.verbose >= 1 {
665            println!(
666                "[MFBO] Done. budget_spent={:.2} best_f={:.6}",
667                budget_spent, best_y
668            );
669        }
670
671        let x_best = best_x.unwrap_or_else(|| Array1::zeros(self.bounds.len()));
672
673        Ok(MultiFidelityResult {
674            x_best,
675            f_best: best_y,
676            n_evals_per_level,
677            budget_spent,
678            history,
679        })
680    }
681}
682
683// ---------------------------------------------------------------------------
684// Top-level convenience function
685// ---------------------------------------------------------------------------
686
687/// Run multi-fidelity Bayesian optimization.
688///
689/// A convenience wrapper around [`MultiFidelityBo`].
690pub fn mfbo_optimize(
691    objectives: Vec<Box<dyn Fn(&[f64]) -> f64>>,
692    levels: Vec<FidelityLevel>,
693    bounds: Vec<(f64, f64)>,
694    budget: f64,
695    config: Option<MultiFidelityConfig>,
696) -> OptimizeResult<MultiFidelityResult> {
697    let cfg = config.unwrap_or_default();
698    let mut optimizer = MultiFidelityBo::new(levels, bounds, cfg)?;
699    optimizer.optimize(&objectives, budget)
700}
701
702// ---------------------------------------------------------------------------
703// Normal distribution helpers (local, no external dep)
704// ---------------------------------------------------------------------------
705
706fn erf_approx(x: f64) -> f64 {
707    let p = 0.3275911_f64;
708    let (a1, a2, a3, a4, a5) = (
709        0.254829592_f64,
710        -0.284496736_f64,
711        1.421413741_f64,
712        -1.453152027_f64,
713        1.061405429_f64,
714    );
715    let sign = if x < 0.0 { -1.0 } else { 1.0 };
716    let xa = x.abs();
717    let t = 1.0 / (1.0 + p * xa);
718    let poly = (((a5 * t + a4) * t + a3) * t + a2) * t + a1;
719    sign * (1.0 - poly * t * (-xa * xa).exp())
720}
721
722fn norm_cdf(z: f64) -> f64 {
723    0.5 * (1.0 + erf_approx(z / std::f64::consts::SQRT_2))
724}
725
726fn norm_pdf(z: f64) -> f64 {
727    (-0.5 * z * z).exp() / (2.0 * std::f64::consts::PI).sqrt()
728}
729
730// ---------------------------------------------------------------------------
731// Tests
732// ---------------------------------------------------------------------------
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use scirs2_core::ndarray::array;
738
739    fn make_two_level_fidelities() -> Vec<FidelityLevel> {
740        vec![
741            FidelityLevel {
742                cost: 1.0,
743                noise: 0.05,
744                correlation: 1.0,
745            },
746            FidelityLevel {
747                cost: 5.0,
748                noise: 0.005,
749                correlation: 0.9,
750            },
751        ]
752    }
753
754    #[test]
755    fn test_ar_gp_fit_predict() {
756        let levels = make_two_level_fidelities();
757        let mut ar_gp = AutoRegressiveGp::new(&levels);
758
759        // Level 0: 4 points of f0(x) = x^2
760        let x0 = Array2::from_shape_vec((4, 1), vec![0.0, 1.0, 2.0, 3.0]).expect("shape");
761        let y0 = array![0.0, 1.0, 4.0, 9.0];
762
763        // Level 1: 3 points of f1(x) = x^2 (exactly correlated)
764        let x1 = Array2::from_shape_vec((3, 1), vec![0.5, 1.5, 2.5]).expect("shape");
765        let y1 = array![0.25, 2.25, 6.25];
766
767        ar_gp.fit(&[x0, x1], &[y0, y1]).expect("fit");
768
769        // Predict at x=1.0 at highest fidelity.
770        let x_test = Array2::from_shape_vec((1, 1), vec![1.0]).expect("shape");
771        let (mean, var) = ar_gp.predict(&x_test, 1).expect("predict");
772        assert!(mean[0].is_finite(), "mean must be finite: {}", mean[0]);
773        assert!(var[0] >= 0.0, "variance must be non-negative: {}", var[0]);
774    }
775
776    #[test]
777    fn test_ar_gp_update() {
778        let levels = make_two_level_fidelities();
779        let mut ar_gp = AutoRegressiveGp::new(&levels);
780
781        let x0 = Array2::from_shape_vec((3, 1), vec![0.0, 1.0, 2.0]).expect("shape");
782        let y0 = array![0.0, 1.0, 4.0];
783        let x1 = Array2::from_shape_vec((2, 1), vec![0.5, 1.5]).expect("shape");
784        let y1 = array![0.25, 2.25];
785
786        ar_gp.fit(&[x0, x1], &[y0, y1]).expect("fit");
787        // Add new high-fidelity point.
788        ar_gp.update(1, array![3.0], 9.0).expect("update");
789
790        let x_test = Array2::from_shape_vec((1, 1), vec![3.0]).expect("shape");
791        let (mean, _) = ar_gp.predict(&x_test, 1).expect("predict");
792        assert!(mean[0].is_finite());
793    }
794
795    #[test]
796    fn test_extended_ei_basics() {
797        let levels = make_two_level_fidelities();
798        let mut ar_gp = AutoRegressiveGp::new(&levels);
799
800        let x0 = Array2::from_shape_vec((4, 1), vec![0.0, 1.0, 2.0, 3.0]).expect("shape");
801        let y0 = array![0.0, 1.0, 4.0, 9.0];
802        let x1 = Array2::from_shape_vec((2, 1), vec![1.0, 2.0]).expect("shape");
803        let y1 = array![1.0, 4.0];
804        ar_gp.fit(&[x0, x1], &[y0, y1]).expect("fit");
805
806        let x_cand = Array2::from_shape_vec((1, 1), vec![0.5]).expect("shape");
807        let ei = extended_ei(&x_cand, &ar_gp, 1, 0.01, 0.5).expect("ei");
808        assert!(
809            ei.is_finite() && ei >= 0.0,
810            "EI must be non-negative: {}",
811            ei
812        );
813    }
814
815    #[test]
816    fn test_mfbo_optimizes_simple_function() {
817        // f_hf(x) = (x - 1)^2;  f_lf(x) = (x - 1)^2 + 0.3 * noise
818        let levels = vec![
819            FidelityLevel {
820                cost: 1.0,
821                noise: 0.05,
822                correlation: 1.0,
823            },
824            FidelityLevel {
825                cost: 3.0,
826                noise: 0.001,
827                correlation: 0.95,
828            },
829        ];
830        let bounds = vec![(0.0_f64, 3.0_f64)];
831        let config = MultiFidelityConfig {
832            n_initial: 4,
833            n_candidates: 50,
834            seed: Some(123),
835            ..Default::default()
836        };
837
838        let objectives: Vec<Box<dyn Fn(&[f64]) -> f64>> = vec![
839            Box::new(|x: &[f64]| (x[0] - 1.0).powi(2) + 0.3),
840            Box::new(|x: &[f64]| (x[0] - 1.0).powi(2)),
841        ];
842
843        let result =
844            mfbo_optimize(objectives, levels, bounds, 20.0, Some(config)).expect("optimize");
845
846        assert!(result.f_best.is_finite());
847        // Should get reasonably close to the minimum at x=1 (f=0).
848        assert!(
849            result.f_best < 1.5,
850            "Expected f_best < 1.5, got {}",
851            result.f_best
852        );
853        assert!(result.budget_spent <= 20.5); // allow small float tolerance
854    }
855}