Skip to main content

egobox_gp/
algorithm.rs

1use crate::errors::{GpError, Result};
2use crate::mean_models::*;
3use crate::optimization::{CobylaParams, optimize_params, prepare_multistart};
4use crate::parameters::{GpParams, GpValidParams};
5use crate::utils::{DiffMatrix, NormalizedData, pairwise_differences};
6use crate::{ThetaTuning, correlation_models::*};
7
8use linfa::dataset::{WithLapack, WithoutLapack};
9use linfa::prelude::{Dataset, DatasetBase, Fit, Float, PredictInplace};
10
11#[cfg(not(feature = "blas"))]
12use linfa_linalg::{cholesky::*, eigh::*, qr::*, svd::*, triangular::*};
13#[cfg(feature = "blas")]
14use log::warn;
15#[cfg(feature = "blas")]
16use ndarray_linalg::{cholesky::*, eigh::*, qr::*, svd::*, triangular::*};
17
18use linfa_pls::PlsRegression;
19use ndarray::{Array, Array1, Array2, ArrayBase, Axis, Data, Ix1, Ix2, Zip};
20
21use ndarray_rand::RandomExt;
22use ndarray_rand::rand_distr::Normal;
23use ndarray_stats::QuantileExt;
24
25use log::debug;
26use rayon::prelude::*;
27#[cfg(feature = "serializable")]
28use serde::{Deserialize, Serialize};
29use std::fmt;
30use std::time::Instant;
31
32/// Default number of multistart for hyperparameters optimization
33pub const GP_OPTIM_N_START: usize = 10;
34/// Minimum of function evaluations for COBYLA optimizer
35pub const GP_COBYLA_MIN_EVAL: usize = 25;
36/// Maximum of function evaluations for COBYLA optimizer
37pub const GP_COBYLA_MAX_EVAL: usize = 1000;
38
39/// Internal parameters computed Gp during training
40/// used later on in prediction computations
41#[derive(Default, Debug)]
42#[cfg_attr(
43    feature = "serializable",
44    derive(Serialize, Deserialize),
45    serde(bound(deserialize = "F: Deserialize<'de>"))
46)]
47pub(crate) struct GpInnerParams<F: Float> {
48    /// Gaussian process variance
49    sigma2: F,
50    /// Generalized least-squares regression weights for Universal Kriging or given beta0 for Ordinary Kriging
51    beta: Array2<F>,
52    /// Gaussian Process weights
53    gamma: Array2<F>,
54    /// Cholesky decomposition of the correlation matrix \[R\]
55    r_chol: Array2<F>,
56    /// Solution of the linear equation system : \[R\] x Ft = y
57    ft: Array2<F>,
58    /// R upper triangle matrix of QR decomposition of the matrix Ft
59    ft_qr_r: Array2<F>,
60}
61
62impl<F: Float> Clone for GpInnerParams<F> {
63    fn clone(&self) -> Self {
64        Self {
65            sigma2: self.sigma2.to_owned(),
66            beta: self.beta.to_owned(),
67            gamma: self.gamma.to_owned(),
68            r_chol: self.r_chol.to_owned(),
69            ft: self.ft.to_owned(),
70            ft_qr_r: self.ft_qr_r.to_owned(),
71        }
72    }
73}
74
75/// A GP regression is an interpolation method where the
76/// interpolated values are modeled by a Gaussian process with a mean and
77/// governed by a prior covariance kernel, which depends on some
78/// parameters to be determined.
79///
80/// The interpolated output is modeled as stochastic process as follows:
81///
82/// `Y(x) = mu(x) + Z(x)`
83///
84/// where:
85/// * `mu(x)` is the trend i.e. the mean of the gaussian process
86/// * `Z(x)` the realization of stochastic gaussian process ~ `Normal(0, sigma^2)`
87///
88/// which in turn is written as:
89///
90/// `Y(x) = betas.regr(x) + sigma^2*corr(x, x')`
91///
92/// where:
93/// * `betas` is a vector of linear regression parameters to be determined
94/// * `regr(x)` a vector of polynomial basis functions
95/// * `sigma^2` is the process variance
96/// * `corr(x, x')` is a correlation function which depends on `distance(x, x')`
97///   and a set of unknown parameters `thetas` to be determined.
98///
99/// # Implementation
100///
101/// * Based on [ndarray](https://github.com/rust-ndarray/ndarray)
102///   and [linfa](https://github.com/rust-ml/linfa) and strive to follow [linfa guidelines](https://github.com/rust-ml/linfa/blob/master/CONTRIBUTE.md)
103/// * GP mean model can be constant, linear or quadratic
104/// * GP correlation model can be build the following kernels: squared exponential, absolute exponential, matern 3/2, matern 5/2    
105///   cf. [SMT Kriging](https://smt.readthedocs.io/en/latest/_src_docs/surrogate_models/krg.html)
106/// * For high dimensional problems, the classic GP algorithm does not perform well as
107///   it depends on the inversion of a correlation (n, n) matrix which is an O(n3) operation.
108///   To work around this problem the library implements dimension reduction using
109///   Partial Least Squares method upon Kriging method also known as KPLS algorithm (see Reference)
110/// * GP models can be saved and loaded using [serde](https://serde.rs/).
111///   See `serializable` feature section below.
112///
113/// # Features
114///
115/// ## serializable
116///
117/// The `serializable` feature enables the serialization of GP models using the [`serde crate`](https://serde.rs/).
118///
119/// ## blas
120///
121/// The `blas` feature enables the use of BLAS/LAPACK linear algebra backend available with [`ndarray-linalg`](https://github.com/rust-ndarray/ndarray-linalg).
122///
123/// # Example
124///
125/// ```no_run
126/// use egobox_gp::{correlation_models::*, mean_models::*, GaussianProcess};
127/// use linfa::prelude::*;
128/// use ndarray::{arr2, concatenate, Array, Array1, Array2, Axis};
129///
130/// // one-dimensional test function to approximate
131/// fn xsinx(x: &Array2<f64>) -> Array1<f64> {
132///     ((x - 3.5) * ((x - 3.5) / std::f64::consts::PI).mapv(|v| v.sin())).remove_axis(Axis(1))
133/// }
134///
135/// // training data
136/// let xt = arr2(&[[0.0], [5.0], [10.0], [15.0], [18.0], [20.0], [25.0]]);
137/// let yt = xsinx(&xt);
138///
139/// // GP with constant mean model and squared exponential correlation model
140/// // i.e. Oridinary Kriging model
141/// let kriging = GaussianProcess::<f64, ConstantMean, SquaredExponentialCorr>::params(
142///                 ConstantMean::default(),
143///                 SquaredExponentialCorr::default())
144///                 .fit(&Dataset::new(xt, yt))
145///                 .expect("Kriging trained");
146///
147/// // Use trained model for making predictions
148/// let xtest = Array::linspace(0., 25., 26).insert_axis(Axis(1));
149/// let ytest = xsinx(&xtest);
150///
151/// let ypred = kriging.predict(&xtest).expect("Kriging prediction");
152/// let yvariances = kriging.predict_var(&xtest).expect("Kriging prediction");  
153///```
154///
155/// # Reference:
156///
157/// Mohamed Amine Bouhlel, John T. Hwang, Nathalie Bartoli, Rémi Lafage, Joseph Morlier, Joaquim R.R.A. Martins,
158/// [A Python surrogate modeling framework with derivatives](https://doi.org/10.1016/j.advengsoft.2019.03.005),
159/// Advances in Engineering Software, Volume 135, 2019, 102662, ISSN 0965-9978.
160///
161/// Bouhlel, Mohamed Amine, et al. [Improving kriging surrogates of high-dimensional design
162/// models by Partial Least Squares dimension reduction](https://hal.archives-ouvertes.fr/hal-01232938/document)
163/// Structural and Multidisciplinary Optimization 53.5 (2016): 935-952.
164///
165#[derive(Debug)]
166#[cfg_attr(
167    feature = "serializable",
168    derive(Serialize, Deserialize),
169    serde(bound(
170        serialize = "F: Serialize, Mean: Serialize, Corr: Serialize",
171        deserialize = "F: Deserialize<'de>, Mean: Deserialize<'de>, Corr: Deserialize<'de>"
172    ))
173)]
174pub struct GaussianProcess<F: Float, Mean: RegressionModel<F>, Corr: CorrelationModel<F>> {
175    /// Parameter of the autocorrelation model equal to the inverse of length scale
176    theta: Array1<F>,
177    /// Reduced likelihood value (result from internal optimization)
178    /// Maybe used to compare different trained models
179    likelihood: F,
180    /// Gaussian process internal fitted params
181    inner_params: GpInnerParams<F>,
182    /// Weights in case of KPLS dimension reduction coming from PLS regression (orig_dim, kpls_dim)
183    w_star: Array2<F>,
184    /// Training inputs
185    xt_norm: NormalizedData<F>,
186    /// Training outputs
187    yt_norm: NormalizedData<F>,
188    /// Training dataset (input, output)
189    pub(crate) training_data: (Array2<F>, Array1<F>),
190    /// Parameters used to fit this model
191    pub(crate) params: GpValidParams<F, Mean, Corr>,
192}
193
194pub(crate) enum GpSamplingMethod {
195    Cholesky,
196    EigenValues,
197}
198
199/// Kriging as GP special case when using constant mean and squared exponential correlation
200pub type Kriging<F> = GpParams<F, ConstantMean, SquaredExponentialCorr>;
201
202impl<F: Float> Kriging<F> {
203    /// Kriging parameters constructor
204    pub fn params() -> GpParams<F, ConstantMean, SquaredExponentialCorr> {
205        GpParams::new(ConstantMean(), SquaredExponentialCorr())
206    }
207}
208
209impl<F: Float, Mean: RegressionModel<F>, Corr: CorrelationModel<F>> Clone
210    for GaussianProcess<F, Mean, Corr>
211{
212    fn clone(&self) -> Self {
213        Self {
214            theta: self.theta.to_owned(),
215            likelihood: self.likelihood,
216            inner_params: self.inner_params.clone(),
217            w_star: self.w_star.to_owned(),
218            xt_norm: self.xt_norm.clone(),
219            yt_norm: self.yt_norm.clone(),
220            training_data: self.training_data.clone(),
221            params: self.params.clone(),
222        }
223    }
224}
225
226impl<F: Float, Mean: RegressionModel<F>, Corr: CorrelationModel<F>> fmt::Display
227    for GaussianProcess<F, Mean, Corr>
228{
229    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
230        write!(
231            f,
232            "GP(mean={}, corr={}, theta={}, variance={}, likelihood={})",
233            self.params.mean,
234            self.params.corr,
235            self.theta,
236            self.inner_params.sigma2,
237            self.likelihood,
238        )
239    }
240}
241
242impl<F: Float, Mean: RegressionModel<F>, Corr: CorrelationModel<F>> GaussianProcess<F, Mean, Corr> {
243    /// Gp parameters contructor
244    pub fn params<NewMean: RegressionModel<F>, NewCorr: CorrelationModel<F>>(
245        mean: NewMean,
246        corr: NewCorr,
247    ) -> GpParams<F, NewMean, NewCorr> {
248        GpParams::new(mean, corr)
249    }
250
251    /// Predict output values at n given `x` points of nx components specified as a (n, nx) matrix.
252    /// Returns n scalar output values as a vector (n,).
253    pub fn predict(&self, x: &ArrayBase<impl Data<Elem = F>, Ix2>) -> Result<Array1<F>> {
254        let xnorm = (x - &self.xt_norm.mean) / &self.xt_norm.std;
255        // Compute the mean term at x
256        let f = self.params.mean.coefs(&xnorm);
257        // Compute the correlation term at x
258        let corr = self._compute_correlation(&xnorm);
259        // Scaled predictor
260        let y_ = &f.dot(&self.inner_params.beta) + &corr.dot(&self.inner_params.gamma);
261        // Predictor
262        Ok((&y_ * &self.yt_norm.std + &self.yt_norm.mean).remove_axis(Axis(1)))
263    }
264
265    /// Predict variance values at n given `x` points of nx components specified as a (n, nx) matrix.
266    /// Returns n variance values as (n,) column vector.
267    pub fn predict_var(&self, x: &ArrayBase<impl Data<Elem = F>, Ix2>) -> Result<Array1<F>> {
268        let xnorm = (x - &self.xt_norm.mean) / &self.xt_norm.std;
269        let corr = self._compute_correlation(&xnorm);
270        let (rt, u) = self._compute_rt_u(&xnorm, &corr);
271
272        let mut mse = Array::ones(rt.ncols()) - rt.mapv(|v| v * v).sum_axis(Axis(0))
273            + u.mapv(|v: F| v * v).sum_axis(Axis(0));
274        mse.mapv_inplace(|v| self.inner_params.sigma2 * v);
275
276        // Mean Squared Error might be slightly negative depending on
277        // machine precision: set to zero in that case
278        Ok(mse.mapv(|v| if v < F::zero() { F::zero() } else { F::cast(v) }))
279    }
280
281    /// Predict both output values and variance at n given `x` points of nx components
282    pub fn predict_valvar(
283        &self,
284        x: &ArrayBase<impl Data<Elem = F>, Ix2>,
285    ) -> Result<(Array1<F>, Array1<F>)> {
286        let xnorm = (x - &self.xt_norm.mean) / &self.xt_norm.std;
287        // Compute the mean term at x
288        let f = self.params.mean.coefs(&xnorm);
289        // Compute the correlation term at x
290        let corr = self._compute_correlation(&xnorm);
291        // Scaled predictor
292        let y_ = &f.dot(&self.inner_params.beta) + &corr.dot(&self.inner_params.gamma);
293        // Predictor
294        let yp = (&y_ * &self.yt_norm.std + &self.yt_norm.mean).remove_axis(Axis(1));
295
296        let (rt, u) = self._compute_rt_u(&xnorm, &corr);
297
298        let mut mse = Array::ones(rt.ncols()) - rt.mapv(|v| v * v).sum_axis(Axis(0))
299            + u.mapv(|v: F| v * v).sum_axis(Axis(0));
300        mse.mapv_inplace(|v| self.inner_params.sigma2 * v);
301
302        // Mean Squared Error might be slightly negative depending on
303        // machine precision: set to zero in that case
304        let vmse = mse.mapv(|v| if v < F::zero() { F::zero() } else { F::cast(v) });
305
306        Ok((yp, vmse))
307    }
308
309    /// Compute covariance matrix given x points specified as a (n, nx) matrix
310    fn _compute_covariance(&self, x: &ArrayBase<impl Data<Elem = F>, Ix2>) -> Array2<F> {
311        let xnorm = (x - &self.xt_norm.mean) / &self.xt_norm.std;
312        let corr = self._compute_correlation(&xnorm);
313        let (rt, u) = self._compute_rt_u(&xnorm, &corr);
314
315        let cross_dx = pairwise_differences(&xnorm, &xnorm);
316        let k = self
317            .params
318            .corr
319            .rval_from_distances(&cross_dx, &self.theta, &self.w_star);
320        let k = k
321            .into_shape_with_order((xnorm.nrows(), xnorm.nrows()))
322            .unwrap();
323
324        // let cov_matrix =
325        //     &array![self.inner_params.sigma2] * (k - rt.t().to_owned().dot(&rt) + u.t().dot(&u));
326        let mut cov_matrix = k - rt.t().to_owned().dot(&rt) + u.t().dot(&u);
327        cov_matrix.mapv_inplace(|v| self.inner_params.sigma2 * v);
328        cov_matrix
329    }
330
331    /// Compute `rt` and `u` matrices and return normalized x as well
332    /// This method factorizes computations done to get variances and covariance matrix
333    fn _compute_rt_u(
334        &self,
335        xnorm: &ArrayBase<impl Data<Elem = F>, Ix2>,
336        corr: &ArrayBase<impl Data<Elem = F>, Ix2>,
337    ) -> (Array2<F>, Array2<F>) {
338        let inners = &self.inner_params;
339
340        let corr_t = corr.t().to_owned();
341        #[cfg(feature = "blas")]
342        let rt = inners
343            .r_chol
344            .to_owned()
345            .with_lapack()
346            .solve_triangular(UPLO::Lower, Diag::NonUnit, &corr_t.with_lapack())
347            .unwrap()
348            .without_lapack();
349        #[cfg(not(feature = "blas"))]
350        let rt = inners
351            .r_chol
352            .solve_triangular(&corr_t, UPLO::Lower)
353            .unwrap();
354
355        let rhs = inners.ft.t().dot(&rt) - self.params.mean.coefs(xnorm).t();
356        #[cfg(feature = "blas")]
357        let u = inners
358            .ft_qr_r
359            .to_owned()
360            .t()
361            .with_lapack()
362            .solve_triangular(UPLO::Upper, Diag::NonUnit, &rhs.with_lapack())
363            .unwrap()
364            .without_lapack();
365        #[cfg(not(feature = "blas"))]
366        let u = inners
367            .ft_qr_r
368            .t()
369            .solve_triangular(&rhs, UPLO::Lower)
370            .unwrap();
371        (rt, u)
372    }
373
374    /// Compute correlation matrix given x points specified as a (n, nx) matrix
375    fn _compute_correlation(&self, xnorm: &ArrayBase<impl Data<Elem = F>, Ix2>) -> Array2<F> {
376        // Get pairwise componentwise L1-distances to the input training set
377        let dx = pairwise_differences(xnorm, &self.xt_norm.data);
378        // Compute the correlation function
379        let r = self
380            .params
381            .corr
382            .rval_from_distances(&dx, &self.theta, &self.w_star);
383        let n_obs = xnorm.nrows();
384        let nt = self.xt_norm.data.nrows();
385        r.into_shape_with_order((n_obs, nt)).unwrap().to_owned()
386    }
387
388    /// Sample the gaussian process for `n_traj` trajectories using cholesky decomposition
389    pub fn sample_chol(&self, x: &ArrayBase<impl Data<Elem = F>, Ix2>, n_traj: usize) -> Array2<F> {
390        self._sample(x, n_traj, GpSamplingMethod::Cholesky)
391    }
392
393    /// Sample the gaussian process for `n_traj` trajectories using eigenvalues decomposition
394    pub fn sample_eig(&self, x: &ArrayBase<impl Data<Elem = F>, Ix2>, n_traj: usize) -> Array2<F> {
395        self._sample(x, n_traj, GpSamplingMethod::EigenValues)
396    }
397
398    /// Sample the gaussian process for `n_traj` trajectories using eigenvalues decomposition (alias of `sample_eig`)
399    pub fn sample(&self, x: &ArrayBase<impl Data<Elem = F>, Ix2>, n_traj: usize) -> Array2<F> {
400        self.sample_eig(x, n_traj)
401    }
402
403    /// Sample the gaussian process for `n_traj` trajectories using either
404    /// cholesky or eigenvalues decomposition to compute the decomposition of the conditioned covariance matrix.
405    /// The later one is recommended as cholesky decomposition suffer from occurence of ill-conditioned matrices
406    /// when the number of x locations increase.
407    fn _sample(
408        &self,
409        x: &ArrayBase<impl Data<Elem = F>, Ix2>,
410        n_traj: usize,
411        method: GpSamplingMethod,
412    ) -> Array2<F> {
413        let mean = self.predict(x).unwrap();
414        let cov = self._compute_covariance(x);
415        sample(x, mean.insert_axis(Axis(1)), cov, n_traj, method)
416    }
417
418    /// Retrieve optimized hyperparameters theta
419    pub fn theta(&self) -> &Array1<F> {
420        &self.theta
421    }
422
423    /// Estimated variance
424    pub fn variance(&self) -> F {
425        self.inner_params.sigma2
426    }
427
428    /// Retrieve reduced likelihood value
429    pub fn likelihood(&self) -> F {
430        self.likelihood
431    }
432
433    /// Retrieve number of PLS components 1 <= n <= x dimension
434    pub fn kpls_dim(&self) -> Option<usize> {
435        if self.w_star.ncols() < self.xt_norm.ncols() {
436            Some(self.w_star.ncols())
437        } else {
438            None
439        }
440    }
441
442    /// Retrieve input and output dimensions
443    pub fn dims(&self) -> (usize, usize) {
444        (self.xt_norm.ncols(), self.yt_norm.ncols())
445    }
446
447    /// Predict derivatives of the output prediction
448    /// wrt the kxth component at a set of n points `x` specified as a (n, nx) matrix where x has nx components.
449    pub fn predict_kth_derivatives(
450        &self,
451        x: &ArrayBase<impl Data<Elem = F>, Ix2>,
452        kx: usize,
453    ) -> Array1<F> {
454        let xnorm = (x - &self.xt_norm.mean) / &self.xt_norm.std;
455        let corr = self._compute_correlation(&xnorm);
456
457        let beta = &self.inner_params.beta;
458        let gamma = &self.inner_params.gamma;
459
460        let df_dx_kx = if self.inner_params.beta.nrows() <= 1 + self.xt_norm.data.ncols() {
461            // for constant or linear: df/dx = cst ([0] or [1]) for all x, so takes use x[0] to get the constant
462            let df = self.params.mean.jac(&x.row(0));
463            let df_dx = df.t().row(kx).dot(beta);
464            df_dx.broadcast((x.nrows(), 1)).unwrap().to_owned()
465        } else {
466            // for quadratic df/dx really depends on x
467            let mut dfdx = Array2::zeros((x.nrows(), 1));
468            Zip::from(dfdx.rows_mut())
469                .and(xnorm.rows())
470                .for_each(|mut dfxi, xi| {
471                    let df = self.params.mean.jac(&xi);
472                    let df_dx = (df.t().row(kx)).dot(beta);
473                    dfxi.assign(&df_dx);
474                });
475            dfdx
476        };
477
478        let nr = x.nrows();
479        let nc = self.xt_norm.data.nrows();
480        let d_dx_1 = &xnorm
481            .column(kx)
482            .to_owned()
483            .into_shape_with_order((nr, 1))
484            .unwrap()
485            .broadcast((nr, nc))
486            .unwrap()
487            .to_owned();
488
489        let d_dx_2 = self
490            .xt_norm
491            .data
492            .column(kx)
493            .to_owned()
494            .as_standard_layout()
495            .into_shape_with_order((1, nc))
496            .unwrap()
497            .to_owned();
498
499        let d_dx = d_dx_1 - d_dx_2;
500
501        // Get pairwise componentwise L1-distances to the input training set
502        let theta = &self.theta.to_owned();
503        let d_dx_corr = d_dx * corr;
504
505        // (df(xnew)/dx).beta + (dr(xnew)/dx).R^-1(ytrain - f.beta)
506        // gamma = R^-1(ytrain - f.beta)
507        // Warning: squared exponential only
508        let res = (df_dx_kx - d_dx_corr.dot(gamma).map(|v| F::cast(2.) * theta[kx] * *v))
509            * self.yt_norm.std[0]
510            / self.xt_norm.std[kx];
511        res.column(0).to_owned()
512    }
513
514    /// Predict derivatives at a set of point `x` specified as a (n, nx) matrix where x has nx components.
515    /// Returns a (n, nx) matrix containing output derivatives at x wrt each nx components
516    pub fn predict_gradients(&self, x: &ArrayBase<impl Data<Elem = F>, Ix2>) -> Array2<F> {
517        let mut drv = Array2::<F>::zeros((x.nrows(), self.xt_norm.data.ncols()));
518        Zip::from(drv.rows_mut())
519            .and(x.rows())
520            .for_each(|mut row, xi| {
521                let pred = self.predict_jacobian(&xi);
522                row.assign(&pred.column(0));
523            });
524        drv
525    }
526
527    /// Predict gradient at a given x point
528    /// Note: output is one dimensional, named jacobian as result is given as a one-column matrix  
529    fn predict_jacobian(&self, x: &ArrayBase<impl Data<Elem = F>, Ix1>) -> Array2<F> {
530        let xx = x.to_owned().insert_axis(Axis(0));
531        let mut jac = Array2::zeros((xx.ncols(), 1));
532
533        let xnorm = (xx - &self.xt_norm.mean) / &self.xt_norm.std;
534
535        let beta = &self.inner_params.beta;
536        let gamma = &self.inner_params.gamma;
537
538        let df = self.params.mean.jac(&xnorm.row(0));
539        let df_dx = df.t().dot(beta);
540
541        let dr = self
542            .params
543            .corr
544            .jac(&xnorm.row(0), &self.xt_norm.data, &self.theta, &self.w_star);
545
546        let dr_dx = df_dx + dr.t().dot(gamma);
547        Zip::from(jac.rows_mut())
548            .and(dr_dx.rows())
549            .and(&self.xt_norm.std)
550            .for_each(|mut jc, dr_i, std_i| {
551                let jc_i = dr_i.map(|v| *v * self.yt_norm.std[0] / *std_i);
552                jc.assign(&jc_i)
553            });
554
555        jac
556    }
557
558    /// Predict variance derivatives at a point `x` specified as a (nx,) vector where x has nx components.
559    /// Returns a (nx,) vector containing variance derivatives at `x` wrt each nx components
560    #[cfg(not(feature = "blas"))]
561    pub fn predict_var_gradients_single(
562        &self,
563        x: &ArrayBase<impl Data<Elem = F>, Ix1>,
564    ) -> Array1<F> {
565        let x = &(x.to_owned().insert_axis(Axis(0)));
566        let xnorm = (x - &self.xt_norm.mean) / &self.xt_norm.std;
567        let sigma2 = self.inner_params.sigma2;
568        let r_chol = &self.inner_params.r_chol;
569
570        let (r, dr) = self.params.corr.rval_with_jac(
571            &xnorm.row(0),
572            &self.xt_norm.data,
573            &self.theta,
574            &self.w_star,
575        );
576
577        // rho1 = Rc^-1 . r(x, X)
578        let rho1 = r_chol.solve_triangular(&r, UPLO::Lower).unwrap();
579
580        // inv_kr = Rc^t^-1 . Rc^-1 . r(x, X) = R^-1 . r(x, X)
581        let inv_kr = r_chol.t().solve_triangular(&rho1, UPLO::Upper).unwrap();
582
583        // p1 = ((dr(x, X)/dx)^t . R^-1 . r(x, X))^t = ((R^-1 . r(x, X))^t . dr(x, X)/dx) = r(x, X)^t . R^-1 . dr(x, X)/dx = p2
584        // let p1 = dr.t().dot(&inv_kr).t().to_owned();
585
586        // p2 = ((R^-1 . r(x, X))^t . dr(x, X)/dx)^t = dr(x, X)/dx)^t . R^-1 . r(x, X) = p1
587        let p2 = inv_kr.t().dot(&dr);
588
589        let f_x = self.params.mean.coefs(&xnorm).t().to_owned();
590        let f_mean = self.params.mean.coefs(&self.xt_norm.data);
591
592        // rho2 = Rc^-1 . F(X)
593        let rho2 = r_chol.solve_triangular(&f_mean, UPLO::Lower).unwrap();
594        // inv_kf = Rc^-1^t . Rc^-1 . F(X) = R^-1 . F(X)
595        let inv_kf = r_chol.t().solve_triangular(&rho2, UPLO::Upper).unwrap();
596
597        // A = f(x)^t - r(x, X)^t . R^-1 . F(X)   -> (1 x m)
598        let a_mat = f_x.t().to_owned() - r.t().dot(&inv_kf);
599
600        // B = F(X)^t . R^-1 . F(X)
601        let b_mat = f_mean.t().dot(&inv_kf);
602        // rho3 = Bc
603        let rho3 = b_mat.cholesky().unwrap();
604        // inv_bat = Bc^-1 . A^t
605        let inv_bat = rho3.solve_triangular(&a_mat.t(), UPLO::Lower).unwrap();
606        // D = Bc^t-1 . Bc^-1 . A^t = B^-1 . A^t
607        let d_mat = rho3.t().solve_triangular(&inv_bat, UPLO::Upper).unwrap();
608
609        let df = self.params.mean.jac(&xnorm.row(0));
610
611        // dA/dx = df(x)/dx^t - dr(x, X)/dx^t . R^-1 . F
612        let d_a = df.t().to_owned() - dr.t().dot(&inv_kf);
613
614        // p3 = (dA/dx . B^-1 . A^t)^t = A . B^-1 . dA/dx^t
615        // let p3 = d_a.dot(&d_mat).t().to_owned();
616
617        // p4 = (B^-1 . A)^t . dA/dx^t = A^t . B^-1 . dA/dx^t = p3
618        let p4 = d_mat.t().dot(&d_a.t());
619        let two = F::cast(2.);
620        let prime = (p4 - p2).mapv(|v| two * v);
621
622        let x_std = &self.xt_norm.std;
623        let dvar = (prime / x_std).mapv(|v| v * sigma2);
624        dvar.row(0).into_owned()
625    }
626
627    /// See non blas version
628    #[cfg(feature = "blas")]
629    pub fn predict_var_gradients_single(
630        &self,
631        x: &ArrayBase<impl Data<Elem = F>, Ix1>,
632    ) -> Array1<F> {
633        let x = &(x.to_owned().insert_axis(Axis(0)));
634        let xnorm = (x - &self.xt_norm.mean) / &self.xt_norm.std;
635
636        let dx = pairwise_differences(&xnorm, &self.xt_norm.data);
637
638        let sigma2 = self.inner_params.sigma2;
639        let r_chol = &self.inner_params.r_chol.to_owned().with_lapack();
640
641        let r = self
642            .params
643            .corr
644            .rval_from_distances(&dx, &self.theta, &self.w_star)
645            .with_lapack();
646        let dr = self
647            .params
648            .corr
649            .jac(&xnorm.row(0), &self.xt_norm.data, &self.theta, &self.w_star)
650            .with_lapack();
651
652        let rho1 = r_chol
653            .solve_triangular(UPLO::Lower, Diag::NonUnit, &r)
654            .unwrap();
655        let inv_kr = r_chol
656            .t()
657            .solve_triangular(UPLO::Upper, Diag::NonUnit, &rho1)
658            .unwrap();
659
660        // let p1 = dr.t().dot(&inv_kr).t().to_owned();
661
662        let p2 = inv_kr.t().dot(&dr);
663
664        let f_x = self.params.mean.coefs(x).t().to_owned();
665        let f_mean = self.params.mean.coefs(&self.xt_norm.data).with_lapack();
666
667        let rho2 = r_chol
668            .solve_triangular(UPLO::Lower, Diag::NonUnit, &f_mean)
669            .unwrap();
670        let inv_kf = r_chol
671            .t()
672            .solve_triangular(UPLO::Upper, Diag::NonUnit, &rho2)
673            .unwrap();
674
675        let a_mat = f_x.t().to_owned().with_lapack() - r.t().dot(&inv_kf);
676
677        let b_mat = f_mean.t().dot(&inv_kf);
678
679        let d_mat = match b_mat.cholesky(UPLO::Lower) {
680            Ok(rho3) => {
681                let inv_bat = rho3
682                    .solve_triangular(UPLO::Upper, Diag::NonUnit, &a_mat.t().to_owned())
683                    .unwrap();
684                rho3.t()
685                    .solve_triangular(UPLO::Upper, Diag::NonUnit, &inv_bat)
686                    .unwrap()
687            }
688            Err(_) => {
689                warn!("Cholesky decomposition error during variance dervivatives computation");
690                Array2::zeros((b_mat.nrows(), b_mat.ncols()))
691            }
692        };
693
694        let df = self.params.mean.jac(&xnorm.row(0)).with_lapack();
695
696        let d_a = df.t().to_owned() - dr.t().dot(&inv_kf);
697        // let p3 = d_a.dot(&d_mat).t();
698        let p4 = d_mat.t().dot(&d_a.t());
699
700        let two = F::cast(2.);
701        let prime_t = (p4 - p2).without_lapack().mapv(|v| two * v);
702
703        let x_std = &self.xt_norm.std;
704        let dvar = (prime_t / x_std).mapv(|v| v * sigma2);
705        dvar.row(0).into_owned()
706    }
707
708    /// Predict variance derivatives at a set of points `x` specified as a (n, nx) matrix where x has nx components.
709    /// Returns a (n, nx) matrix containing variance derivatives at `x` wrt each nx components
710    pub fn predict_var_gradients(&self, x: &ArrayBase<impl Data<Elem = F>, Ix2>) -> Array2<F> {
711        let mut derivs = Array::zeros((x.nrows(), x.ncols()));
712        Zip::from(derivs.rows_mut())
713            .and(x.rows())
714            .for_each(|mut der, x| der.assign(&self.predict_var_gradients_single(&x)));
715        derivs
716    }
717
718    /// Predict both value and variance gradients at a set of points `x` specified as a (n, nx) matrix
719    /// where x has nx components.
720    pub fn predict_valvar_gradients(
721        &self,
722        x: &ArrayBase<impl Data<Elem = F>, Ix2>,
723    ) -> (Array2<F>, Array2<F>) {
724        let mut val_derivs = Array::zeros((x.nrows(), x.ncols()));
725        let mut var_derivs = Array::zeros((x.nrows(), x.ncols()));
726        Zip::from(val_derivs.rows_mut())
727            .and(var_derivs.rows_mut())
728            .and(x.rows())
729            .for_each(|mut val_der, mut var_der, x| {
730                val_der.assign(&self.predict_jacobian(&x).column(0));
731                var_der.assign(&self.predict_var_gradients_single(&x));
732            });
733        (val_derivs, var_derivs)
734    }
735}
736
737impl<F, D, Mean, Corr> PredictInplace<ArrayBase<D, Ix2>, Array1<F>>
738    for GaussianProcess<F, Mean, Corr>
739where
740    F: Float,
741    D: Data<Elem = F>,
742    Mean: RegressionModel<F>,
743    Corr: CorrelationModel<F>,
744{
745    fn predict_inplace(&self, x: &ArrayBase<D, Ix2>, y: &mut Array1<F>) {
746        assert_eq!(
747            x.nrows(),
748            y.len(),
749            "The number of data points must match the number of output targets."
750        );
751
752        let values = self.predict(x).expect("GP Prediction");
753        *y = values;
754    }
755
756    fn default_target(&self, x: &ArrayBase<D, Ix2>) -> Array1<F> {
757        Array1::zeros((x.nrows(),))
758    }
759}
760
761/// Gausssian Process adaptator to implement `linfa::Predict` trait for variance prediction.
762#[allow(dead_code)]
763pub struct GpVariancePredictor<'a, F, Mean, Corr>(&'a GaussianProcess<F, Mean, Corr>)
764where
765    F: Float,
766    Mean: RegressionModel<F>,
767    Corr: CorrelationModel<F>;
768
769impl<F, D, Mean, Corr> PredictInplace<ArrayBase<D, Ix2>, Array1<F>>
770    for GpVariancePredictor<'_, F, Mean, Corr>
771where
772    F: Float,
773    D: Data<Elem = F>,
774    Mean: RegressionModel<F>,
775    Corr: CorrelationModel<F>,
776{
777    fn predict_inplace(&self, x: &ArrayBase<D, Ix2>, y: &mut Array1<F>) {
778        assert_eq!(
779            x.nrows(),
780            y.len(),
781            "The number of data points must match the number of output targets."
782        );
783
784        let values = self.0.predict_var(x).expect("GP Prediction");
785        *y = values;
786    }
787
788    fn default_target(&self, x: &ArrayBase<D, Ix2>) -> Array1<F> {
789        Array1::zeros(x.nrows())
790    }
791}
792
793impl<F: Float, Mean: RegressionModel<F>, Corr: CorrelationModel<F>, D: Data<Elem = F>>
794    Fit<ArrayBase<D, Ix2>, ArrayBase<D, Ix1>, GpError> for GpValidParams<F, Mean, Corr>
795{
796    type Object = GaussianProcess<F, Mean, Corr>;
797
798    /// Fit GP parameters using maximum likelihood
799    fn fit(
800        &self,
801        dataset: &DatasetBase<ArrayBase<D, Ix2>, ArrayBase<D, Ix1>>,
802    ) -> Result<Self::Object> {
803        let x = dataset.records();
804        let y = dataset.targets().to_owned().insert_axis(Axis(1));
805
806        if let Some(d) = self.kpls_dim()
807            && *d > x.ncols()
808        {
809            return Err(GpError::InvalidValueError(format!(
810                "Dimension reduction {} should be smaller than actual \
811                    training input dimensions {}",
812                d,
813                x.ncols()
814            )));
815        }
816
817        let dim = if let Some(n_components) = self.kpls_dim() {
818            *n_components
819        } else {
820            x.ncols()
821        };
822
823        let (x, y, active, init) = match self.theta_tuning() {
824            ThetaTuning::Fixed(init) | ThetaTuning::Full { init, bounds: _ } => (
825                x.to_owned(),
826                y.to_owned(),
827                (0..dim).collect::<Vec<_>>(),
828                init,
829            ),
830            ThetaTuning::Partial {
831                init,
832                bounds: _,
833                active,
834            } => (x.to_owned(), y.to_owned(), active.to_vec(), init),
835        };
836        // Initial guess for theta
837        let theta0_dim = init.len();
838        let theta0 = if theta0_dim == 1 {
839            Array1::from_elem(dim, init[0])
840        } else if theta0_dim == dim {
841            init.to_owned()
842        } else {
843            panic!(
844                "Initial guess for theta should be either 1-dim or dim of xtrain (w_star.ncols()), got {theta0_dim}"
845            )
846        };
847
848        let xtrain = NormalizedData::new(&x);
849        let ytrain = NormalizedData::new(&y);
850
851        let mut w_star = Array2::eye(x.ncols());
852        if let Some(n_components) = self.kpls_dim() {
853            let ds = Dataset::new(x.to_owned(), y.to_owned());
854            w_star = PlsRegression::params(*n_components).fit(&ds).map_or_else(
855                |e| match e {
856                    linfa_pls::PlsError::PowerMethodConstantResidualError() => {
857                        Ok(Array2::zeros((x.ncols(), *n_components)))
858                    }
859                    err => Err(err),
860                },
861                |v| Ok(v.rotations().0.to_owned()),
862            )?;
863        };
864        let x_distances = DiffMatrix::new(&xtrain.data);
865        let sums = x_distances
866            .d
867            .mapv(|v| num_traits::float::Float::abs(v))
868            .sum_axis(Axis(1));
869        if *sums.min().unwrap() == F::zero() {
870            log::warn!("Warning: multiple x have the same value (at least same row twice).");
871        }
872        let fx = self.mean().coefs(&xtrain.data);
873
874        let opt_params = match self.theta_tuning() {
875            ThetaTuning::Fixed(init) => {
876                // Easy path no optimization
877                init.to_owned()
878            }
879            ThetaTuning::Full { init: _, bounds }
880            | ThetaTuning::Partial {
881                init: _,
882                bounds,
883                active: _,
884            } => {
885                let base: f64 = 10.;
886                let objfn = |x: &[f64], _gradient: Option<&mut [f64]>, _params: &mut ()| -> f64 {
887                    let mut theta = theta0.to_owned();
888                    let xarr = x.iter().map(|v| base.powf(*v)).collect::<Vec<_>>();
889                    std::iter::zip(active.clone(), xarr).for_each(|(i, xi)| theta[i] = F::cast(xi));
890
891                    for v in theta.iter() {
892                        // check theta as optimizer may return nan values
893                        if v.is_nan() {
894                            // shortcut return worst value wrt to rlf minimization
895                            return f64::INFINITY;
896                        }
897                    }
898                    let rxx = self
899                        .corr()
900                        .rval_from_distances(&x_distances.d, &theta, &w_star);
901                    match reduced_likelihood(&fx, rxx, &x_distances, &ytrain, self.nugget()) {
902                        Ok(r) => unsafe { -(*(&r.0 as *const F as *const f64)) },
903                        Err(_) => f64::INFINITY,
904                    }
905                };
906
907                // Multistart: user theta0 + 1e-5, 1e-4, 1e-3, 1e-2, 0.1, 1., 10.
908                // let bounds = vec![(F::cast(-6.), F::cast(2.)); theta0.len()];
909                let bounds_dim = bounds.len();
910                let bounds = if bounds_dim == 1 {
911                    vec![bounds[0]; w_star.ncols()]
912                } else if bounds_dim == w_star.ncols() {
913                    bounds.to_vec()
914                } else {
915                    panic!(
916                        "Bounds for theta should be either 1-dim or dim of xtrain ({}), got {}",
917                        w_star.ncols(),
918                        bounds_dim
919                    )
920                };
921
922                // Select init params and bounds wrt to activity
923                let active_bounds = bounds
924                    .iter()
925                    .enumerate()
926                    .filter(|(i, _)| active.contains(i))
927                    .map(|(_, &b)| b)
928                    .collect::<Vec<_>>();
929                let (theta_inits, bounds) = prepare_multistart(
930                    self.n_start(),
931                    &theta0.select(Axis(0), &active),
932                    &active_bounds,
933                );
934                debug!("Optimize with multistart theta = {theta_inits:?} and bounds = {bounds:?}");
935                let now = Instant::now();
936                let opt_params = (0..theta_inits.nrows())
937                    .into_par_iter()
938                    .map(|i| {
939                        optimize_params(
940                            objfn,
941                            &theta_inits.row(i).to_owned(),
942                            &bounds,
943                            CobylaParams {
944                                maxeval: (10 * theta_inits.ncols())
945                                    .clamp(GP_COBYLA_MIN_EVAL, self.max_eval()),
946                                ..CobylaParams::default()
947                            },
948                        )
949                    })
950                    .reduce(
951                        || (f64::INFINITY, Array::ones((theta_inits.ncols(),))),
952                        |a, b| if b.0 < a.0 { b } else { a },
953                    );
954                debug!("elapsed optim = {:?}", now.elapsed().as_millis());
955                opt_params.1.mapv(|v| F::cast(base.powf(v)))
956            }
957        };
958
959        // In case of partial optimization we set only active components
960        let opt_params = match self.theta_tuning() {
961            ThetaTuning::Fixed(_) | ThetaTuning::Full { init: _, bounds: _ } => opt_params,
962            ThetaTuning::Partial {
963                init,
964                bounds: _,
965                active,
966            } => {
967                let mut opt_theta = init.to_owned();
968                std::iter::zip(active.clone(), opt_params)
969                    .for_each(|(i, xi)| opt_theta[i] = F::cast(xi));
970                opt_theta
971            }
972        };
973
974        let rxx = self
975            .corr()
976            .rval_from_distances(&x_distances.d, &opt_params, &w_star);
977        let (lkh, inner_params) =
978            reduced_likelihood(&fx, rxx, &x_distances, &ytrain, self.nugget())?;
979        Ok(GaussianProcess {
980            theta: opt_params,
981            likelihood: lkh,
982            inner_params,
983            w_star,
984            xt_norm: xtrain,
985            yt_norm: ytrain,
986            training_data: (x.to_owned(), y.to_owned().remove_axis(Axis(1))),
987            params: self.clone(),
988        })
989    }
990}
991
992/// Compute reduced likelihood function
993/// fx: mean factors term at x samples,
994/// rxx: correlation factors at x samples,
995/// x_distances: pairwise distances between x samples
996/// ytrain: normalized output training values
997/// nugget: factor to improve numerical stability  
998#[cfg(not(feature = "blas"))]
999fn reduced_likelihood<F: Float>(
1000    fx: &ArrayBase<impl Data<Elem = F>, Ix2>,
1001    rxx: ArrayBase<impl Data<Elem = F>, Ix2>,
1002    x_distances: &DiffMatrix<F>,
1003    ytrain: &NormalizedData<F>,
1004    nugget: F,
1005) -> Result<(F, GpInnerParams<F>)> {
1006    // Set up R
1007    let mut r_mx: Array2<F> = Array2::<F>::eye(x_distances.n_obs).mapv(|v| v + v * nugget);
1008    for (i, ij) in x_distances.d_indices.outer_iter().enumerate() {
1009        r_mx[[ij[0], ij[1]]] = rxx[[i, 0]];
1010        r_mx[[ij[1], ij[0]]] = rxx[[i, 0]];
1011    }
1012    let fxl = fx;
1013    // R cholesky decomposition
1014    let r_chol = r_mx.cholesky()?;
1015    // Solve generalized least squared problem
1016    let ft = r_chol.solve_triangular(fxl, UPLO::Lower)?;
1017    let (ft_qr_q, ft_qr_r) = ft.qr().unwrap().into_decomp();
1018
1019    // Check whether we have an ill-conditionned problem
1020    let (_, sv_qr_r, _) = ft_qr_r.svd(false, false).unwrap();
1021    let cond_ft = sv_qr_r[sv_qr_r.len() - 1] / sv_qr_r[0];
1022    if F::cast(cond_ft) < F::cast(1e-10) {
1023        let (_, sv_f, _) = &fxl.svd(false, false).unwrap();
1024        let cond_fx = sv_f[0] / sv_f[sv_f.len() - 1];
1025        if F::cast(cond_fx) > F::cast(1e15) {
1026            return Err(GpError::LikelihoodComputationError(
1027                "F is too ill conditioned. Poor combination \
1028                of regression model and observations."
1029                    .to_string(),
1030            ));
1031        } else {
1032            // ft is too ill conditioned, get out (try different theta)
1033            return Err(GpError::LikelihoodComputationError(
1034                "ft is too ill conditioned, try another theta again".to_string(),
1035            ));
1036        }
1037    }
1038    let yt = r_chol.solve_triangular(&ytrain.data, UPLO::Lower)?;
1039
1040    let beta = ft_qr_r.solve_triangular_into(ft_qr_q.t().dot(&yt), UPLO::Upper)?;
1041    let rho = yt - ft.dot(&beta);
1042    let rho_sqr = rho.mapv(|v| v * v).sum_axis(Axis(0));
1043
1044    let gamma = r_chol.t().solve_triangular_into(rho, UPLO::Upper)?;
1045    // The determinant of R is equal to the squared product of
1046    // the diagonal elements of its Cholesky decomposition r_chol
1047    let n_obs: F = F::cast(x_distances.n_obs);
1048
1049    let logdet = r_chol.diag().mapv(|v: F| v.log10()).sum() * F::cast(2.) / n_obs;
1050
1051    // Reduced likelihood
1052    let sigma2 = rho_sqr / n_obs;
1053    let reduced_likelihood = -n_obs * (sigma2.sum().log10() + logdet);
1054
1055    Ok((
1056        reduced_likelihood,
1057        GpInnerParams {
1058            sigma2: sigma2[0] * ytrain.std[0] * ytrain.std[0],
1059            beta,
1060            gamma,
1061            r_chol,
1062            ft,
1063            ft_qr_r,
1064        },
1065    ))
1066}
1067
1068/// See non blas version
1069#[cfg(feature = "blas")]
1070fn reduced_likelihood<F: Float>(
1071    fx: &ArrayBase<impl Data<Elem = F>, Ix2>,
1072    rxx: ArrayBase<impl Data<Elem = F>, Ix2>,
1073    x_distances: &DiffMatrix<F>,
1074    ytrain: &NormalizedData<F>,
1075    nugget: F,
1076) -> Result<(F, GpInnerParams<F>)> {
1077    // Set up R
1078    let mut r_mx: Array2<F> = Array2::<F>::eye(x_distances.n_obs).mapv(|v| v + v * nugget);
1079    for (i, ij) in x_distances.d_indices.outer_iter().enumerate() {
1080        r_mx[[ij[0], ij[1]]] = rxx[[i, 0]];
1081        r_mx[[ij[1], ij[0]]] = rxx[[i, 0]];
1082    }
1083
1084    let fxl = fx.to_owned().with_lapack();
1085
1086    // R cholesky decomposition
1087    let r_chol = r_mx.with_lapack().cholesky(UPLO::Lower)?;
1088
1089    // Solve generalized least squared problem
1090    let ft = r_chol.solve_triangular(UPLO::Lower, Diag::NonUnit, &fxl)?;
1091    let (ft_qr_q, ft_qr_r) = ft.qr().unwrap();
1092
1093    // Check whether we have an ill-conditionned problem
1094    let (_, sv_qr_r, _) = ft_qr_r.svd(false, false).unwrap();
1095    let cond_ft = sv_qr_r[sv_qr_r.len() - 1] / sv_qr_r[0];
1096    if F::cast(cond_ft) < F::cast(1e-10) {
1097        let (_, sv_f, _) = &fxl.svd(false, false).unwrap();
1098        let cond_fx = sv_f[0] / sv_f[sv_f.len() - 1];
1099        if F::cast(cond_fx) > F::cast(1e15) {
1100            return Err(GpError::LikelihoodComputationError(
1101                "F is too ill conditioned. Poor combination \
1102                of regression model and observations."
1103                    .to_string(),
1104            ));
1105        } else {
1106            // ft is too ill conditioned, get out (try different theta)
1107            return Err(GpError::LikelihoodComputationError(
1108                "ft is too ill conditioned, try another theta again".to_string(),
1109            ));
1110        }
1111    }
1112
1113    let yt = r_chol.solve_triangular(
1114        UPLO::Lower,
1115        Diag::NonUnit,
1116        &ytrain.data.to_owned().with_lapack(),
1117    )?;
1118
1119    let beta = ft_qr_r.solve_triangular_into(UPLO::Upper, Diag::NonUnit, ft_qr_q.t().dot(&yt))?;
1120
1121    let rho = yt - ft.dot(&beta);
1122    let rho_sqr = rho.mapv(|v| v * v).sum_axis(Axis(0));
1123    let rho_sqr = rho_sqr.without_lapack();
1124
1125    let gamma = r_chol
1126        .t()
1127        .solve_triangular_into(UPLO::Upper, Diag::NonUnit, rho)?;
1128
1129    // The determinant of R is equal to the squared product of
1130    // the diagonal elements of its Cholesky decomposition r_chol
1131    let n_obs: F = F::cast(x_distances.n_obs);
1132
1133    let logdet = r_chol
1134        .to_owned()
1135        .without_lapack()
1136        .diag()
1137        .mapv(|v: F| v.log10())
1138        .sum()
1139        * F::cast(2.)
1140        / n_obs;
1141
1142    // Reduced likelihood
1143    let sigma2: Array1<F> = rho_sqr / n_obs;
1144    let reduced_likelihood = -n_obs * (sigma2.sum().log10() + logdet);
1145    Ok((
1146        reduced_likelihood,
1147        GpInnerParams {
1148            sigma2: sigma2[0] * ytrain.std[0] * ytrain.std[0],
1149            beta: beta.without_lapack(),
1150            gamma: gamma.without_lapack(),
1151            r_chol: r_chol.without_lapack(),
1152            ft: ft.without_lapack(),
1153            ft_qr_r: ft_qr_r.without_lapack(),
1154        },
1155    ))
1156}
1157
1158/// Sample the gaussian process for `n_traj` trajectories using either
1159/// cholesky or eigenvalues decomposition to compute the decomposition of the conditioned covariance matrix.
1160/// `cov_x` is the covariance matrix at the given x points [n, nx]
1161/// The later one is recommended as cholesky decomposition suffer from occurence of ill-conditioned matrices
1162/// when the number of x locations increase.
1163pub(crate) fn sample<F: Float>(
1164    x: &ArrayBase<impl Data<Elem = F>, Ix2>,
1165    mean_x: Array2<F>,
1166    cov_x: Array2<F>,
1167    n_traj: usize,
1168    method: GpSamplingMethod,
1169) -> Array2<F> {
1170    let n_eval = x.nrows();
1171    let c = match method {
1172        GpSamplingMethod::Cholesky => {
1173            #[cfg(not(feature = "blas"))]
1174            let c = cov_x.with_lapack().cholesky().unwrap();
1175            #[cfg(feature = "blas")]
1176            let c = cov_x.with_lapack().cholesky(UPLO::Lower).unwrap();
1177            c
1178        }
1179        GpSamplingMethod::EigenValues => {
1180            #[cfg(feature = "blas")]
1181            let (v, w) = cov_x.with_lapack().eigh(UPLO::Lower).unwrap();
1182            #[cfg(not(feature = "blas"))]
1183            let (v, w) = cov_x.with_lapack().eigh_into().unwrap();
1184            let v = v.mapv(F::cast);
1185            let v = v.mapv(|x| {
1186                // We lower bound the float value at 1e-9
1187                if x < F::cast(1e-9) {
1188                    return F::zero();
1189                }
1190                x.sqrt()
1191            });
1192            let d = Array2::from_diag(&v).with_lapack();
1193            #[cfg(feature = "blas")]
1194            let c = w.dot(&d);
1195            #[cfg(not(feature = "blas"))]
1196            let c = w.dot(&d);
1197            c
1198        }
1199    }
1200    .without_lapack();
1201    let normal = Normal::new(0., 1.).unwrap();
1202    let ary = Array::random((n_eval, n_traj), normal).mapv(|v| F::cast(v));
1203    mean_x.to_owned() + c.dot(&ary)
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208    use super::*;
1209    use approx::{assert_abs_diff_eq, assert_abs_diff_ne};
1210    use argmin_testfunctions::rosenbrock;
1211    use egobox_doe::{Lhs, LhsKind, SamplingMethod};
1212    use linfa::prelude::Predict;
1213    #[cfg(not(feature = "blas"))]
1214    use linfa_linalg::norm::Norm;
1215    use ndarray::{Array, Zip, arr1, arr2, array};
1216    #[cfg(feature = "blas")]
1217    use ndarray_linalg::Norm;
1218    use ndarray_npy::write_npy;
1219    use ndarray_rand::RandomExt;
1220    use ndarray_rand::rand::SeedableRng;
1221    use ndarray_rand::rand_distr::Uniform;
1222    use ndarray_stats::DeviationExt;
1223    use paste::paste;
1224    use rand_xoshiro::Xoshiro256Plus;
1225
1226    #[test]
1227    fn test_constant_function() {
1228        let dim = 3;
1229        let lim = array![[0., 1.]];
1230        let xlimits = lim.broadcast((dim, 2)).unwrap();
1231        let rng = Xoshiro256Plus::seed_from_u64(42);
1232        let nt = 5;
1233        let xt = Lhs::new(&xlimits).with_rng(rng).sample(nt);
1234        let yt = Array::from_vec(vec![3.1; nt]);
1235        let gp = GaussianProcess::<f64, ConstantMean, SquaredExponentialCorr>::params(
1236            ConstantMean::default(),
1237            SquaredExponentialCorr::default(),
1238        )
1239        .theta_init(array![0.1])
1240        .kpls_dim(Some(1))
1241        .fit(&Dataset::new(xt, yt))
1242        .expect("GP fit error");
1243        let rng = Xoshiro256Plus::seed_from_u64(43);
1244        let xtest = Lhs::new(&xlimits).with_rng(rng).sample(nt);
1245        let ytest = gp.predict(&xtest).expect("prediction error");
1246        assert_abs_diff_eq!(Array::from_elem((nt,), 3.1), ytest, epsilon = 1e-6);
1247    }
1248
1249    macro_rules! test_gp {
1250        ($regr:ident, $corr:ident) => {
1251            paste! {
1252
1253                #[test]
1254                fn [<test_gp_ $regr:snake _ $corr:snake >]() {
1255                    let xt = array![[0.0], [1.0], [2.0], [3.0], [4.0]];
1256                    let xplot = Array::linspace(0., 4., 100).insert_axis(Axis(1));
1257                    let yt = array![0.0, 1.0, 1.5, 0.9, 1.0];
1258                    let gp = GaussianProcess::<f64, [<$regr Mean>], [<$corr Corr>] >::params(
1259                        [<$regr Mean>]::default(),
1260                        [<$corr Corr>]::default(),
1261                    )
1262                    .theta_init(array![0.1])
1263                    .fit(&Dataset::new(xt, yt))
1264                    .expect("GP fit error");
1265                    let yvals = gp
1266                        .predict(&arr2(&[[1.0], [3.5]]))
1267                        .expect("prediction error");
1268                    let expected_y = arr1(&[1.0, 0.9]);
1269                    assert_abs_diff_eq!(expected_y, yvals, epsilon = 0.5);
1270
1271                    let gpr_vals = gp.predict(&xplot).unwrap();
1272
1273                    let yvars = gp
1274                        .predict_var(&arr2(&[[1.0], [3.5]]))
1275                        .expect("prediction error");
1276                    let expected_vars = arr1(&[0., 0.1]);
1277                    assert_abs_diff_eq!(expected_vars, yvars, epsilon = 0.5);
1278
1279                    let gpr_vars = gp.predict_var(&xplot).unwrap();
1280
1281                    let test_dir = "target/tests";
1282                    std::fs::create_dir_all(test_dir).ok();
1283
1284                    let xplot_file = stringify!([<gp_x_ $regr:snake _ $corr:snake >]);
1285                    let file_path = format!("{}/{}.npy", test_dir, xplot_file);
1286                    write_npy(file_path, &xplot).expect("x saved");
1287
1288                    let gp_vals_file = stringify!([<gp_vals_ $regr:snake _ $corr:snake >]);
1289                    let file_path = format!("{}/{}.npy", test_dir, gp_vals_file);
1290                    write_npy(file_path, &gpr_vals).expect("gp vals saved");
1291
1292                    let gp_vars_file = stringify!([<gp_vars_ $regr:snake _ $corr:snake >]);
1293                    let file_path = format!("{}/{}.npy", test_dir, gp_vars_file);
1294                    write_npy(file_path, &gpr_vars).expect("gp vars saved");
1295                }
1296            }
1297        };
1298    }
1299
1300    test_gp!(Constant, SquaredExponential);
1301    test_gp!(Constant, AbsoluteExponential);
1302    test_gp!(Constant, Matern32);
1303    test_gp!(Constant, Matern52);
1304
1305    test_gp!(Linear, SquaredExponential);
1306    test_gp!(Linear, AbsoluteExponential);
1307    test_gp!(Linear, Matern32);
1308    test_gp!(Linear, Matern52);
1309
1310    test_gp!(Quadratic, SquaredExponential);
1311    test_gp!(Quadratic, AbsoluteExponential);
1312    test_gp!(Quadratic, Matern32);
1313    test_gp!(Quadratic, Matern52);
1314
1315    fn griewank(x: &Array2<f64>) -> Array1<f64> {
1316        let dim = x.ncols();
1317        let d = Array1::linspace(1., dim as f64, dim).mapv(|v| v.sqrt());
1318        let mut y = Array1::zeros((x.nrows(),));
1319        Zip::from(&mut y).and(x.rows()).for_each(|y, x| {
1320            let s = x.mapv(|v| v * v).sum() / 4000.;
1321            let p = (x.to_owned() / &d)
1322                .mapv(|v| v.cos())
1323                .fold(1., |acc, x| acc * x);
1324            *y = s - p + 1.;
1325        });
1326        y
1327    }
1328
1329    #[test]
1330    fn test_griewank() {
1331        let x = array![[1., 1., 1., 1., 1.], [2., 2., 2., 2., 2.]];
1332        assert_abs_diff_eq!(array![0.72890641, 1.01387135], griewank(&x), epsilon = 1e-8);
1333    }
1334
1335    #[test]
1336    fn test_kpls_griewank() {
1337        let dims = [5]; // , 10, 60];
1338        let nts = [100]; // , 300, 500];
1339        let lim = array![[-600., 600.]];
1340
1341        let test_dir = "target/tests";
1342        std::fs::create_dir_all(test_dir).ok();
1343
1344        (0..dims.len()).for_each(|i| {
1345            let dim = dims[i];
1346            let nt = nts[i];
1347            let xlimits = lim.broadcast((dim, 2)).unwrap();
1348
1349            let prefix = "griewank";
1350            let xfilename = format!("{test_dir}/{prefix}_xt_{nt}x{dim}.npy");
1351            let yfilename = format!("{test_dir}/{prefix}_yt_{nt}x1.npy");
1352
1353            let rng = Xoshiro256Plus::seed_from_u64(42);
1354            let xt = Lhs::new(&xlimits).with_rng(rng).sample(nt);
1355            write_npy(xfilename, &xt).expect("cannot save xt");
1356            let yt = griewank(&xt);
1357            write_npy(yfilename, &yt).expect("cannot save yt");
1358
1359            let gp = GaussianProcess::<f64, ConstantMean, SquaredExponentialCorr>::params(
1360                ConstantMean::default(),
1361                SquaredExponentialCorr::default(),
1362            )
1363            .kpls_dim(Some(3))
1364            .fit(&Dataset::new(xt, yt))
1365            .expect("GP fit error");
1366
1367            let rng = Xoshiro256Plus::seed_from_u64(0);
1368            let xtest = Lhs::new(&xlimits).with_rng(rng).sample(100);
1369            //let xtest = Array2::ones((1, dim));
1370            let ytest = gp.predict(&xtest).expect("prediction error");
1371            let ytrue = griewank(&xtest);
1372
1373            let nrmse = (ytrue.to_owned() - &ytest).norm_l2() / ytrue.norm_l2();
1374            println!(
1375                "diff={}  ytrue={} nrsme={}",
1376                (ytrue.to_owned() - &ytest).norm_l2(),
1377                ytrue.norm_l2(),
1378                nrmse
1379            );
1380            assert_abs_diff_eq!(nrmse, 0., epsilon = 1e-2);
1381        });
1382    }
1383
1384    fn tensor_product_exp(x: &ArrayBase<impl Data<Elem = f64>, Ix2>) -> Array1<f64> {
1385        x.mapv(|v| v.exp()).map_axis(Axis(1), |row| row.product())
1386    }
1387
1388    #[test]
1389    fn test_kpls_tp_exp() {
1390        let dim = 3;
1391        let nt = 300;
1392        let lim = array![[-1., 1.]];
1393        let xlimits = lim.broadcast((dim, 2)).unwrap();
1394        let rng = Xoshiro256Plus::seed_from_u64(42);
1395        let xt = Lhs::new(&xlimits).with_rng(rng).sample(nt);
1396        let yt = tensor_product_exp(&xt);
1397
1398        let gp = GaussianProcess::<f64, ConstantMean, SquaredExponentialCorr>::params(
1399            ConstantMean::default(),
1400            SquaredExponentialCorr::default(),
1401        )
1402        .kpls_dim(Some(1))
1403        .fit(&Dataset::new(xt, yt))
1404        .expect("GP training");
1405
1406        let xv = Lhs::new(&xlimits).sample(100);
1407        let yv = tensor_product_exp(&xv);
1408
1409        let ytest = gp.predict(&xv).unwrap();
1410        let err = ytest.l2_dist(&yv).unwrap() / yv.norm_l2();
1411        assert_abs_diff_eq!(err, 0., epsilon = 2e-2);
1412    }
1413
1414    fn rosenb(x: &ArrayBase<impl Data<Elem = f64>, Ix2>) -> Array1<f64> {
1415        let mut y: Array1<f64> = Array1::zeros((x.nrows(),));
1416        Zip::from(&mut y).and(x.rows()).par_for_each(|yi, xi| {
1417            *yi = rosenbrock(&xi.to_vec());
1418        });
1419        y
1420    }
1421
1422    #[test]
1423    fn test_kpls_rosenb() {
1424        let dim = 20;
1425        let nt = 30;
1426        let lim = array![[-1., 1.]];
1427        let xlimits = lim.broadcast((dim, 2)).unwrap();
1428        let rng = Xoshiro256Plus::seed_from_u64(42);
1429        let xt = Lhs::new(&xlimits).with_rng(rng).sample(nt);
1430        let yt = rosenb(&xt);
1431
1432        let gp = GaussianProcess::<f64, ConstantMean, Matern32Corr>::params(
1433            ConstantMean::default(),
1434            Matern52Corr::default(),
1435        )
1436        .kpls_dim(Some(1))
1437        .fit(&Dataset::new(xt.to_owned(), yt))
1438        .expect("GP training");
1439
1440        let rng2 = Xoshiro256Plus::seed_from_u64(41);
1441        let xv = Lhs::new(&xlimits).with_rng(rng2).sample(300);
1442        let yv = rosenb(&xv);
1443
1444        let ytest = gp.predict(&xv).expect("GP prediction");
1445        let err = ytest.l2_dist(&yv).unwrap() / yv.norm_l2();
1446        assert_abs_diff_eq!(err, 0., epsilon = 4e-1);
1447
1448        let var = GpVariancePredictor(&gp).predict(&xt);
1449        assert_abs_diff_eq!(var, Array1::zeros(nt), epsilon = 2e-1);
1450    }
1451
1452    fn sphere(x: &Array2<f64>) -> Array1<f64> {
1453        (x * x).sum_axis(Axis(1))
1454    }
1455
1456    fn dsphere(x: &Array2<f64>) -> Array2<f64> {
1457        x.mapv(|v| 2. * v)
1458    }
1459
1460    fn norm1(x: &Array2<f64>) -> Array1<f64> {
1461        x.mapv(|v| v.abs()).sum_axis(Axis(1)).to_owned()
1462    }
1463
1464    fn dnorm1(x: &Array2<f64>) -> Array2<f64> {
1465        x.mapv(|v| if v > 0. { 1. } else { -1. })
1466    }
1467
1468    macro_rules! test_gp_derivatives {
1469        ($regr:ident, $corr:ident, $func:ident, $limit:expr_2021, $nt:expr_2021) => {
1470            paste! {
1471
1472                #[test]
1473                fn [<test_gp_derivatives_ $regr:snake _ $corr:snake>]() {
1474                    let mut rng = Xoshiro256Plus::seed_from_u64(42);
1475                    let xt = egobox_doe::Lhs::new(&array![[-$limit, $limit], [-$limit, $limit]])
1476                    .kind(egobox_doe::LhsKind::CenteredMaximin)
1477                    .with_rng(rng.clone())
1478                    .sample($nt);
1479
1480                    let yt = [<$func>](&xt);
1481                    let gp = GaussianProcess::<f64, [<$regr Mean>], [<$corr Corr>] >::params(
1482                        [<$regr Mean>]::default(),
1483                        [<$corr Corr>]::default(),
1484                    )
1485                    .fit(&Dataset::new(xt, yt))
1486                    .expect("GP fitting");
1487
1488                    let x = Array::random_using((2,), Uniform::new(-$limit, $limit), &mut rng);
1489                    //let x = array![3., 5.];
1490                    let xa: f64 = x[0];
1491                    let xb: f64 = x[1];
1492                    let e = 1e-5;
1493
1494                    let x = array![
1495                        [xa, xb],
1496                        [xa + e, xb],
1497                        [xa - e, xb],
1498                        [xa, xb + e],
1499                        [xa, xb - e]
1500                    ];
1501
1502                    let y_pred = gp.predict(&x).unwrap();
1503                    println!("value at [{},{}] = {}", xa, xb, y_pred);
1504                    let y_deriv = gp.predict_gradients(&x);
1505                    println!("deriv at [{},{}] = {}", xa, xb, y_deriv);
1506                    let true_deriv = [<d $func>](&array![[xa, xb]]);
1507                    println!("true deriv at [{},{}] = {}", xa, xb, true_deriv);
1508                    println!("jacob = at [{},{}] = {}", xa, xb, gp.predict_jacobian(&array![xa, xb]));
1509
1510                    let diff_g = (y_pred[1] - y_pred[2]) / (2. * e);
1511                    let diff_d = (y_pred[3] - y_pred[4]) / (2. * e);
1512
1513                    // test only if fdiff is not largely wrong
1514                    if (diff_g-true_deriv[[0, 0]]).abs() < 10. {
1515                        assert_rel_or_abs_error(y_deriv[[0, 0]], diff_g);
1516                    }
1517                    if (diff_d-true_deriv[[0, 1]]).abs() < 10. {
1518                        assert_rel_or_abs_error(y_deriv[[0, 1]], diff_d);
1519                    }
1520                }
1521            }
1522        };
1523    }
1524
1525    test_gp_derivatives!(Constant, SquaredExponential, sphere, 10., 10);
1526    test_gp_derivatives!(Linear, SquaredExponential, sphere, 10., 10);
1527    test_gp_derivatives!(Quadratic, SquaredExponential, sphere, 10., 10);
1528    test_gp_derivatives!(Constant, AbsoluteExponential, sphere, 10., 10);
1529    test_gp_derivatives!(Linear, AbsoluteExponential, norm1, 10., 16);
1530    test_gp_derivatives!(Quadratic, AbsoluteExponential, norm1, 10., 16);
1531    test_gp_derivatives!(Constant, Matern32, norm1, 10., 16);
1532    test_gp_derivatives!(Linear, Matern32, norm1, 10., 16);
1533    test_gp_derivatives!(Quadratic, Matern32, sphere, 10., 16);
1534    test_gp_derivatives!(Constant, Matern52, norm1, 10., 16);
1535    test_gp_derivatives!(Linear, Matern52, norm1, 10., 16);
1536    test_gp_derivatives!(Quadratic, Matern52, sphere, 10., 10);
1537
1538    #[allow(unused_macros)]
1539    macro_rules! test_gp_variance_derivatives {
1540        ($regr:ident, $corr:ident, $func:ident, $limit:expr_2021, $nt:expr_2021) => {
1541            paste! {
1542
1543                #[test]
1544                fn [<test_gp_variance_derivatives_ $regr:snake _ $corr:snake _ $func:snake>]() {
1545                    let mut rng = Xoshiro256Plus::seed_from_u64(42);
1546                    let xt = egobox_doe::Lhs::new(&array![[-$limit, $limit], [-$limit, $limit]]).with_rng(rng.clone()).sample($nt);
1547                    let yt = [<$func>](&xt);
1548                    println!(stringify!(<$func>));
1549
1550                    let gp = GaussianProcess::<f64, [<$regr Mean>], [<$corr Corr>] >::params(
1551                        [<$regr Mean>]::default(),
1552                        [<$corr Corr>]::default(),
1553                    )
1554                    .fit(&Dataset::new(xt, yt))
1555                    .expect("GP fitting");
1556
1557                    for _ in 0..10 {
1558                        let x = Array::random_using((2,), Uniform::new(-$limit, $limit), &mut rng);
1559                        let xa: f64 = x[0];
1560                        let xb: f64 = x[1];
1561                        let e = 1e-5;
1562
1563                        let x = array![
1564                            [xa, xb],
1565                            [xa + e, xb],
1566                            [xa - e, xb],
1567                            [xa, xb + e],
1568                            [xa, xb - e]
1569                        ];
1570                        println!("****************************************");
1571                        let y_pred = gp.predict(&x).unwrap();
1572                        println!("value at [{},{}] = {}", xa, xb, y_pred);
1573                        let y_deriv = gp.predict_gradients(&x);
1574                        println!("deriv at [{},{}] = {}", xa, xb, y_deriv);
1575                        let y_pred = gp.predict_var(&x).unwrap();
1576                        println!("variance at [{},{}] = {}", xa, xb, y_pred);
1577                        let y_deriv = gp.predict_var_gradients(&x);
1578                        println!("variance deriv at [{},{}] = {}", xa, xb, y_deriv);
1579
1580                        let diff_g = (y_pred[1] - y_pred[2]) / (2. * e);
1581                        let diff_d = (y_pred[3] - y_pred[4]) / (2. * e);
1582
1583                        assert_rel_or_abs_error(y_deriv[[0, 0]], diff_g);
1584                        assert_rel_or_abs_error(y_deriv[[0, 1]], diff_d);
1585                    }
1586                }
1587            }
1588        };
1589    }
1590
1591    test_gp_variance_derivatives!(Constant, SquaredExponential, sphere, 10., 100);
1592    test_gp_variance_derivatives!(Linear, SquaredExponential, sphere, 10., 100);
1593    test_gp_variance_derivatives!(Quadratic, SquaredExponential, sphere, 10., 100);
1594    // FIXME: exclude as it fails on testing-features CI: blas, nlopt...
1595    #[cfg(not(feature = "nlopt"))]
1596    test_gp_variance_derivatives!(Constant, AbsoluteExponential, norm1, 10., 100);
1597    test_gp_variance_derivatives!(Linear, AbsoluteExponential, norm1, 1., 50);
1598    test_gp_variance_derivatives!(Quadratic, AbsoluteExponential, sphere, 10., 100);
1599    test_gp_variance_derivatives!(Constant, Matern32, sphere, 10., 100);
1600    test_gp_variance_derivatives!(Linear, Matern32, norm1, 1., 50);
1601    test_gp_variance_derivatives!(Quadratic, Matern32, sphere, 10., 100);
1602    test_gp_variance_derivatives!(Constant, Matern52, sphere, 10., 100);
1603    test_gp_variance_derivatives!(Linear, Matern52, norm1, 1., 50);
1604    test_gp_variance_derivatives!(Quadratic, Matern52, sphere, 10., 100);
1605
1606    #[test]
1607    fn test_variance_derivatives() {
1608        let xt = egobox_doe::FullFactorial::new(&array![[-10., 10.], [-10., 10.]]).sample(10);
1609        let yt = sphere(&xt);
1610
1611        let gp = GaussianProcess::<f64, ConstantMean, SquaredExponentialCorr>::params(
1612            ConstantMean::default(),
1613            SquaredExponentialCorr::default(),
1614        )
1615        .fit(&Dataset::new(xt, yt))
1616        .expect("GP fitting");
1617
1618        for _ in 0..20 {
1619            let mut rng = Xoshiro256Plus::seed_from_u64(42);
1620            let x = Array::random_using((2,), Uniform::new(-10., 10.), &mut rng);
1621            let xa: f64 = x[0];
1622            let xb: f64 = x[1];
1623            let e = 1e-5;
1624
1625            let x = array![
1626                [xa, xb],
1627                [xa + e, xb],
1628                [xa - e, xb],
1629                [xa, xb + e],
1630                [xa, xb - e]
1631            ];
1632            let y_pred = gp.predict_var(&x).unwrap();
1633            println!("variance at [{xa},{xb}] = {y_pred}");
1634            let y_deriv = gp.predict_var_gradients(&x);
1635            println!("variance deriv at [{xa},{xb}] = {y_deriv}");
1636
1637            let diff_g = (y_pred[1] - y_pred[2]) / (2. * e);
1638            let diff_d = (y_pred[3] - y_pred[4]) / (2. * e);
1639
1640            if y_pred[0].abs() > 1e-1 && y_pred[0].abs() > 1e-1 {
1641                // do not test with fdiff when variance or deriv is too small
1642                assert_rel_or_abs_error(y_deriv[[0, 0]], diff_g);
1643            }
1644            if y_pred[0].abs() > 1e-1 && y_pred[0].abs() > 1e-1 {
1645                // do not test with fdiff when variance or deriv  is too small
1646                assert_rel_or_abs_error(y_deriv[[0, 1]], diff_d);
1647            }
1648        }
1649    }
1650
1651    #[test]
1652    fn test_fixed_theta() {
1653        let xt = array![[0.0], [1.0], [2.0], [3.0], [4.0]];
1654        let yt = array![0.0, 1.0, 1.5, 0.9, 1.0];
1655        let gp = Kriging::params()
1656            .fit(&Dataset::new(xt.clone(), yt.clone()))
1657            .expect("GP fit error");
1658        let default = ThetaTuning::default();
1659        assert_abs_diff_ne!(*gp.theta(), default.init());
1660        let expected = gp.theta();
1661
1662        let gp = Kriging::params()
1663            .theta_tuning(ThetaTuning::Fixed(expected.clone()))
1664            .fit(&Dataset::new(xt, yt))
1665            .expect("GP fit error");
1666        assert_abs_diff_eq!(*gp.theta(), expected);
1667    }
1668
1669    fn x2sinx(x: &Array2<f64>) -> Array1<f64> {
1670        ((x * x) * (x).mapv(|v| v.sin())).remove_axis(Axis(1))
1671    }
1672
1673    #[test]
1674    fn test_sampling() {
1675        let xdoe = array![[-8.5], [-4.0], [-3.0], [-1.0], [4.0], [7.5]];
1676        let ydoe = x2sinx(&xdoe);
1677        let krg = Kriging::<f64>::params()
1678            .fit(&Dataset::new(xdoe, ydoe))
1679            .expect("Kriging training");
1680        let n_plot = 35;
1681        let n_traj = 10;
1682        let (x_min, x_max) = (-10., 10.);
1683        let x = Array::linspace(x_min, x_max, n_plot)
1684            .into_shape_with_order((n_plot, 1))
1685            .unwrap();
1686        let trajs = krg.sample(&x, n_traj);
1687        assert_eq!(&[n_plot, n_traj], trajs.shape())
1688    }
1689
1690    #[test]
1691    fn test_sampling_eigen() {
1692        let xdoe = array![[-8.5], [-4.0], [-3.0], [-1.0], [4.0], [7.5]];
1693        let ydoe = x2sinx(&xdoe);
1694        let krg = Kriging::<f64>::params()
1695            .fit(&Dataset::new(xdoe, ydoe))
1696            .expect("Kriging training");
1697        let n_plot = 500;
1698        let n_traj = 10;
1699        let (x_min, x_max) = (-10., 10.);
1700        let x = Array::linspace(x_min, x_max, n_plot)
1701            .into_shape_with_order((n_plot, 1))
1702            .unwrap();
1703        let trajs = krg.sample_eig(&x, n_traj);
1704        assert_eq!(&[n_plot, n_traj], trajs.shape());
1705        assert!(!trajs.fold(false, |acc, v| acc || v.is_nan())); // check no nans
1706    }
1707
1708    fn assert_rel_or_abs_error(y_deriv: f64, fdiff: f64) {
1709        println!("analytic deriv = {y_deriv}, fdiff = {fdiff}");
1710        if fdiff.abs() < 1. {
1711            let atol = 1.;
1712            println!("Check absolute error: abs({y_deriv}) should be < {atol}");
1713            assert_abs_diff_eq!(y_deriv, 0.0, epsilon = atol); // check absolute when close to zero
1714        } else {
1715            let rtol = 6e-1;
1716            let rel_error = (y_deriv - fdiff).abs() / fdiff.abs(); // check relative
1717            println!("Check relative error: {rel_error} should be < {rtol}");
1718            assert_abs_diff_eq!(rel_error, 0.0, epsilon = rtol);
1719        }
1720    }
1721
1722    fn sin_linear(x: &Array2<f64>) -> Array2<f64> {
1723        // sin + linear trend
1724        let x1 = x.column(0).to_owned().mapv(|v| v.sin());
1725        let x2 = x.column(0).mapv(|v| 2. * v) + x.column(1).mapv(|v| 5. * v);
1726        (x1 + x2)
1727            .mapv(|v| v + 10.)
1728            .into_shape_with_order((x.nrows(), 1))
1729            .unwrap()
1730    }
1731
1732    #[test]
1733    fn test_bug_var_derivatives() {
1734        let _xt = egobox_doe::Lhs::new(&array![[-5., 10.], [-5., 10.]])
1735            .kind(LhsKind::Centered)
1736            .sample(12);
1737        let _yt = sin_linear(&_xt);
1738
1739        let xt = array![
1740            [6.875, -4.375],
1741            [-3.125, 1.875],
1742            [1.875, -1.875],
1743            [-4.375, 3.125],
1744            [8.125, 9.375],
1745            [4.375, 4.375],
1746            [0.625, 0.625],
1747            [9.375, 6.875],
1748            [5.625, 8.125],
1749            [-0.625, -3.125],
1750            [3.125, 5.625],
1751            [-1.875, -0.625]
1752        ];
1753        let yt = array![
1754            2.43286801,
1755            13.10840811,
1756            5.32908578,
1757            17.81862219,
1758            74.08849877,
1759            39.68137781,
1760            14.96009727,
1761            63.17475741,
1762            61.26331775,
1763            -7.46009727,
1764            44.39159189,
1765            2.17091422,
1766        ];
1767
1768        let gp = GaussianProcess::<f64, ConstantMean, SquaredExponentialCorr>::params(
1769            ConstantMean::default(),
1770            SquaredExponentialCorr::default(),
1771        )
1772        .theta_tuning(ThetaTuning::Fixed(array![
1773            f64::sqrt(2. * 0.0437386),
1774            f64::sqrt(2. * 0.00697978)
1775        ]))
1776        .fit(&Dataset::new(xt, yt))
1777        .expect("GP fitting");
1778
1779        let e = 5e-6;
1780        let xa = -1.3;
1781        let xb = 2.5;
1782        let x = array![
1783            [xa, xb],
1784            [xa + e, xb],
1785            [xa - e, xb],
1786            [xa, xb + e],
1787            [xa, xb - e]
1788        ];
1789        let y_pred = gp.predict_var(&x).unwrap();
1790        let y_deriv = gp.predict_var_gradients(&array![[xa, xb]]);
1791        let diff_g = (y_pred[1] - y_pred[2]) / (2. * e);
1792        let diff_d = (y_pred[3] - y_pred[4]) / (2. * e);
1793
1794        assert_abs_diff_eq!(y_deriv[[0, 0]], diff_g, epsilon = 1e-5);
1795        assert_abs_diff_eq!(y_deriv[[0, 1]], diff_d, epsilon = 1e-5);
1796    }
1797}