use super::config::{BayesianOptError, BayesianOptResult};
use std::f64::consts::PI;
pub type GaussianProcessConfig = GaussianProcessSurrogate;
#[derive(Debug, Clone)]
pub struct GaussianProcessSurrogate {
pub kernel: KernelFunction,
pub noise_variance: f64,
pub mean_function: MeanFunction,
}
impl Default for GaussianProcessSurrogate {
fn default() -> Self {
Self {
kernel: KernelFunction::RBF,
noise_variance: 1e-6,
mean_function: MeanFunction::Zero,
}
}
}
impl GaussianProcessSurrogate {
pub fn predict(&self, _x: &[f64]) -> BayesianOptResult<(f64, f64)> {
Err(BayesianOptError::GaussianProcessError(
"GaussianProcessSurrogate stores configuration only and cannot predict; \
construct a GaussianProcessModel from training data instead"
.to_string(),
))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KernelFunction {
RBF,
Matern,
Linear,
Polynomial,
SpectralMixture,
}
#[derive(Debug, Clone, PartialEq)]
pub enum MeanFunction {
Zero,
Constant(f64),
Linear,
Polynomial { degree: usize },
}
#[derive(Debug, Clone)]
pub struct GPHyperparameters {
pub length_scales: Vec<f64>,
pub signal_variance: f64,
pub noise_variance: f64,
pub mean_parameters: Vec<f64>,
}
impl Default for GPHyperparameters {
fn default() -> Self {
Self {
length_scales: vec![1.0],
signal_variance: 1.0,
noise_variance: 1e-6,
mean_parameters: vec![0.0],
}
}
}
#[derive(Debug, Clone)]
pub struct GaussianProcessModel {
pub x_train: Vec<Vec<f64>>,
pub y_train: Vec<f64>,
pub config: GaussianProcessConfig,
pub hyperparameters: GPHyperparameters,
l_factor: Option<Vec<Vec<f64>>>,
alpha: Option<Vec<f64>>,
}
impl GaussianProcessModel {
pub fn new(
x_train: Vec<Vec<f64>>,
y_train: Vec<f64>,
config: GaussianProcessConfig,
) -> BayesianOptResult<Self> {
if x_train.len() != y_train.len() {
return Err(BayesianOptError::GaussianProcessError(
"Training inputs and outputs must have same length".to_string(),
));
}
if x_train.is_empty() {
return Err(BayesianOptError::GaussianProcessError(
"Training data cannot be empty".to_string(),
));
}
let input_dim = x_train[0].len();
let hyperparameters = GPHyperparameters {
length_scales: vec![1.0; input_dim.max(1)],
signal_variance: 1.0,
noise_variance: config.noise_variance,
mean_parameters: vec![0.0],
};
let mut model = Self {
x_train,
y_train,
config,
hyperparameters,
l_factor: None,
alpha: None,
};
model.fit()?;
Ok(model)
}
pub fn fit(&mut self) -> BayesianOptResult<()> {
self.optimize_hyperparameters()?;
self.factorize()?;
Ok(())
}
fn optimize_hyperparameters(&mut self) -> BayesianOptResult<()> {
let n = self.x_train.len();
if n == 0 {
return Ok(());
}
let input_dim = self.x_train[0].len();
for dim in 0..input_dim {
let values: Vec<f64> = self.x_train.iter().map(|x| x[dim]).collect();
let min_val = values.iter().fold(f64::INFINITY, |a, &b| a.min(b));
let max_val = values.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
let range = (max_val - min_val).max(1e-6);
self.hyperparameters.length_scales[dim] = range / 2.0;
}
let mean_y = self.y_train.iter().sum::<f64>() / n as f64;
let var_y = self
.y_train
.iter()
.map(|&y| (y - mean_y).powi(2))
.sum::<f64>()
/ n as f64;
self.hyperparameters.signal_variance = var_y.max(1e-6);
Ok(())
}
fn factorize(&mut self) -> BayesianOptResult<()> {
let n = self.x_train.len();
let mut k_matrix = vec![vec![0.0; n]; n];
for i in 0..n {
for j in i..n {
let value = self.kernel(&self.x_train[i], &self.x_train[j]);
k_matrix[i][j] = value;
k_matrix[j][i] = value;
}
}
let signal_scale = self.hyperparameters.signal_variance.max(1e-12);
let mut jitter = self.hyperparameters.noise_variance.max(0.0);
let mut factor = None;
for _attempt in 0..8 {
let mut regularized = k_matrix.clone();
for d in 0..n {
regularized[d][d] += jitter;
}
if let Some(l) = cholesky_lower(®ularized) {
factor = Some(l);
break;
}
jitter = if jitter <= 0.0 {
1e-10 * signal_scale
} else {
jitter * 10.0
};
}
let l = factor.ok_or_else(|| {
BayesianOptError::GaussianProcessError(
"Kernel matrix is not positive definite even after jitter regularization"
.to_string(),
)
})?;
let prior_mean = self.prior_mean_vector();
let centered: Vec<f64> = self
.y_train
.iter()
.zip(prior_mean.iter())
.map(|(&y, &m)| y - m)
.collect();
let z = forward_substitution(&l, ¢ered);
let alpha = back_substitution_transpose(&l, &z);
self.l_factor = Some(l);
self.alpha = Some(alpha);
Ok(())
}
fn prior_mean_vector(&self) -> Vec<f64> {
self.x_train
.iter()
.map(|x| self.mean_function_value(x))
.collect()
}
fn kernel(&self, x1: &[f64], x2: &[f64]) -> f64 {
match self.config.kernel {
KernelFunction::RBF => self.rbf_kernel(x1, x2),
KernelFunction::Matern => self.matern_kernel(x1, x2),
KernelFunction::Linear => self.linear_kernel(x1, x2),
KernelFunction::Polynomial => self.polynomial_kernel(x1, x2),
KernelFunction::SpectralMixture => self.rbf_kernel(x1, x2), }
}
fn rbf_kernel(&self, x1: &[f64], x2: &[f64]) -> f64 {
let mut distance_sq = 0.0;
for (i, (&xi, &xj)) in x1.iter().zip(x2.iter()).enumerate() {
let length_scale = self.hyperparameters.length_scales.get(i).unwrap_or(&1.0);
distance_sq += ((xi - xj) / length_scale).powi(2);
}
self.hyperparameters.signal_variance * (-0.5 * distance_sq).exp()
}
fn matern_kernel(&self, x1: &[f64], x2: &[f64]) -> f64 {
let mut distance = 0.0;
for (i, (&xi, &xj)) in x1.iter().zip(x2.iter()).enumerate() {
let length_scale = self.hyperparameters.length_scales.get(i).unwrap_or(&1.0);
distance += ((xi - xj) / length_scale).powi(2);
}
distance = distance.sqrt();
let sqrt3_r = 3.0_f64.sqrt() * distance;
self.hyperparameters.signal_variance * (1.0 + sqrt3_r) * (-sqrt3_r).exp()
}
fn linear_kernel(&self, x1: &[f64], x2: &[f64]) -> f64 {
let dot_product: f64 = x1.iter().zip(x2.iter()).map(|(&xi, &xj)| xi * xj).sum();
self.hyperparameters.signal_variance * dot_product
}
fn polynomial_kernel(&self, x1: &[f64], x2: &[f64]) -> f64 {
let dot_product: f64 = x1.iter().zip(x2.iter()).map(|(&xi, &xj)| xi * xj).sum();
self.hyperparameters.signal_variance * (1.0 + dot_product).powi(2)
}
pub fn predict(&self, x: &[f64]) -> BayesianOptResult<(f64, f64)> {
let l = self.l_factor.as_ref().ok_or_else(|| {
BayesianOptError::GaussianProcessError("Model not fitted".to_string())
})?;
let alpha = self.alpha.as_ref().ok_or_else(|| {
BayesianOptError::GaussianProcessError("Model not fitted".to_string())
})?;
let k_star: Vec<f64> = self
.x_train
.iter()
.map(|x_train| self.kernel(x, x_train))
.collect();
let mut mean = self.mean_function_value(x);
for (ks, a) in k_star.iter().zip(alpha.iter()) {
mean += ks * a;
}
let v = forward_substitution(l, &k_star);
let mut variance = self.kernel(x, x);
for vi in &v {
variance -= vi * vi;
}
variance = variance.max(1e-12);
Ok((mean, variance))
}
fn mean_function_value(&self, x: &[f64]) -> f64 {
match self.config.mean_function {
MeanFunction::Zero => 0.0,
MeanFunction::Constant(c) => c,
MeanFunction::Linear => {
x.iter().sum::<f64>() * self.hyperparameters.mean_parameters.first().unwrap_or(&0.0)
}
MeanFunction::Polynomial { degree: _ } => {
let x_sum = x.iter().sum::<f64>();
x_sum * self.hyperparameters.mean_parameters.first().unwrap_or(&0.0)
}
}
}
pub fn log_marginal_likelihood(&self) -> BayesianOptResult<f64> {
let l = self.l_factor.as_ref().ok_or_else(|| {
BayesianOptError::GaussianProcessError("Model not fitted".to_string())
})?;
let alpha = self.alpha.as_ref().ok_or_else(|| {
BayesianOptError::GaussianProcessError("Model not fitted".to_string())
})?;
let n = self.y_train.len();
let prior_mean = self.prior_mean_vector();
let mut data_fit = 0.0;
for i in 0..n {
data_fit += (self.y_train[i] - prior_mean[i]) * alpha[i];
}
let mut half_log_det = 0.0;
for i in 0..n {
half_log_det += l[i][i].ln();
}
let log_likelihood = (-0.5 * data_fit) - half_log_det - (0.5 * n as f64) * (2.0 * PI).ln();
Ok(log_likelihood)
}
}
fn cholesky_lower(a: &[Vec<f64>]) -> Option<Vec<Vec<f64>>> {
let n = a.len();
let mut l = vec![vec![0.0; n]; n];
for i in 0..n {
for j in 0..=i {
let mut sum = a[i][j];
for k in 0..j {
sum -= l[i][k] * l[j][k];
}
if i == j {
if sum <= 0.0 || !sum.is_finite() {
return None;
}
l[i][i] = sum.sqrt();
} else {
let pivot = l[j][j];
if pivot.abs() < 1e-300 {
return None;
}
l[i][j] = sum / pivot;
}
}
}
Some(l)
}
fn forward_substitution(l: &[Vec<f64>], b: &[f64]) -> Vec<f64> {
let n = b.len();
let mut y = vec![0.0; n];
for i in 0..n {
let mut sum = b[i];
for k in 0..i {
sum -= l[i][k] * y[k];
}
y[i] = sum / l[i][i];
}
y
}
fn back_substitution_transpose(l: &[Vec<f64>], z: &[f64]) -> Vec<f64> {
let n = z.len();
let mut x = vec![0.0; n];
for i in (0..n).rev() {
let mut sum = z[i];
for k in (i + 1)..n {
sum -= l[k][i] * x[k];
}
x[i] = sum / l[i][i];
}
x
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_gp_cholesky_quadratic_regression() {
let grid = [0.0f64, 1.0, 2.0, 3.0, 4.0, 5.0];
let x_train: Vec<Vec<f64>> = grid.iter().map(|&x| vec![x]).collect();
let y_train: Vec<f64> = grid.iter().map(|&x| (x - 2.0).powi(2)).collect();
let config = GaussianProcessSurrogate {
kernel: KernelFunction::RBF,
noise_variance: 1e-8,
mean_function: MeanFunction::Zero,
};
let model = GaussianProcessModel::new(x_train, y_train.clone(), config)
.expect("GP should fit on well-separated quadratic samples");
let mut train_var_max = 0.0f64;
for (&x, &y) in grid.iter().zip(y_train.iter()) {
let (mean, variance) = model.predict(&[x]).expect("prediction should succeed");
assert!(
(mean - y).abs() < 1e-3,
"at x={x}, predicted mean {mean} should match target {y}"
);
assert!(
variance < 1e-2,
"posterior variance {variance} at training point x={x} should be near zero"
);
train_var_max = train_var_max.max(variance);
}
let (mean_mid, var_mid) = model.predict(&[2.5]).expect("interpolation should succeed");
let true_mid = (2.5f64 - 2.0).powi(2);
assert!(
(mean_mid - true_mid).abs() < 0.75,
"interpolated mean {mean_mid} should be near the true value {true_mid}"
);
assert!(var_mid > 0.0, "interpolation variance should be positive");
let (_mean_far, var_far) = model
.predict(&[12.0])
.expect("extrapolation should succeed");
assert!(
var_far > 10.0 * train_var_max,
"extrapolation variance {var_far} should exceed training-point variance {train_var_max}"
);
}
#[test]
fn test_cholesky_lower_reconstructs_matrix() {
let a = vec![
vec![4.0, 2.0, 2.0],
vec![2.0, 5.0, 3.0],
vec![2.0, 3.0, 6.0],
];
let l = cholesky_lower(&a).expect("SPD matrix should factorize");
for i in 0..3 {
for j in 0..3 {
let mut reconstructed = 0.0;
for k in 0..3 {
reconstructed += l[i][k] * l[j][k];
}
assert!(
(reconstructed - a[i][j]).abs() < 1e-9,
"L Lᵀ mismatch at ({i},{j})"
);
}
}
}
#[test]
fn test_cholesky_rejects_non_pd() {
let a = vec![vec![1.0, 2.0], vec![2.0, 1.0]];
assert!(cholesky_lower(&a).is_none());
}
#[test]
fn test_triangular_solves_roundtrip() {
let a = vec![
vec![4.0, 2.0, 2.0],
vec![2.0, 5.0, 3.0],
vec![2.0, 3.0, 6.0],
];
let l = cholesky_lower(&a).expect("SPD matrix should factorize");
let b = vec![1.0, -2.0, 3.0];
let z = forward_substitution(&l, &b);
let x = back_substitution_transpose(&l, &z);
for i in 0..3 {
let mut ax = 0.0;
for j in 0..3 {
ax += a[i][j] * x[j];
}
assert!((ax - b[i]).abs() < 1e-9, "A x != b at row {i}");
}
}
#[test]
fn test_surrogate_predict_is_honest_error() {
let surrogate = GaussianProcessSurrogate::default();
assert!(surrogate.predict(&[0.0]).is_err());
}
#[test]
fn test_log_marginal_likelihood_finite() {
let x_train = vec![vec![0.0], vec![1.0], vec![2.0], vec![3.0]];
let y_train = vec![0.0, 1.0, 4.0, 9.0];
let config = GaussianProcessSurrogate {
kernel: KernelFunction::RBF,
noise_variance: 1e-6,
mean_function: MeanFunction::Zero,
};
let model = GaussianProcessModel::new(x_train, y_train, config).expect("fit");
let lml = model
.log_marginal_likelihood()
.expect("log marginal likelihood should be computable");
assert!(lml.is_finite());
}
}