use crate::error::{dimension_mismatch, invalid_param, numerical_error, Result};
struct Lcg {
state: u64,
}
impl Lcg {
fn new(seed: u64) -> Self {
let state = if seed == 0 {
0xDEAD_BEEF_CAFE_BABE
} else {
seed
};
Self { state }
}
fn next_u64(&mut self) -> u64 {
self.state = self
.state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
self.state
}
fn next_index(&mut self, n: usize) -> usize {
if n == 0 {
0
} else {
(self.next_u64() as usize) % n
}
}
}
fn erf_approx(x: f64) -> f64 {
const A1: f64 = 0.254_829_592;
const A2: f64 = -0.284_496_736;
const A3: f64 = 1.421_413_741;
const A4: f64 = -1.453_152_027;
const A5: f64 = 1.061_405_429;
const P: f64 = 0.327_591_1;
let sign = if x < 0.0 { -1.0 } else { 1.0 };
let ax = x.abs();
let t = 1.0 / (1.0 + P * ax);
let poly = (((A5 * t + A4) * t + A3) * t + A2) * t + A1;
let y = 1.0 - poly * t * (-(ax * ax)).exp();
sign * y
}
fn normal_cdf(z: f64) -> f64 {
0.5 * (1.0 + erf_approx(z / std::f64::consts::SQRT_2))
}
fn normal_pdf(z: f64) -> f64 {
let inv_sqrt_two_pi = 1.0 / (2.0 * std::f64::consts::PI).sqrt();
inv_sqrt_two_pi * (-0.5 * z * z).exp()
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GpConfig {
pub length_scale: f64,
pub signal_variance: f64,
pub noise_variance: f64,
pub jitter: f64,
}
impl GpConfig {
pub fn validate(&self) -> Result<()> {
if !(self.length_scale > 0.0 && self.length_scale.is_finite()) {
return Err(invalid_param("length_scale", "must be positive and finite"));
}
if !(self.signal_variance > 0.0 && self.signal_variance.is_finite()) {
return Err(invalid_param(
"signal_variance",
"must be positive and finite",
));
}
if !(self.noise_variance >= 0.0 && self.noise_variance.is_finite()) {
return Err(invalid_param(
"noise_variance",
"must be non-negative and finite",
));
}
if !(self.jitter >= 0.0 && self.jitter.is_finite()) {
return Err(invalid_param("jitter", "must be non-negative and finite"));
}
Ok(())
}
}
pub struct GaussianProcess {
pub config: GpConfig,
pub x_train: Vec<Vec<f64>>,
pub y_train: Vec<f64>,
alpha: Vec<f64>,
l_factor: Vec<f64>,
n_train: usize,
}
impl GaussianProcess {
pub fn new(config: GpConfig) -> Result<Self> {
config.validate()?;
Ok(Self {
config,
x_train: Vec::new(),
y_train: Vec::new(),
alpha: Vec::new(),
l_factor: Vec::new(),
n_train: 0,
})
}
pub fn config_default() -> GpConfig {
GpConfig {
length_scale: 1.0,
signal_variance: 1.0,
noise_variance: 1.0e-4,
jitter: 1.0e-6,
}
}
fn rbf_kernel(&self, a: &[f64], b: &[f64]) -> f64 {
let mut sq = 0.0_f64;
for (x, y) in a.iter().zip(b.iter()) {
let d = x - y;
sq += d * d;
}
let inv_2l2 = 1.0 / (2.0 * self.config.length_scale * self.config.length_scale);
self.config.signal_variance * (-sq * inv_2l2).exp()
}
pub fn fit(&mut self, x_train: Vec<Vec<f64>>, y_train: Vec<f64>) -> Result<()> {
if x_train.len() != y_train.len() {
return Err(dimension_mismatch(
&format!("{} x rows", x_train.len()),
&format!("{} y rows", y_train.len()),
));
}
if x_train.is_empty() {
return Err(invalid_param("x_train", "must contain ≥ 1 training row"));
}
let n = x_train.len();
let d = x_train[0].len();
for (i, row) in x_train.iter().enumerate() {
if row.len() != d {
return Err(dimension_mismatch(
&format!("{d} input dims (row 0)"),
&format!("{} input dims (row {i})", row.len()),
));
}
}
self.x_train = x_train;
self.y_train = y_train;
self.n_train = n;
let mut k_matrix = vec![0.0_f64; n * n];
for i in 0..n {
for j in 0..n {
k_matrix[i * n + j] = self.rbf_kernel(&self.x_train[i], &self.x_train[j]);
}
k_matrix[i * n + i] += self.config.noise_variance + self.config.jitter;
}
let l = match cholesky_lower(&k_matrix, n) {
Some(l) => l,
None => {
let extra = self.config.jitter.max(1.0e-8) * 100.0;
let mut k_retry = k_matrix.clone();
for i in 0..n {
k_retry[i * n + i] += extra;
}
cholesky_lower(&k_retry, n).ok_or_else(|| {
numerical_error(
"GP kernel matrix is not positive definite even after jitter retry",
)
})?
},
};
self.l_factor = l;
let z = forward_substitution(&self.l_factor, &self.y_train, n);
self.alpha = backward_substitution_transposed(&self.l_factor, &z, n);
Ok(())
}
pub fn predict_mean(&self, x: &[f64]) -> Result<f64> {
if self.n_train == 0 {
return Err(invalid_param("GaussianProcess", "must call fit() first"));
}
if x.len() != self.x_train[0].len() {
return Err(dimension_mismatch(
&format!("{} input dims", self.x_train[0].len()),
&format!("{} input dims", x.len()),
));
}
let mut mu = 0.0_f64;
for i in 0..self.n_train {
mu += self.rbf_kernel(x, &self.x_train[i]) * self.alpha[i];
}
Ok(mu)
}
pub fn predict(&self, x: &[f64]) -> Result<(f64, f64)> {
if self.n_train == 0 {
return Err(invalid_param("GaussianProcess", "must call fit() first"));
}
if x.len() != self.x_train[0].len() {
return Err(dimension_mismatch(
&format!("{} input dims", self.x_train[0].len()),
&format!("{} input dims", x.len()),
));
}
let mut k_star = vec![0.0_f64; self.n_train];
for (i, k_entry) in k_star.iter_mut().enumerate() {
*k_entry = self.rbf_kernel(x, &self.x_train[i]);
}
let mut mu = 0.0_f64;
for (k, a) in k_star.iter().zip(self.alpha.iter()) {
mu += k * a;
}
let v = forward_substitution(&self.l_factor, &k_star, self.n_train);
let mut vv = 0.0_f64;
for vi in &v {
vv += vi * vi;
}
let kxx = self.rbf_kernel(x, x);
let var = (kxx - vv).max(0.0);
Ok((mu, var.sqrt()))
}
}
fn cholesky_lower(a: &[f64], n: usize) -> Option<Vec<f64>> {
let mut l = vec![0.0_f64; n * n];
for i in 0..n {
for j in 0..=i {
let mut sum = a[i * n + j];
for k in 0..j {
sum -= l[i * n + k] * l[j * n + k];
}
if i == j {
if !(sum > 0.0 && sum.is_finite()) {
return None;
}
l[i * n + j] = sum.sqrt();
} else {
let diag = l[j * n + j];
if !(diag.abs() > 0.0 && diag.is_finite()) {
return None;
}
l[i * n + j] = sum / diag;
}
}
}
Some(l)
}
fn forward_substitution(l: &[f64], b: &[f64], n: usize) -> Vec<f64> {
let mut y = vec![0.0_f64; n];
for i in 0..n {
let mut sum = b[i];
for j in 0..i {
sum -= l[i * n + j] * y[j];
}
y[i] = sum / l[i * n + i];
}
y
}
fn backward_substitution_transposed(l: &[f64], y: &[f64], n: usize) -> Vec<f64> {
let mut x = vec![0.0_f64; n];
for i in (0..n).rev() {
let mut sum = y[i];
for j in (i + 1)..n {
sum -= l[j * n + i] * x[j];
}
x[i] = sum / l[i * n + i];
}
x
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AcquisitionStrategy {
ExpectedImprovement,
UpperConfidenceBound {
kappa: i32,
},
PosteriorVariance,
}
#[derive(Debug, Clone)]
pub struct BayesianOptConfig {
pub n_initial_samples: usize,
pub n_iterations: usize,
pub gp_config: GpConfig,
pub acquisition: AcquisitionStrategy,
pub maximize: bool,
}
impl BayesianOptConfig {
pub fn validate(&self) -> Result<()> {
if self.n_initial_samples == 0 {
return Err(invalid_param(
"n_initial_samples",
"need ≥ 1 sample to seed the GP",
));
}
if self.n_iterations == 0 {
return Err(invalid_param(
"n_iterations",
"must request ≥ 1 acquisition iteration",
));
}
self.gp_config.validate()?;
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct BayesianOptResult {
pub best_x: Vec<f64>,
pub best_y: f64,
pub n_evaluations: usize,
pub history_x: Vec<Vec<f64>>,
pub history_y: Vec<f64>,
}
pub struct BayesianOptimizer {
pub config: BayesianOptConfig,
pub gp: GaussianProcess,
pub history_x: Vec<Vec<f64>>,
pub history_y: Vec<f64>,
}
impl BayesianOptimizer {
pub fn new(config: BayesianOptConfig) -> Result<Self> {
config.validate()?;
let gp = GaussianProcess::new(config.gp_config)?;
Ok(Self {
config,
gp,
history_x: Vec::new(),
history_y: Vec::new(),
})
}
pub fn add_sample(&mut self, x: Vec<f64>, y: f64) {
self.history_x.push(x);
self.history_y.push(y);
}
pub fn fit_gp(&mut self) -> Result<()> {
self.gp.fit(self.history_x.clone(), self.history_y.clone())
}
pub fn acquisition_score(&self, x: &[f64]) -> Result<f64> {
let (mu, sigma) = self.gp.predict(x)?;
match self.config.acquisition {
AcquisitionStrategy::ExpectedImprovement => {
let best_raw = self.current_best_y_for_score();
let (improvement_centre, effective_best) = if self.config.maximize {
(mu, best_raw)
} else {
(-mu, -best_raw)
};
let diff = improvement_centre - effective_best;
if sigma <= 0.0 {
return Ok(diff.max(0.0));
}
let z = diff / sigma;
let ei = diff * normal_cdf(z) + sigma * normal_pdf(z);
Ok(ei)
},
AcquisitionStrategy::UpperConfidenceBound { kappa } => {
let k = kappa as f64;
if self.config.maximize {
Ok(mu + k * sigma)
} else {
Ok(-mu + k * sigma)
}
},
AcquisitionStrategy::PosteriorVariance => Ok(sigma),
}
}
pub fn select_next_query(&self, candidate_pool: &[Vec<f64>]) -> Result<usize> {
if candidate_pool.is_empty() {
return Err(invalid_param(
"candidate_pool",
"must contain ≥ 1 candidate",
));
}
let mut best_idx = 0_usize;
let mut best_score = f64::NEG_INFINITY;
for (idx, x) in candidate_pool.iter().enumerate() {
let s = self.acquisition_score(x)?;
if s > best_score {
best_score = s;
best_idx = idx;
}
}
Ok(best_idx)
}
pub fn optimize<F>(
&mut self,
oracle: F,
candidate_pool: &[Vec<f64>],
rng_seed: u64,
) -> Result<BayesianOptResult>
where
F: Fn(&[f64]) -> f64,
{
let total_budget = self.config.n_initial_samples + self.config.n_iterations;
if candidate_pool.len() < total_budget {
return Err(invalid_param(
"candidate_pool",
&format!(
"needs ≥ {} candidates (got {})",
total_budget,
candidate_pool.len(),
),
));
}
let mut rng = Lcg::new(rng_seed);
let mut visited = vec![false; candidate_pool.len()];
for _ in 0..self.config.n_initial_samples {
let mut pick = rng.next_index(candidate_pool.len());
let mut probes = 0;
while visited[pick] && probes < candidate_pool.len() {
pick = (pick + 1) % candidate_pool.len();
probes += 1;
}
visited[pick] = true;
let x = candidate_pool[pick].clone();
let y = oracle(&x);
self.add_sample(x, y);
}
self.fit_gp()?;
for _ in 0..self.config.n_iterations {
let mut remaining: Vec<usize> = Vec::with_capacity(candidate_pool.len());
for (i, &v) in visited.iter().enumerate() {
if !v {
remaining.push(i);
}
}
if remaining.is_empty() {
break;
}
let sub_pool: Vec<Vec<f64>> = remaining
.iter()
.map(|&i| candidate_pool[i].clone())
.collect();
let local = self.select_next_query(&sub_pool)?;
let global = remaining[local];
visited[global] = true;
let x = candidate_pool[global].clone();
let y = oracle(&x);
self.add_sample(x, y);
self.fit_gp()?;
}
let (best_idx, best_y) = self.argbest();
Ok(BayesianOptResult {
best_x: self.history_x[best_idx].clone(),
best_y,
n_evaluations: self.history_y.len(),
history_x: self.history_x.clone(),
history_y: self.history_y.clone(),
})
}
fn argbest(&self) -> (usize, f64) {
let mut best_idx = 0_usize;
let mut best_val = if self.config.maximize {
f64::NEG_INFINITY
} else {
f64::INFINITY
};
for (i, &y) in self.history_y.iter().enumerate() {
let better = if self.config.maximize {
y > best_val
} else {
y < best_val
};
if better {
best_val = y;
best_idx = i;
}
}
(best_idx, best_val)
}
fn current_best_y_for_score(&self) -> f64 {
let (_, best) = self.argbest();
best
}
}
#[cfg(test)]
mod tests {
use super::*;
fn approx(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() < tol
}
#[test]
fn test_gp_config_validation() {
let good = GpConfig {
length_scale: 1.0,
signal_variance: 0.5,
noise_variance: 1.0e-4,
jitter: 1.0e-6,
};
assert!(good.validate().is_ok());
let bad_ls = GpConfig {
length_scale: 0.0,
..good
};
assert!(bad_ls.validate().is_err());
let bad_sig = GpConfig {
signal_variance: -0.1,
..good
};
assert!(bad_sig.validate().is_err());
let bad_noise = GpConfig {
noise_variance: -1.0,
..good
};
assert!(bad_noise.validate().is_err());
let bad_jitter = GpConfig {
jitter: f64::INFINITY,
..good
};
assert!(bad_jitter.validate().is_err());
assert!(GaussianProcess::new(bad_ls).is_err());
}
#[test]
fn test_gp_fit_5_points_succeeds() {
let mut gp = GaussianProcess::new(GaussianProcess::config_default()).unwrap();
let xs: Vec<Vec<f64>> = (0..5).map(|i| vec![i as f64 * 0.25]).collect();
let ys: Vec<f64> = xs.iter().map(|x| x[0].sin()).collect();
gp.fit(xs.clone(), ys.clone()).unwrap();
assert_eq!(gp.n_train, 5);
assert_eq!(gp.alpha.len(), 5);
assert_eq!(gp.l_factor.len(), 25);
}
#[test]
fn test_predict_at_training_point() {
let mut gp = GaussianProcess::new(GpConfig {
length_scale: 0.5,
signal_variance: 1.0,
noise_variance: 1.0e-8,
jitter: 1.0e-10,
})
.unwrap();
let xs: Vec<Vec<f64>> = (0..4)
.map(|i| vec![(i as f64) * 0.3, (i as f64) * 0.1])
.collect();
let ys: Vec<f64> = vec![0.1, 0.4, -0.2, 0.9];
gp.fit(xs.clone(), ys.clone()).unwrap();
for (x, &y) in xs.iter().zip(ys.iter()) {
let mu = gp.predict_mean(x).unwrap();
assert!(approx(mu, y, 1.0e-4), "predict {mu} vs train {y}");
}
}
#[test]
fn test_predict_variance_positive_at_unseen() {
let mut gp = GaussianProcess::new(GaussianProcess::config_default()).unwrap();
let xs: Vec<Vec<f64>> = vec![vec![0.0], vec![0.5], vec![1.0]];
let ys: Vec<f64> = vec![0.0, 0.5, 1.0];
gp.fit(xs, ys).unwrap();
let (_mu, sigma) = gp.predict(&[2.0]).unwrap();
assert!(sigma > 0.0, "expected positive sigma at unseen point");
}
#[test]
fn test_rbf_kernel_symmetric() {
let gp = GaussianProcess::new(GaussianProcess::config_default()).unwrap();
let a = vec![0.3_f64, -0.2, 0.7];
let b = vec![1.1_f64, 0.5, -0.4];
let kab = gp.rbf_kernel(&a, &b);
let kba = gp.rbf_kernel(&b, &a);
assert!(approx(kab, kba, 1.0e-15));
}
#[test]
fn test_rbf_kernel_max_at_zero_distance() {
let cfg = GpConfig {
length_scale: 0.8,
signal_variance: 2.5,
noise_variance: 0.0,
jitter: 0.0,
};
let gp = GaussianProcess::new(cfg).unwrap();
let a = vec![1.2_f64, -0.3];
let kaa = gp.rbf_kernel(&a, &a);
let kab = gp.rbf_kernel(&a, &[1.5_f64, 0.0]);
assert!(approx(kaa, cfg.signal_variance, 1.0e-12));
assert!(kaa > kab);
}
#[test]
fn test_acquisition_strategy_variants() {
let _ei = AcquisitionStrategy::ExpectedImprovement;
let _ucb = AcquisitionStrategy::UpperConfidenceBound { kappa: 2 };
let _pv = AcquisitionStrategy::PosteriorVariance;
assert_ne!(
AcquisitionStrategy::ExpectedImprovement,
AcquisitionStrategy::PosteriorVariance,
);
assert_ne!(
AcquisitionStrategy::UpperConfidenceBound { kappa: 1 },
AcquisitionStrategy::UpperConfidenceBound { kappa: 2 },
);
}
#[test]
fn test_ei_zero_when_no_room_for_improvement() {
let cfg = BayesianOptConfig {
n_initial_samples: 1,
n_iterations: 1,
gp_config: GpConfig {
length_scale: 0.5,
signal_variance: 1.0,
noise_variance: 1.0e-10,
jitter: 1.0e-10,
},
acquisition: AcquisitionStrategy::ExpectedImprovement,
maximize: true,
};
let mut opt = BayesianOptimizer::new(cfg).unwrap();
opt.add_sample(vec![0.0_f64], 1.0);
opt.fit_gp().unwrap();
let ei = opt.acquisition_score(&[0.0_f64]).unwrap();
assert!(
ei.abs() < 1.0e-3,
"EI at the best point should ≈ 0, got {ei}"
);
}
#[test]
fn test_bayesian_optimizer_construct() {
let bad = BayesianOptConfig {
n_initial_samples: 0,
n_iterations: 5,
gp_config: GaussianProcess::config_default(),
acquisition: AcquisitionStrategy::ExpectedImprovement,
maximize: false,
};
assert!(BayesianOptimizer::new(bad).is_err());
let good = BayesianOptConfig {
n_initial_samples: 2,
n_iterations: 4,
gp_config: GaussianProcess::config_default(),
acquisition: AcquisitionStrategy::ExpectedImprovement,
maximize: false,
};
let opt = BayesianOptimizer::new(good).unwrap();
assert_eq!(opt.history_x.len(), 0);
assert_eq!(opt.history_y.len(), 0);
}
#[test]
fn test_optimize_minimisation_reduces_best() {
let pool: Vec<Vec<f64>> = (0..21).map(|i| vec![(i as f64) / 20.0]).collect();
let cfg = BayesianOptConfig {
n_initial_samples: 3,
n_iterations: 8,
gp_config: GpConfig {
length_scale: 0.15,
signal_variance: 1.0,
noise_variance: 1.0e-6,
jitter: 1.0e-8,
},
acquisition: AcquisitionStrategy::ExpectedImprovement,
maximize: false,
};
let mut opt = BayesianOptimizer::new(cfg).unwrap();
let oracle = |x: &[f64]| (x[0] - 0.7).powi(2);
let result = opt.optimize(oracle, &pool, 0x00C0_FFEE_u64).unwrap();
assert!(
result.best_y < 0.05,
"BO did not converge: best_y = {}",
result.best_y,
);
let initial_best = result.history_y[..3]
.iter()
.copied()
.fold(f64::INFINITY, f64::min);
assert!(
result.best_y <= initial_best,
"best_y {} should be ≤ initial best {}",
result.best_y,
initial_best,
);
}
#[test]
fn test_optimize_maximisation() {
let pool: Vec<Vec<f64>> = (0..21).map(|i| vec![(i as f64) / 20.0]).collect();
let cfg = BayesianOptConfig {
n_initial_samples: 3,
n_iterations: 8,
gp_config: GpConfig {
length_scale: 0.15,
signal_variance: 1.0,
noise_variance: 1.0e-6,
jitter: 1.0e-8,
},
acquisition: AcquisitionStrategy::ExpectedImprovement,
maximize: true,
};
let mut opt = BayesianOptimizer::new(cfg).unwrap();
let oracle = |x: &[f64]| -(x[0] - 0.3).powi(2);
let result = opt.optimize(oracle, &pool, 0xBEEF_u64).unwrap();
assert!(
result.best_y > -0.05,
"BO did not converge to maximum: best_y = {}",
result.best_y,
);
}
#[test]
fn test_seed_reproducibility() {
let pool: Vec<Vec<f64>> = (0..15).map(|i| vec![(i as f64) / 14.0]).collect();
let make_opt = || {
BayesianOptimizer::new(BayesianOptConfig {
n_initial_samples: 3,
n_iterations: 4,
gp_config: GpConfig {
length_scale: 0.2,
signal_variance: 1.0,
noise_variance: 1.0e-6,
jitter: 1.0e-8,
},
acquisition: AcquisitionStrategy::UpperConfidenceBound { kappa: 2 },
maximize: false,
})
.unwrap()
};
let oracle = |x: &[f64]| (x[0] - 0.4).powi(2) + 0.1;
let mut a = make_opt();
let mut b = make_opt();
let ra = a.optimize(oracle, &pool, 0xABCD_u64).unwrap();
let rb = b.optimize(oracle, &pool, 0xABCD_u64).unwrap();
assert_eq!(ra.history_y.len(), rb.history_y.len());
for (x, y) in ra.history_y.iter().zip(rb.history_y.iter()) {
assert_eq!(x.to_bits(), y.to_bits());
}
assert_eq!(ra.best_y.to_bits(), rb.best_y.to_bits());
}
}