quantrs2_anneal/bayesian_hyperopt/
gaussian_process.rs1use super::config::{BayesianOptError, BayesianOptResult};
11use std::f64::consts::PI;
12
13pub type GaussianProcessConfig = GaussianProcessSurrogate;
15
16#[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 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#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum KernelFunction {
60 RBF,
62 Matern,
64 Linear,
66 Polynomial,
68 SpectralMixture,
70}
71
72#[derive(Debug, Clone, PartialEq)]
74pub enum MeanFunction {
75 Zero,
77 Constant(f64),
79 Linear,
81 Polynomial { degree: usize },
83}
84
85#[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#[derive(Debug, Clone)]
110pub struct GaussianProcessModel {
111 pub x_train: Vec<Vec<f64>>,
113 pub y_train: Vec<f64>,
115 pub config: GaussianProcessConfig,
117 pub hyperparameters: GPHyperparameters,
119 l_factor: Option<Vec<Vec<f64>>>,
122 alpha: Option<Vec<f64>>,
124}
125
126impl GaussianProcessModel {
127 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 model.fit()?;
164
165 Ok(model)
166 }
167
168 pub fn fit(&mut self) -> BayesianOptResult<()> {
173 self.optimize_hyperparameters()?;
174 self.factorize()?;
175 Ok(())
176 }
177
178 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 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 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 fn factorize(&mut self) -> BayesianOptResult<()> {
224 let n = self.x_train.len();
225
226 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(®ularized) {
246 factor = Some(l);
247 break;
248 }
249 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 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, ¢ered);
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 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 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), }
301 }
302
303 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 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 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 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 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 let k_star: Vec<f64> = self
353 .x_train
354 .iter()
355 .map(|x_train| self.kernel(x, x_train))
356 .collect();
357
358 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 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 variance = variance.max(1e-12);
373
374 Ok((mean, variance))
375 }
376
377 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 x.iter().sum::<f64>() * self.hyperparameters.mean_parameters.first().unwrap_or(&0.0)
385 }
386 MeanFunction::Polynomial { degree: _ } => {
387 let x_sum = x.iter().sum::<f64>();
389 x_sum * self.hyperparameters.mean_parameters.first().unwrap_or(&0.0)
390 }
391 }
392 }
393
394 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 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 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
427fn 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
461fn 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
477fn 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 #[test]
502 fn test_gp_cholesky_quadratic_regression() {
503 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 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 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 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 #[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 #[test]
580 fn test_cholesky_rejects_non_pd() {
581 let a = vec![vec![1.0, 2.0], vec![2.0, 1.0]];
583 assert!(cholesky_lower(&a).is_none());
584 }
585
586 #[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 let z = forward_substitution(&l, &b);
599 let x = back_substitution_transpose(&l, &z);
600
601 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 #[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 #[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}