Skip to main content

quantrs2_anneal/bayesian_hyperopt/
gaussian_process.rs

1//! Gaussian Process Surrogate Models
2//!
3//! This module provides a real Gaussian-process regression surrogate used by the
4//! Bayesian hyperparameter optimizer. Predictions follow the standard exact-GP
5//! equations (Rasmussen & Williams, *Gaussian Processes for Machine Learning*,
6//! Algorithm 2.1): the training kernel matrix `K + σ²I` is Cholesky-factorized
7//! once per fit, and the predictive mean and variance are obtained from triangular
8//! solves against the resulting lower-triangular factor.
9
10use super::config::{BayesianOptError, BayesianOptResult};
11use std::f64::consts::PI;
12
13/// Gaussian process configuration (alias for backward compatibility)
14pub type GaussianProcessConfig = GaussianProcessSurrogate;
15
16/// Gaussian process configuration holder.
17///
18/// This type stores only the *configuration* of a Gaussian process (kernel, noise
19/// level, and prior mean function). It intentionally carries no training data and
20/// therefore cannot produce predictions on its own — construct a
21/// [`GaussianProcessModel`] from observed data for a fitted, predictive process.
22#[derive(Debug, Clone)]
23pub struct GaussianProcessSurrogate {
24    pub kernel: KernelFunction,
25    pub noise_variance: f64,
26    pub mean_function: MeanFunction,
27}
28
29impl Default for GaussianProcessSurrogate {
30    fn default() -> Self {
31        Self {
32            kernel: KernelFunction::RBF,
33            noise_variance: 1e-6,
34            mean_function: MeanFunction::Zero,
35        }
36    }
37}
38
39impl GaussianProcessSurrogate {
40    /// Predictions are not available on a bare configuration holder.
41    ///
42    /// `GaussianProcessSurrogate` describes *how* a GP should behave but holds no
43    /// observations, so it has nothing to condition on. Build a
44    /// [`GaussianProcessModel`] from training data (`GaussianProcessModel::new`)
45    /// to obtain real posterior mean/variance predictions. This method returns an
46    /// honest error rather than a fabricated `(0.0, 1.0)` so that a misconfigured
47    /// call site fails loudly instead of silently degrading to a constant.
48    pub fn predict(&self, _x: &[f64]) -> BayesianOptResult<(f64, f64)> {
49        Err(BayesianOptError::GaussianProcessError(
50            "GaussianProcessSurrogate stores configuration only and cannot predict; \
51             construct a GaussianProcessModel from training data instead"
52                .to_string(),
53        ))
54    }
55}
56
57/// Kernel functions for Gaussian processes
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum KernelFunction {
60    /// Radial Basis Function (RBF) kernel
61    RBF,
62    /// Matern kernel
63    Matern,
64    /// Linear kernel
65    Linear,
66    /// Polynomial kernel
67    Polynomial,
68    /// Spectral mixture kernel
69    SpectralMixture,
70}
71
72/// Mean functions for Gaussian processes
73#[derive(Debug, Clone, PartialEq)]
74pub enum MeanFunction {
75    /// Zero mean function
76    Zero,
77    /// Constant mean function
78    Constant(f64),
79    /// Linear mean function
80    Linear,
81    /// Polynomial mean function
82    Polynomial { degree: usize },
83}
84
85/// Gaussian process hyperparameters
86#[derive(Debug, Clone)]
87pub struct GPHyperparameters {
88    pub length_scales: Vec<f64>,
89    pub signal_variance: f64,
90    pub noise_variance: f64,
91    pub mean_parameters: Vec<f64>,
92}
93
94impl Default for GPHyperparameters {
95    fn default() -> Self {
96        Self {
97            length_scales: vec![1.0],
98            signal_variance: 1.0,
99            noise_variance: 1e-6,
100            mean_parameters: vec![0.0],
101        }
102    }
103}
104
105/// Gaussian Process regression model.
106///
107/// Conditioned on training data, this model provides posterior mean and variance
108/// predictions via an exact Cholesky-based solve of `K + σ²I`.
109#[derive(Debug, Clone)]
110pub struct GaussianProcessModel {
111    /// Training input data
112    pub x_train: Vec<Vec<f64>>,
113    /// Training output data
114    pub y_train: Vec<f64>,
115    /// GP configuration
116    pub config: GaussianProcessConfig,
117    /// Learned hyperparameters
118    pub hyperparameters: GPHyperparameters,
119    /// Lower-triangular Cholesky factor `L` of `K + σ²I` (row-major), where
120    /// `L * Lᵀ = K + σ²I`. Populated by [`GaussianProcessModel::fit`].
121    l_factor: Option<Vec<Vec<f64>>>,
122    /// Precomputed `α = (K + σ²I)⁻¹ (y − m)`, used for the predictive mean.
123    alpha: Option<Vec<f64>>,
124}
125
126impl GaussianProcessModel {
127    /// Create new Gaussian Process model
128    pub fn new(
129        x_train: Vec<Vec<f64>>,
130        y_train: Vec<f64>,
131        config: GaussianProcessConfig,
132    ) -> BayesianOptResult<Self> {
133        if x_train.len() != y_train.len() {
134            return Err(BayesianOptError::GaussianProcessError(
135                "Training inputs and outputs must have same length".to_string(),
136            ));
137        }
138
139        if x_train.is_empty() {
140            return Err(BayesianOptError::GaussianProcessError(
141                "Training data cannot be empty".to_string(),
142            ));
143        }
144
145        let input_dim = x_train[0].len();
146        let hyperparameters = GPHyperparameters {
147            length_scales: vec![1.0; input_dim.max(1)],
148            signal_variance: 1.0,
149            noise_variance: config.noise_variance,
150            mean_parameters: vec![0.0],
151        };
152
153        let mut model = Self {
154            x_train,
155            y_train,
156            config,
157            hyperparameters,
158            l_factor: None,
159            alpha: None,
160        };
161
162        // Fit the model (heuristic hyperparameters + Cholesky factorization).
163        model.fit()?;
164
165        Ok(model)
166    }
167
168    /// Fit the Gaussian Process model.
169    ///
170    /// Sets heuristic hyperparameters, then Cholesky-factorizes `K + σ²I` and
171    /// precomputes `α` for fast predictive-mean evaluation.
172    pub fn fit(&mut self) -> BayesianOptResult<()> {
173        self.optimize_hyperparameters()?;
174        self.factorize()?;
175        Ok(())
176    }
177
178    /// Heuristic hyperparameter selection.
179    ///
180    /// Length scales are set to half the per-dimension data range and the signal
181    /// variance to the empirical output variance. (A full type-II maximum-likelihood
182    /// optimization could refine these, but these data-driven heuristics keep the
183    /// kernel well-conditioned for the small designs seen during Bayesian
184    /// optimization.)
185    fn optimize_hyperparameters(&mut self) -> BayesianOptResult<()> {
186        let n = self.x_train.len();
187        if n == 0 {
188            return Ok(());
189        }
190
191        let input_dim = self.x_train[0].len();
192
193        // Set length scales based on the spread of the observed inputs.
194        for dim in 0..input_dim {
195            let values: Vec<f64> = self.x_train.iter().map(|x| x[dim]).collect();
196            let min_val = values.iter().fold(f64::INFINITY, |a, &b| a.min(b));
197            let max_val = values.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
198            let range = (max_val - min_val).max(1e-6);
199
200            self.hyperparameters.length_scales[dim] = range / 2.0;
201        }
202
203        // Set signal variance based on the empirical output variance.
204        let mean_y = self.y_train.iter().sum::<f64>() / n as f64;
205        let var_y = self
206            .y_train
207            .iter()
208            .map(|&y| (y - mean_y).powi(2))
209            .sum::<f64>()
210            / n as f64;
211
212        self.hyperparameters.signal_variance = var_y.max(1e-6);
213
214        Ok(())
215    }
216
217    /// Cholesky-factorize `K + σ²I` and precompute `α`.
218    ///
219    /// If the (nominally positive-definite) matrix is numerically indefinite —
220    /// e.g. because two training inputs nearly coincide — an increasing jitter is
221    /// added to the diagonal until the factorization succeeds, a standard
222    /// regularization used by production GP libraries.
223    fn factorize(&mut self) -> BayesianOptResult<()> {
224        let n = self.x_train.len();
225
226        // Build the (noise-free) kernel Gram matrix.
227        let mut k_matrix = vec![vec![0.0; n]; n];
228        for i in 0..n {
229            for j in i..n {
230                let value = self.kernel(&self.x_train[i], &self.x_train[j]);
231                k_matrix[i][j] = value;
232                k_matrix[j][i] = value;
233            }
234        }
235
236        let signal_scale = self.hyperparameters.signal_variance.max(1e-12);
237        let mut jitter = self.hyperparameters.noise_variance.max(0.0);
238
239        let mut factor = None;
240        for _attempt in 0..8 {
241            let mut regularized = k_matrix.clone();
242            for d in 0..n {
243                regularized[d][d] += jitter;
244            }
245            if let Some(l) = cholesky_lower(&regularized) {
246                factor = Some(l);
247                break;
248            }
249            // Grow the jitter geometrically (seeded relative to the signal scale
250            // when the noise term is zero) and retry.
251            jitter = if jitter <= 0.0 {
252                1e-10 * signal_scale
253            } else {
254                jitter * 10.0
255            };
256        }
257
258        let l = factor.ok_or_else(|| {
259            BayesianOptError::GaussianProcessError(
260                "Kernel matrix is not positive definite even after jitter regularization"
261                    .to_string(),
262            )
263        })?;
264
265        // Center targets by the prior mean, then solve (K + σ²I) α = (y − m) via
266        // two triangular solves: L z = (y − m), then Lᵀ α = z.
267        let prior_mean = self.prior_mean_vector();
268        let centered: Vec<f64> = self
269            .y_train
270            .iter()
271            .zip(prior_mean.iter())
272            .map(|(&y, &m)| y - m)
273            .collect();
274
275        let z = forward_substitution(&l, &centered);
276        let alpha = back_substitution_transpose(&l, &z);
277
278        self.l_factor = Some(l);
279        self.alpha = Some(alpha);
280
281        Ok(())
282    }
283
284    /// Prior-mean values evaluated at every training input.
285    fn prior_mean_vector(&self) -> Vec<f64> {
286        self.x_train
287            .iter()
288            .map(|x| self.mean_function_value(x))
289            .collect()
290    }
291
292    /// Compute kernel function between two points
293    fn kernel(&self, x1: &[f64], x2: &[f64]) -> f64 {
294        match self.config.kernel {
295            KernelFunction::RBF => self.rbf_kernel(x1, x2),
296            KernelFunction::Matern => self.matern_kernel(x1, x2),
297            KernelFunction::Linear => self.linear_kernel(x1, x2),
298            KernelFunction::Polynomial => self.polynomial_kernel(x1, x2),
299            KernelFunction::SpectralMixture => self.rbf_kernel(x1, x2), // Fallback to RBF
300        }
301    }
302
303    /// RBF (Gaussian) kernel with per-dimension length scales.
304    fn rbf_kernel(&self, x1: &[f64], x2: &[f64]) -> f64 {
305        let mut distance_sq = 0.0;
306        for (i, (&xi, &xj)) in x1.iter().zip(x2.iter()).enumerate() {
307            let length_scale = self.hyperparameters.length_scales.get(i).unwrap_or(&1.0);
308            distance_sq += ((xi - xj) / length_scale).powi(2);
309        }
310
311        self.hyperparameters.signal_variance * (-0.5 * distance_sq).exp()
312    }
313
314    /// Matern kernel (Matern 3/2).
315    fn matern_kernel(&self, x1: &[f64], x2: &[f64]) -> f64 {
316        let mut distance = 0.0;
317        for (i, (&xi, &xj)) in x1.iter().zip(x2.iter()).enumerate() {
318            let length_scale = self.hyperparameters.length_scales.get(i).unwrap_or(&1.0);
319            distance += ((xi - xj) / length_scale).powi(2);
320        }
321        distance = distance.sqrt();
322
323        let sqrt3_r = 3.0_f64.sqrt() * distance;
324        self.hyperparameters.signal_variance * (1.0 + sqrt3_r) * (-sqrt3_r).exp()
325    }
326
327    /// Linear kernel
328    fn linear_kernel(&self, x1: &[f64], x2: &[f64]) -> f64 {
329        let dot_product: f64 = x1.iter().zip(x2.iter()).map(|(&xi, &xj)| xi * xj).sum();
330        self.hyperparameters.signal_variance * dot_product
331    }
332
333    /// Polynomial kernel
334    fn polynomial_kernel(&self, x1: &[f64], x2: &[f64]) -> f64 {
335        let dot_product: f64 = x1.iter().zip(x2.iter()).map(|(&xi, &xj)| xi * xj).sum();
336        self.hyperparameters.signal_variance * (1.0 + dot_product).powi(2)
337    }
338
339    /// Predict the posterior mean and variance at a new point.
340    ///
341    /// Implements the exact-GP predictive equations:
342    /// `μ(x*) = m(x*) + k*ᵀ α` and `σ²(x*) = k(x*, x*) − vᵀ v` with `v = L⁻¹ k*`.
343    pub fn predict(&self, x: &[f64]) -> BayesianOptResult<(f64, f64)> {
344        let l = self.l_factor.as_ref().ok_or_else(|| {
345            BayesianOptError::GaussianProcessError("Model not fitted".to_string())
346        })?;
347        let alpha = self.alpha.as_ref().ok_or_else(|| {
348            BayesianOptError::GaussianProcessError("Model not fitted".to_string())
349        })?;
350
351        // Cross-covariance k* between x and every training input.
352        let k_star: Vec<f64> = self
353            .x_train
354            .iter()
355            .map(|x_train| self.kernel(x, x_train))
356            .collect();
357
358        // Predictive mean: prior mean plus k*ᵀ α.
359        let mut mean = self.mean_function_value(x);
360        for (ks, a) in k_star.iter().zip(alpha.iter()) {
361            mean += ks * a;
362        }
363
364        // Predictive variance: k(x, x) − ||L⁻¹ k*||².
365        let v = forward_substitution(l, &k_star);
366        let mut variance = self.kernel(x, x);
367        for vi in &v {
368            variance -= vi * vi;
369        }
370
371        // Numerical guard: posterior variance must stay non-negative.
372        variance = variance.max(1e-12);
373
374        Ok((mean, variance))
375    }
376
377    /// Evaluate mean function
378    fn mean_function_value(&self, x: &[f64]) -> f64 {
379        match self.config.mean_function {
380            MeanFunction::Zero => 0.0,
381            MeanFunction::Constant(c) => c,
382            MeanFunction::Linear => {
383                // Simple linear mean: sum of coordinates
384                x.iter().sum::<f64>() * self.hyperparameters.mean_parameters.first().unwrap_or(&0.0)
385            }
386            MeanFunction::Polynomial { degree: _ } => {
387                // Simplified polynomial mean
388                let x_sum = x.iter().sum::<f64>();
389                x_sum * self.hyperparameters.mean_parameters.first().unwrap_or(&0.0)
390            }
391        }
392    }
393
394    /// Exact log marginal likelihood of the training data under the fitted GP.
395    ///
396    /// `log p(y) = −½ (y − m)ᵀ α − Σ ln L_ii − ½ n ln(2π)`, where the middle term
397    /// equals `½ ln|K + σ²I|` because `L` is the Cholesky factor.
398    pub fn log_marginal_likelihood(&self) -> BayesianOptResult<f64> {
399        let l = self.l_factor.as_ref().ok_or_else(|| {
400            BayesianOptError::GaussianProcessError("Model not fitted".to_string())
401        })?;
402        let alpha = self.alpha.as_ref().ok_or_else(|| {
403            BayesianOptError::GaussianProcessError("Model not fitted".to_string())
404        })?;
405
406        let n = self.y_train.len();
407        let prior_mean = self.prior_mean_vector();
408
409        // Data-fit term: (y − m)ᵀ α.
410        let mut data_fit = 0.0;
411        for i in 0..n {
412            data_fit += (self.y_train[i] - prior_mean[i]) * alpha[i];
413        }
414
415        // Complexity term: ½ ln|K| = Σ ln L_ii.
416        let mut half_log_det = 0.0;
417        for i in 0..n {
418            half_log_det += l[i][i].ln();
419        }
420
421        let log_likelihood = (-0.5 * data_fit) - half_log_det - (0.5 * n as f64) * (2.0 * PI).ln();
422
423        Ok(log_likelihood)
424    }
425}
426
427/// Compute the lower-triangular Cholesky factor `L` of a symmetric matrix `a`,
428/// such that `L * Lᵀ = a`.
429///
430/// Returns `None` if `a` is not positive definite (a non-positive pivot appears),
431/// which the caller uses to trigger jitter regularization.
432fn cholesky_lower(a: &[Vec<f64>]) -> Option<Vec<Vec<f64>>> {
433    let n = a.len();
434    let mut l = vec![vec![0.0; n]; n];
435
436    for i in 0..n {
437        for j in 0..=i {
438            let mut sum = a[i][j];
439            for k in 0..j {
440                sum -= l[i][k] * l[j][k];
441            }
442
443            if i == j {
444                if sum <= 0.0 || !sum.is_finite() {
445                    return None;
446                }
447                l[i][i] = sum.sqrt();
448            } else {
449                let pivot = l[j][j];
450                if pivot.abs() < 1e-300 {
451                    return None;
452                }
453                l[i][j] = sum / pivot;
454            }
455        }
456    }
457
458    Some(l)
459}
460
461/// Solve the lower-triangular system `L y = b` for `y` by forward substitution.
462fn forward_substitution(l: &[Vec<f64>], b: &[f64]) -> Vec<f64> {
463    let n = b.len();
464    let mut y = vec![0.0; n];
465
466    for i in 0..n {
467        let mut sum = b[i];
468        for k in 0..i {
469            sum -= l[i][k] * y[k];
470        }
471        y[i] = sum / l[i][i];
472    }
473
474    y
475}
476
477/// Solve the upper-triangular system `Lᵀ x = z` for `x` by back substitution,
478/// using the lower-triangular factor `L` transposed implicitly.
479fn back_substitution_transpose(l: &[Vec<f64>], z: &[f64]) -> Vec<f64> {
480    let n = z.len();
481    let mut x = vec![0.0; n];
482
483    for i in (0..n).rev() {
484        let mut sum = z[i];
485        for k in (i + 1)..n {
486            sum -= l[k][i] * x[k];
487        }
488        x[i] = sum / l[i][i];
489    }
490
491    x
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    /// Regression test: fit an exact GP to a sampled 1-D quadratic and verify the
499    /// posterior interpolates the training data with near-zero variance there,
500    /// while variance grows far from the data.
501    #[test]
502    fn test_gp_cholesky_quadratic_regression() {
503        // f(x) = (x - 2)^2 sampled on an integer grid.
504        let grid = [0.0f64, 1.0, 2.0, 3.0, 4.0, 5.0];
505        let x_train: Vec<Vec<f64>> = grid.iter().map(|&x| vec![x]).collect();
506        let y_train: Vec<f64> = grid.iter().map(|&x| (x - 2.0).powi(2)).collect();
507
508        let config = GaussianProcessSurrogate {
509            kernel: KernelFunction::RBF,
510            noise_variance: 1e-8,
511            mean_function: MeanFunction::Zero,
512        };
513
514        let model = GaussianProcessModel::new(x_train, y_train.clone(), config)
515            .expect("GP should fit on well-separated quadratic samples");
516
517        // At each training input the posterior mean matches the observation and the
518        // posterior variance is tiny.
519        let mut train_var_max = 0.0f64;
520        for (&x, &y) in grid.iter().zip(y_train.iter()) {
521            let (mean, variance) = model.predict(&[x]).expect("prediction should succeed");
522            assert!(
523                (mean - y).abs() < 1e-3,
524                "at x={x}, predicted mean {mean} should match target {y}"
525            );
526            assert!(
527                variance < 1e-2,
528                "posterior variance {variance} at training point x={x} should be near zero"
529            );
530            train_var_max = train_var_max.max(variance);
531        }
532
533        // Interpolation between samples: the mean should track the underlying
534        // quadratic and the variance stays finite and positive.
535        let (mean_mid, var_mid) = model.predict(&[2.5]).expect("interpolation should succeed");
536        let true_mid = (2.5f64 - 2.0).powi(2);
537        assert!(
538            (mean_mid - true_mid).abs() < 0.75,
539            "interpolated mean {mean_mid} should be near the true value {true_mid}"
540        );
541        assert!(var_mid > 0.0, "interpolation variance should be positive");
542
543        // Extrapolation far from the data has much larger posterior variance than
544        // at a training point.
545        let (_mean_far, var_far) = model
546            .predict(&[12.0])
547            .expect("extrapolation should succeed");
548        assert!(
549            var_far > 10.0 * train_var_max,
550            "extrapolation variance {var_far} should exceed training-point variance {train_var_max}"
551        );
552    }
553
554    /// The Cholesky factor must satisfy L Lᵀ = A for a known SPD matrix.
555    #[test]
556    fn test_cholesky_lower_reconstructs_matrix() {
557        let a = vec![
558            vec![4.0, 2.0, 2.0],
559            vec![2.0, 5.0, 3.0],
560            vec![2.0, 3.0, 6.0],
561        ];
562        let l = cholesky_lower(&a).expect("SPD matrix should factorize");
563
564        for i in 0..3 {
565            for j in 0..3 {
566                let mut reconstructed = 0.0;
567                for k in 0..3 {
568                    reconstructed += l[i][k] * l[j][k];
569                }
570                assert!(
571                    (reconstructed - a[i][j]).abs() < 1e-9,
572                    "L Lᵀ mismatch at ({i},{j})"
573                );
574            }
575        }
576    }
577
578    /// A non-positive-definite matrix must be rejected (so callers can add jitter).
579    #[test]
580    fn test_cholesky_rejects_non_pd() {
581        // Negative eigenvalue -> not positive definite.
582        let a = vec![vec![1.0, 2.0], vec![2.0, 1.0]];
583        assert!(cholesky_lower(&a).is_none());
584    }
585
586    /// Triangular solves must invert the factorization: L Lᵀ x = b.
587    #[test]
588    fn test_triangular_solves_roundtrip() {
589        let a = vec![
590            vec![4.0, 2.0, 2.0],
591            vec![2.0, 5.0, 3.0],
592            vec![2.0, 3.0, 6.0],
593        ];
594        let l = cholesky_lower(&a).expect("SPD matrix should factorize");
595        let b = vec![1.0, -2.0, 3.0];
596
597        // Solve A x = b via L z = b, Lᵀ x = z.
598        let z = forward_substitution(&l, &b);
599        let x = back_substitution_transpose(&l, &z);
600
601        // Verify A x = b.
602        for i in 0..3 {
603            let mut ax = 0.0;
604            for j in 0..3 {
605                ax += a[i][j] * x[j];
606            }
607            assert!((ax - b[i]).abs() < 1e-9, "A x != b at row {i}");
608        }
609    }
610
611    /// The bare configuration holder must fail loudly rather than fabricate a value.
612    #[test]
613    fn test_surrogate_predict_is_honest_error() {
614        let surrogate = GaussianProcessSurrogate::default();
615        assert!(surrogate.predict(&[0.0]).is_err());
616    }
617
618    /// Log marginal likelihood is finite for a well-conditioned fit.
619    #[test]
620    fn test_log_marginal_likelihood_finite() {
621        let x_train = vec![vec![0.0], vec![1.0], vec![2.0], vec![3.0]];
622        let y_train = vec![0.0, 1.0, 4.0, 9.0];
623        let config = GaussianProcessSurrogate {
624            kernel: KernelFunction::RBF,
625            noise_variance: 1e-6,
626            mean_function: MeanFunction::Zero,
627        };
628        let model = GaussianProcessModel::new(x_train, y_train, config).expect("fit");
629        let lml = model
630            .log_marginal_likelihood()
631            .expect("log marginal likelihood should be computable");
632        assert!(lml.is_finite());
633    }
634}