use scirs2_core::ndarray::{Array1, Array2, Axis};
use sklears_core::{
error::{Result as SklResult, SklearsError},
traits::{Estimator, Fit, Predict, Untrained},
types::Float,
};
use crate::kernels::Kernel;
use crate::utils::{robust_cholesky, triangular_solve};
pub use crate::sparse_gpr::InducingPointInit;
#[derive(Debug, Clone)]
pub struct FitcGaussianProcessRegressorConfig {
pub n_inducing: usize,
pub inducing_init: InducingPointInit,
pub noise_variance: f64,
pub jitter: f64,
pub copy_x_train: bool,
pub random_state: Option<u64>,
}
impl Default for FitcGaussianProcessRegressorConfig {
fn default() -> Self {
Self {
n_inducing: 10,
inducing_init: InducingPointInit::Kmeans,
noise_variance: 1e-6,
jitter: 1e-6,
copy_x_train: true,
random_state: None,
}
}
}
#[derive(Debug, Clone)]
pub struct FitcGaussianProcessRegressor<S = Untrained> {
state: S,
kernel: Option<Box<dyn Kernel>>,
config: FitcGaussianProcessRegressorConfig,
}
#[derive(Debug, Clone)]
#[allow(non_snake_case)]
pub struct FitcGprTrained {
pub X_train: Option<Array2<f64>>,
pub y_train: Array1<f64>,
pub Z: Array2<f64>,
pub kernel: Box<dyn Kernel>,
pub L_mm: Array2<f64>,
pub L_b: Array2<f64>,
pub beta: Array1<f64>,
pub noise_variance: f64,
pub log_marginal_likelihood_value: f64,
}
impl FitcGaussianProcessRegressor<Untrained> {
pub fn new() -> Self {
Self {
state: Untrained,
kernel: None,
config: FitcGaussianProcessRegressorConfig::default(),
}
}
pub fn kernel(mut self, kernel: Box<dyn Kernel>) -> Self {
self.kernel = Some(kernel);
self
}
pub fn n_inducing(mut self, n_inducing: usize) -> Self {
self.config.n_inducing = n_inducing;
self
}
pub fn inducing_init(mut self, inducing_init: InducingPointInit) -> Self {
self.config.inducing_init = inducing_init;
self
}
pub fn noise_variance(mut self, noise_variance: f64) -> Self {
self.config.noise_variance = noise_variance;
self
}
pub fn jitter(mut self, jitter: f64) -> Self {
self.config.jitter = jitter;
self
}
pub fn copy_x_train(mut self, copy_x_train: bool) -> Self {
self.config.copy_x_train = copy_x_train;
self
}
pub fn random_state(mut self, random_state: Option<u64>) -> Self {
self.config.random_state = random_state;
self
}
}
impl Estimator for FitcGaussianProcessRegressor<Untrained> {
type Config = FitcGaussianProcessRegressorConfig;
type Error = SklearsError;
type Float = Float;
fn config(&self) -> &Self::Config {
&self.config
}
}
impl Estimator for FitcGaussianProcessRegressor<FitcGprTrained> {
type Config = FitcGaussianProcessRegressorConfig;
type Error = SklearsError;
type Float = Float;
fn config(&self) -> &Self::Config {
&self.config
}
}
#[allow(non_snake_case)]
impl Fit<Array2<f64>, Array1<f64>> for FitcGaussianProcessRegressor<Untrained> {
type Fitted = FitcGaussianProcessRegressor<FitcGprTrained>;
fn fit(self, X: &Array2<f64>, y: &Array1<f64>) -> SklResult<Self::Fitted> {
if X.nrows() != y.len() {
return Err(SklearsError::InvalidInput(
"X and y must have the same number of samples".to_string(),
));
}
let n = X.nrows();
if n == 0 {
return Err(SklearsError::InvalidInput(
"Cannot fit FitcGaussianProcessRegressor on empty data".to_string(),
));
}
let kernel = self
.kernel
.ok_or_else(|| SklearsError::InvalidInput("Kernel must be specified".to_string()))?;
let n_inducing = self.config.n_inducing.clamp(1, n);
let Z = match self.config.inducing_init {
InducingPointInit::Random => crate::utils::random_inducing_points(
&X.view(),
n_inducing,
self.config.random_state,
)?,
InducingPointInit::Uniform => crate::utils::uniform_inducing_points(
&X.view(),
n_inducing,
self.config.random_state,
)?,
InducingPointInit::Kmeans => crate::utils::kmeans_inducing_points(
&X.view(),
n_inducing,
self.config.random_state,
)?,
};
let m = Z.nrows();
let mut Kmm = kernel.compute_kernel_matrix(&Z, None)?;
for i in 0..m {
Kmm[[i, i]] += self.config.jitter;
}
let L_mm = robust_cholesky(&Kmm)?;
let Knm = kernel.compute_kernel_matrix(X, Some(&Z))?;
let mut Q_nn_diag = Array1::<f64>::zeros(n);
for i in 0..n {
let k_i = Knm.row(i).to_owned();
let w_i = triangular_solve(&L_mm, &k_i)?;
Q_nn_diag[i] = k_i.dot(&w_i);
}
let mut Lambda_diag = Array1::<f64>::zeros(n);
for i in 0..n {
let x_i = X.row(i);
let k_ii = kernel.kernel(&x_i, &x_i);
let diag_correction = (k_ii - Q_nn_diag[i]).max(0.0);
Lambda_diag[i] = diag_correction + self.config.noise_variance;
}
let inv_lambda = Lambda_diag.mapv(|v| 1.0 / v);
let inv_lambda_col = inv_lambda.view().insert_axis(Axis(1));
let Knm_scaled = &Knm * &inv_lambda_col;
let B = Kmm + Knm.t().dot(&Knm_scaled);
let y_scaled = y * &inv_lambda;
let b = Knm.t().dot(&y_scaled);
let L_b = robust_cholesky(&B)?;
let beta = triangular_solve(&L_b, &b)?;
let quad = {
let mut s = 0.0;
for i in 0..n {
s += y[i] * y[i] * inv_lambda[i];
}
s - b.dot(&beta)
};
let log_det_lambda: f64 = Lambda_diag.mapv(|v| v.ln()).sum();
let log_det_b = 2.0 * L_b.diag().mapv(|v| v.ln()).sum();
let log_det_kmm = 2.0 * L_mm.diag().mapv(|v| v.ln()).sum();
let log_marginal_likelihood_value = -0.5
* (quad + log_det_lambda + log_det_b - log_det_kmm
+ n as f64 * (2.0 * std::f64::consts::PI).ln());
let X_train = if self.config.copy_x_train {
Some(X.to_owned())
} else {
None
};
Ok(FitcGaussianProcessRegressor {
state: FitcGprTrained {
X_train,
y_train: y.to_owned(),
Z,
kernel,
L_mm,
L_b,
beta,
noise_variance: self.config.noise_variance,
log_marginal_likelihood_value,
},
kernel: None,
config: self.config,
})
}
}
#[allow(non_snake_case)]
impl Predict<Array2<f64>, Array1<f64>> for FitcGaussianProcessRegressor<FitcGprTrained> {
fn predict(&self, X: &Array2<f64>) -> SklResult<Array1<f64>> {
let (mean, _) = self.predict_with_std(X)?;
Ok(mean)
}
}
#[allow(non_snake_case)]
impl FitcGaussianProcessRegressor<FitcGprTrained> {
pub fn predict_with_std(&self, X: &Array2<f64>) -> SklResult<(Array1<f64>, Array1<f64>)> {
let K_star_m = self
.state
.kernel
.compute_kernel_matrix(X, Some(&self.state.Z))?;
let n_test = X.nrows();
let mut mean = Array1::<f64>::zeros(n_test);
let mut variance = Array1::<f64>::zeros(n_test);
for i in 0..n_test {
let k_star_i = K_star_m.row(i).to_owned();
let x_i = X.row(i);
let k_star_star = self.state.kernel.kernel(&x_i, &x_i);
mean[i] = k_star_i.dot(&self.state.beta);
let w_star = triangular_solve(&self.state.L_mm, &k_star_i)?;
let q_star = k_star_i.dot(&w_star);
let v_star = triangular_solve(&self.state.L_b, &k_star_i)?;
let extra = k_star_i.dot(&v_star);
variance[i] = (k_star_star - q_star + extra).max(0.0);
}
let std = variance.mapv(f64::sqrt);
Ok((mean, std))
}
pub fn log_marginal_likelihood(&self) -> f64 {
self.state.log_marginal_likelihood_value
}
pub fn inducing_points(&self) -> &Array2<f64> {
&self.state.Z
}
}
impl Default for FitcGaussianProcessRegressor<Untrained> {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[allow(non_snake_case)]
mod tests {
use super::*;
use crate::gpr::GaussianProcessRegressor;
use crate::kernels::RBF;
use approx::assert_abs_diff_eq;
use scirs2_core::ndarray::array;
fn toy_1d_data() -> (Array2<f64>, Array1<f64>) {
let X = array![
[0.0],
[0.5],
[1.0],
[1.5],
[2.0],
[2.5],
[3.0],
[3.5],
[4.0],
[4.5],
[5.0],
[5.5],
[6.0],
[6.5],
[7.0]
];
let y = X.column(0).mapv(|x: f64| x.sin() + 0.1 * (x * 3.7).cos());
(X, y)
}
#[test]
fn test_fitc_predictive_variance_nonnegative() {
let (X, y) = toy_1d_data();
let fitted = FitcGaussianProcessRegressor::new()
.kernel(Box::new(RBF::new(1.0)))
.n_inducing(5)
.noise_variance(1e-3)
.fit(&X, &y)
.expect("FITC fit should succeed on well-conditioned toy data");
let X_test = array![[0.25], [3.3], [7.0], [10.0], [-3.0]];
let (_, std) = fitted
.predict_with_std(&X_test)
.expect("FITC predict_with_std should succeed after fitting");
for &s in std.iter() {
assert!(s.is_finite(), "predictive std must be finite");
assert!(s >= 0.0, "predictive std must be non-negative");
}
assert!(
std[3] >= std[0],
"extrapolation std ({}) should not be smaller than interpolation std ({})",
std[3],
std[0]
);
}
#[test]
fn test_fitc_approaches_full_gpr_as_inducing_grows() {
let (X, y) = toy_1d_data();
let n = X.nrows();
let noise_variance = 1e-3;
let full_gpr = GaussianProcessRegressor::new()
.kernel(Box::new(RBF::new(1.0)))
.alpha(noise_variance)
.fit(&X, &y)
.expect("full GPR fit should succeed on well-conditioned toy data");
let fitc = FitcGaussianProcessRegressor::new()
.kernel(Box::new(RBF::new(1.0)))
.n_inducing(n)
.noise_variance(noise_variance)
.jitter(1e-10)
.fit(&X, &y)
.expect("FITC fit should succeed on well-conditioned toy data");
let X_test = array![[0.25], [2.2], [3.9], [5.1], [6.6]];
let (mean_full, std_full) = full_gpr
.predict_with_std(&X_test)
.expect("full GPR predict_with_std should succeed");
let (mean_fitc, std_fitc) = fitc
.predict_with_std(&X_test)
.expect("FITC predict_with_std should succeed");
for i in 0..X_test.nrows() {
assert_abs_diff_eq!(mean_full[i], mean_fitc[i], epsilon = 1e-3);
assert_abs_diff_eq!(std_full[i], std_fitc[i], epsilon = 1e-3);
}
}
#[test]
fn test_fitc_builder_defaults() {
let cfg = FitcGaussianProcessRegressorConfig::default();
assert_eq!(cfg.n_inducing, 10);
assert!(cfg.noise_variance > 0.0);
assert!(cfg.jitter > 0.0);
}
#[test]
fn test_fitc_requires_kernel() {
let (X, y) = toy_1d_data();
let result = FitcGaussianProcessRegressor::new()
.n_inducing(3)
.fit(&X, &y);
assert!(result.is_err());
}
#[test]
fn test_fitc_rejects_mismatched_shapes() {
let (X, _) = toy_1d_data();
let y_short = array![1.0, 2.0, 3.0];
let result = FitcGaussianProcessRegressor::new()
.kernel(Box::new(RBF::new(1.0)))
.fit(&X, &y_short);
assert!(result.is_err());
}
#[test]
fn test_fitc_log_marginal_likelihood_is_finite() {
let (X, y) = toy_1d_data();
let fitted = FitcGaussianProcessRegressor::new()
.kernel(Box::new(RBF::new(1.0)))
.n_inducing(6)
.noise_variance(1e-3)
.fit(&X, &y)
.expect("FITC fit should succeed on well-conditioned toy data");
assert!(fitted.log_marginal_likelihood().is_finite());
assert_eq!(fitted.inducing_points().nrows(), 6);
}
}