#![allow(clippy::needless_range_loop)]
use super::cholesky::{backward_sub, cholesky, forward_sub};
use super::kernel::Kernel;
use super::GpError;
use crate::core::scalar::ControlScalar;
pub struct GpRegression<S, K, const D: usize, const N: usize>
where
S: ControlScalar,
K: Kernel<S, D>,
{
kernel: K,
noise_var: S,
x_train: [[S; D]; N],
y_train: [S; N],
l_chol: [[S; N]; N],
alpha: [S; N],
trained: bool,
}
impl<S, K, const D: usize, const N: usize> GpRegression<S, K, D, N>
where
S: ControlScalar,
K: Kernel<S, D>,
{
pub fn new(kernel: K, noise_var: S) -> Self {
Self {
kernel,
noise_var,
x_train: [[S::ZERO; D]; N],
y_train: [S::ZERO; N],
l_chol: [[S::ZERO; N]; N],
alpha: [S::ZERO; N],
trained: false,
}
}
pub fn fit(&mut self, x_train: [[S; D]; N], y_train: [S; N]) -> Result<(), GpError> {
self.x_train = x_train;
self.y_train = y_train;
let mut k_mat = [[S::ZERO; N]; N];
for i in 0..N {
for j in 0..=i {
let kij = self.kernel.eval(&x_train[i], &x_train[j]);
k_mat[i][j] = kij;
k_mat[j][i] = kij;
}
k_mat[i][i] += self.noise_var;
}
self.l_chol = cholesky(&k_mat)?;
let v = forward_sub(&self.l_chol, &y_train)?;
self.alpha = backward_sub(&self.l_chol, &v)?;
self.trained = true;
Ok(())
}
pub fn predict(&self, x_star: &[S; D]) -> Result<(S, S), GpError> {
if !self.trained {
return Err(GpError::NotTrained);
}
let mut k_star = [S::ZERO; N];
for i in 0..N {
k_star[i] = self.kernel.eval(x_star, &self.x_train[i]);
}
let mut mean = S::ZERO;
for i in 0..N {
mean += k_star[i] * self.alpha[i];
}
let v = forward_sub(&self.l_chol, &k_star)?;
let mut v_dot_v = S::ZERO;
for i in 0..N {
v_dot_v += v[i] * v[i];
}
let prior_var = self.kernel.eval(x_star, x_star) + self.noise_var;
let var_raw = prior_var - v_dot_v;
let var = if var_raw < S::ZERO { S::ZERO } else { var_raw };
Ok((mean, var))
}
pub fn log_marginal_likelihood(&self) -> Result<S, GpError> {
if !self.trained {
return Err(GpError::NotTrained);
}
let mut yt_alpha = 0.0_f64;
for i in 0..N {
yt_alpha += self.y_train[i].to_f64() * self.alpha[i].to_f64();
}
let term1 = -0.5 * yt_alpha;
let mut log_det = 0.0_f64;
for i in 0..N {
log_det += libm::log(self.l_chol[i][i].to_f64());
}
let term2 = -log_det;
let term3 = -(N as f64) * 0.5 * libm::log(2.0 * core::f64::consts::PI);
Ok(S::from_f64(term1 + term2 + term3))
}
pub fn is_trained(&self) -> bool {
self.trained
}
pub fn kernel(&self) -> &K {
&self.kernel
}
pub fn noise_var(&self) -> S {
self.noise_var
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gp::kernel::{LinearKernel, Matern52Kernel, RbfKernel};
fn make_rbf() -> RbfKernel<f64> {
RbfKernel {
variance: 1.0,
length_scale: 1.0,
}
}
#[test]
fn fit_1d_rbf_no_error() {
let mut gp: GpRegression<f64, _, 1, 3> = GpRegression::new(make_rbf(), 0.01);
let x = [[0.0_f64], [1.0], [2.0]];
let y = [0.0_f64, 1.0, 0.0];
assert!(gp.fit(x, y).is_ok());
}
#[test]
fn predict_at_training_point_close_to_target() {
let mut gp: GpRegression<f64, _, 1, 3> = GpRegression::new(make_rbf(), 1e-6);
let x = [[0.0_f64], [1.0], [2.0]];
let y = [0.0_f64, 1.0, 0.0];
gp.fit(x, y).expect("fit must succeed");
let (mean, _var) = gp.predict(&[1.0_f64]).expect("predict must succeed");
assert!((mean - 1.0).abs() < 0.01, "mean={mean} expected ~1.0");
}
#[test]
fn variance_increases_away_from_training() {
let mut gp: GpRegression<f64, _, 1, 3> = GpRegression::new(make_rbf(), 1e-6);
let x = [[0.0_f64], [1.0], [2.0]];
let y = [0.0_f64, 1.0, 0.0];
gp.fit(x, y).expect("fit must succeed");
let (_m_near, var_near) = gp.predict(&[1.0_f64]).expect("predict failed");
let (_m_far, var_far) = gp.predict(&[100.0_f64]).expect("predict failed");
assert!(
var_far > var_near,
"far variance {var_far} should exceed near variance {var_near}"
);
}
#[test]
fn log_marginal_likelihood_finite() {
let mut gp: GpRegression<f64, _, 1, 3> = GpRegression::new(make_rbf(), 0.1);
let x = [[0.0_f64], [1.0], [2.0]];
let y = [0.0_f64, 1.0, 0.0];
gp.fit(x, y).expect("fit must succeed");
let lml = gp.log_marginal_likelihood().expect("lml must succeed");
assert!(lml.is_finite(), "lml={lml} must be finite");
}
#[test]
fn predict_before_fit_returns_not_trained() {
let gp: GpRegression<f64, _, 1, 3> = GpRegression::new(make_rbf(), 0.1);
let result = gp.predict(&[0.5_f64]);
assert_eq!(result, Err(GpError::NotTrained));
}
#[test]
fn lml_before_fit_returns_not_trained() {
let gp: GpRegression<f64, _, 1, 3> = GpRegression::new(make_rbf(), 0.1);
assert_eq!(gp.log_marginal_likelihood(), Err(GpError::NotTrained));
}
#[test]
fn fit_matern52_succeeds() {
let kernel = Matern52Kernel::<f64> {
variance: 1.0,
length_scale: 1.0,
};
let mut gp: GpRegression<f64, _, 1, 4> = GpRegression::new(kernel, 0.1);
let x = [[0.0_f64], [1.0], [2.0], [3.0]];
let y = [1.0_f64, 2.0, 1.5, 0.5];
assert!(gp.fit(x, y).is_ok());
}
#[test]
fn fit_linear_kernel_succeeds() {
let kernel = LinearKernel::<f64> {
variance: 1.0,
bias: 1.0,
degree: 1,
};
let mut gp: GpRegression<f64, _, 1, 3> = GpRegression::new(kernel, 0.1);
let x = [[1.0_f64], [2.0], [3.0]];
let y = [2.0_f64, 4.0, 6.0];
assert!(gp.fit(x, y).is_ok());
}
#[test]
fn variance_always_nonneg() {
let mut gp: GpRegression<f64, _, 1, 3> = GpRegression::new(make_rbf(), 1e-6);
let x = [[0.0_f64], [1.0], [2.0]];
let y = [0.0_f64, 1.0, 0.0];
gp.fit(x, y).expect("fit must succeed");
for xstar in [-5.0_f64, 0.0, 0.5, 1.0, 2.0, 10.0, 100.0] {
let (_m, var) = gp.predict(&[xstar]).expect("predict failed");
assert!(var >= 0.0, "variance {var} < 0 at x={xstar}");
}
}
#[test]
fn fit_2d_rbf_succeeds() {
let kernel = RbfKernel::<f64> {
variance: 1.0,
length_scale: 1.0,
};
let mut gp: GpRegression<f64, _, 2, 4> = GpRegression::new(kernel, 0.1);
let x = [[0.0_f64, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]];
let y = [0.0_f64, 1.0, 1.0, 2.0];
assert!(gp.fit(x, y).is_ok());
let (mean, var) = gp.predict(&[0.5_f64, 0.5]).expect("predict failed");
assert!(var >= 0.0, "variance must be non-negative, got {var}");
assert!(mean.is_finite(), "mean must be finite, got {mean}");
}
}