1use std::collections::BTreeSet;
60use std::f64::consts::PI;
61
62use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2, Axis};
63use sklears_core::{
64 error::{Result as SklResult, SklearsError},
65 traits::{Estimator, Fit, Predict, PredictProba, Untrained},
66};
67
68use crate::classification::{sigmoid, sigmoid_derivative};
69use crate::kernels::Kernel;
70use crate::sparse_gpr::InducingPointInit;
71use crate::utils;
72
73const GH10_NODES: [f64; 10] = [
76 -3.436_159_118_837_74,
77 -2.532_731_674_232_79,
78 -1.756_683_649_299_88,
79 -1.036_610_829_789_51,
80 -0.342_901_327_223_705,
81 0.342_901_327_223_705,
82 1.036_610_829_789_51,
83 1.756_683_649_299_88,
84 2.532_731_674_232_79,
85 3.436_159_118_837_74,
86];
87const GH10_WEIGHTS: [f64; 10] = [
89 7.640_432_855_232_62e-6,
90 1.343_645_746_781e-3,
91 3.387_439_445_548_1e-2,
92 2.401_386_110_823_14e-1,
93 6.108_626_337_353_25e-1,
94 6.108_626_337_353_25e-1,
95 2.401_386_110_823_14e-1,
96 3.387_439_445_548_1e-2,
97 1.343_645_746_781e-3,
98 7.640_432_855_232_62e-6,
99];
100
101const GRADIENT_CLIP_NORM: f64 = 25.0;
109
110const GRADIENT_CLIP_NORM_S: f64 = 5.0;
117
118const S_LEARNING_RATE_SCALE: f64 = 0.1;
125
126fn softplus(x: f64) -> f64 {
128 x.max(0.0) + (1.0 + (-x.abs()).exp()).ln()
129}
130
131fn log_sigmoid(x: f64) -> f64 {
133 -softplus(-x)
134}
135
136fn gauss_hermite_bernoulli_moments(mu: f64, sigma: f64, y: f64) -> (f64, f64, f64) {
142 let norm = 1.0 / PI.sqrt();
143 let mut e_log_lik = 0.0;
144 let mut e_dlog = 0.0;
145 let mut e_d2log = 0.0;
146
147 for k in 0..10 {
148 let f = mu + std::f64::consts::SQRT_2 * sigma * GH10_NODES[k];
149 let weight = GH10_WEIGHTS[k] * norm;
150
151 let log_lik = y * log_sigmoid(f) + (1.0 - y) * log_sigmoid(-f);
152 let s = sigmoid(f);
153 let dlog = y - s;
154 let d2log = -sigmoid_derivative(f);
155
156 e_log_lik += weight * log_lik;
157 e_dlog += weight * dlog;
158 e_d2log += weight * d2log;
159 }
160
161 (e_log_lik, e_dlog, e_d2log)
162}
163
164fn gauss_hermite_sigmoid_expectation(mu: f64, sigma: f64) -> f64 {
168 let norm = 1.0 / PI.sqrt();
169 let mut acc = 0.0;
170 for k in 0..10 {
171 let f = mu + std::f64::consts::SQRT_2 * sigma * GH10_NODES[k];
172 acc += GH10_WEIGHTS[k] * norm * sigmoid(f);
173 }
174 acc
175}
176
177fn clip_vector_norm(v: Array1<f64>, max_norm: f64) -> Array1<f64> {
178 let norm = v.dot(&v).sqrt();
179 if norm > max_norm && norm > 0.0 {
180 v * (max_norm / norm)
181 } else {
182 v
183 }
184}
185
186#[allow(non_snake_case)]
187fn clip_matrix_norm(M: Array2<f64>, max_norm: f64) -> Array2<f64> {
188 let norm = M.iter().map(|x| x * x).sum::<f64>().sqrt();
189 if norm > max_norm && norm > 0.0 {
190 M * (max_norm / norm)
191 } else {
192 M
193 }
194}
195
196#[derive(Debug, Clone)]
198pub struct VariationalGpcConfig {
199 pub n_inducing: usize,
201 pub inducing_init: InducingPointInit,
203 pub max_iter: usize,
205 pub learning_rate: f64,
207 pub tol: f64,
209 pub jitter: f64,
211 pub optimize_kernel: bool,
215 pub random_state: Option<u64>,
217}
218
219impl Default for VariationalGpcConfig {
220 fn default() -> Self {
221 Self {
222 n_inducing: 10,
223 inducing_init: InducingPointInit::Kmeans,
224 max_iter: 200,
225 learning_rate: 0.01,
226 tol: 1e-5,
227 jitter: 1e-6,
228 optimize_kernel: true,
229 random_state: None,
230 }
231 }
232}
233
234#[derive(Debug, Clone)]
251pub struct VariationalGaussianProcessClassifier<S = Untrained> {
252 state: S,
253 kernel: Option<Box<dyn Kernel>>,
254 config: VariationalGpcConfig,
255}
256
257#[derive(Debug, Clone)]
259#[allow(non_snake_case)]
260pub struct VariationalGpcTrained {
261 pub Z: Array2<f64>,
263 pub m: Array1<f64>,
265 pub S: Array2<f64>,
267 pub classes: Array1<i32>,
270 pub kernel: Box<dyn Kernel>,
273 pub jitter: f64,
276 pub elbo_history: Vec<f64>,
278 pub final_elbo: f64,
280}
281
282impl VariationalGaussianProcessClassifier<Untrained> {
283 pub fn new() -> Self {
285 Self {
286 state: Untrained,
287 kernel: None,
288 config: VariationalGpcConfig::default(),
289 }
290 }
291
292 pub fn kernel(mut self, kernel: Box<dyn Kernel>) -> Self {
294 self.kernel = Some(kernel);
295 self
296 }
297
298 pub fn n_inducing(mut self, n_inducing: usize) -> Self {
300 self.config.n_inducing = n_inducing;
301 self
302 }
303
304 pub fn inducing_init(mut self, inducing_init: InducingPointInit) -> Self {
306 self.config.inducing_init = inducing_init;
307 self
308 }
309
310 pub fn max_iter(mut self, max_iter: usize) -> Self {
312 self.config.max_iter = max_iter;
313 self
314 }
315
316 pub fn learning_rate(mut self, learning_rate: f64) -> Self {
318 self.config.learning_rate = learning_rate;
319 self
320 }
321
322 pub fn tol(mut self, tol: f64) -> Self {
324 self.config.tol = tol;
325 self
326 }
327
328 pub fn jitter(mut self, jitter: f64) -> Self {
330 self.config.jitter = jitter;
331 self
332 }
333
334 pub fn optimize_kernel(mut self, optimize_kernel: bool) -> Self {
336 self.config.optimize_kernel = optimize_kernel;
337 self
338 }
339
340 pub fn random_state(mut self, random_state: Option<u64>) -> Self {
342 self.config.random_state = random_state;
343 self
344 }
345}
346
347impl Estimator for VariationalGaussianProcessClassifier<Untrained> {
348 type Config = VariationalGpcConfig;
349 type Error = SklearsError;
350 type Float = f64;
351
352 fn config(&self) -> &Self::Config {
353 &self.config
354 }
355}
356
357impl Estimator for VariationalGaussianProcessClassifier<VariationalGpcTrained> {
358 type Config = VariationalGpcConfig;
359 type Error = SklearsError;
360 type Float = f64;
361
362 fn config(&self) -> &Self::Config {
363 &self.config
364 }
365}
366
367#[allow(non_snake_case)]
372fn kernel_workspace(
373 kernel: &dyn Kernel,
374 X: &Array2<f64>,
375 Z: &Array2<f64>,
376 jitter: f64,
377) -> SklResult<(Array2<f64>, Array2<f64>, Array2<f64>)> {
378 let m_ind = Z.nrows();
379 let mut Kmm = kernel.compute_kernel_matrix(Z, None)?;
380 for i in 0..m_ind {
381 Kmm[[i, i]] += jitter;
382 }
383 let L_mm = utils::robust_cholesky(&Kmm)?;
384 let Knm = kernel.compute_kernel_matrix(X, Some(Z))?;
385
386 let n = X.nrows();
387 let mut W = Array2::<f64>::zeros((n, m_ind));
388 for i in 0..n {
389 let k_i = Knm.row(i).to_owned();
390 let w_i = utils::triangular_solve(&L_mm, &k_i)?;
391 W.row_mut(i).assign(&w_i);
392 }
393
394 Ok((Kmm, Knm, W))
395}
396
397#[allow(non_snake_case)]
400fn elbo_and_gradients(
401 y: &Array1<f64>,
402 Knm: &Array2<f64>,
403 Kxx_diag: &Array1<f64>,
404 W: &Array2<f64>,
405 Kmm: &Array2<f64>,
406 m: &Array1<f64>,
407 S: &Array2<f64>,
408) -> SklResult<(f64, Array1<f64>, Array2<f64>)> {
409 let n = y.len();
410 let m_ind = m.len();
411
412 let mut data_fit = 0.0;
413 let mut grad_m = Array1::<f64>::zeros(m_ind);
414 let mut grad_S = Array2::<f64>::zeros((m_ind, m_ind));
415
416 for i in 0..n {
417 let k_i = Knm.row(i);
418 let w_i = W.row(i).to_owned();
419
420 let mu_i = w_i.dot(m);
422 let q_ii = k_i.dot(&w_i); let sw_i = S.dot(&w_i);
424 let extra_var = w_i.dot(&sw_i);
425 let sigma2_i = (Kxx_diag[i] - q_ii + extra_var).max(1e-12);
426 let sigma_i = sigma2_i.sqrt();
427
428 let (e_log_lik, e_dloglik_df, e_d2loglik_df2) =
429 gauss_hermite_bernoulli_moments(mu_i, sigma_i, y[i]);
430
431 data_fit += e_log_lik;
432
433 grad_m = &grad_m + e_dloglik_df * &w_i;
435
436 let outer_w = Array2::from_shape_fn((m_ind, m_ind), |(a, b)| w_i[a] * w_i[b]);
439 grad_S = &grad_S + (0.5 * e_d2loglik_df2) * &outer_w;
440 }
441
442 let zero_mean = Array1::<f64>::zeros(m_ind);
443 let kl = utils::kl_divergence_gaussian(m, S, &zero_mean, Kmm)?;
444
445 let kmm_inv = utils::matrix_inverse(Kmm)?;
446 let S_inv = utils::matrix_inverse(S)?;
447 let kmm_inv_m = kmm_inv.dot(m);
448
449 grad_m = &grad_m - &kmm_inv_m;
451 grad_S = &grad_S - 0.5 * (&kmm_inv - &S_inv);
452
453 let elbo = data_fit - kl;
454
455 Ok((elbo, grad_m, grad_S))
456}
457
458#[allow(non_snake_case)]
459fn elbo_value_for_kernel(
460 kernel: &dyn Kernel,
461 X: &Array2<f64>,
462 y: &Array1<f64>,
463 Z: &Array2<f64>,
464 m: &Array1<f64>,
465 S: &Array2<f64>,
466 jitter: f64,
467) -> SklResult<f64> {
468 let (Kmm, Knm, W) = kernel_workspace(kernel, X, Z, jitter)?;
469 let Kxx_diag = X
470 .axis_iter(Axis(0))
471 .map(|x| kernel.kernel(&x, &x))
472 .collect::<Array1<f64>>();
473 let (elbo, _, _) = elbo_and_gradients(y, &Knm, &Kxx_diag, &W, &Kmm, m, S)?;
474 Ok(elbo)
475}
476
477#[allow(non_snake_case)]
482fn kernel_param_gradient_fd(
483 kernel: &dyn Kernel,
484 X: &Array2<f64>,
485 y: &Array1<f64>,
486 Z: &Array2<f64>,
487 m: &Array1<f64>,
488 S: &Array2<f64>,
489 jitter: f64,
490) -> SklResult<Array1<f64>> {
491 let params = kernel.get_params();
492 if params.is_empty() {
493 return Ok(Array1::zeros(0));
494 }
495
496 let base_elbo = elbo_value_for_kernel(kernel, X, y, Z, m, S, jitter)?;
497
498 let mut grad = Array1::<f64>::zeros(params.len());
499 for i in 0..params.len() {
500 let step = 1e-6 * params[i].abs().max(1.0);
501 let mut perturbed = params.clone();
502 perturbed[i] += step;
503
504 let mut kernel_plus = kernel.clone_box();
505 kernel_plus.set_params(&perturbed)?;
506 let elbo_plus = elbo_value_for_kernel(kernel_plus.as_ref(), X, y, Z, m, S, jitter)?;
507
508 grad[i] = (elbo_plus - base_elbo) / step;
509 }
510
511 Ok(grad)
512}
513
514#[allow(non_snake_case)]
515impl Fit<ArrayView2<'_, f64>, ArrayView1<'_, i32>>
516 for VariationalGaussianProcessClassifier<Untrained>
517{
518 type Fitted = VariationalGaussianProcessClassifier<VariationalGpcTrained>;
519
520 fn fit(self, X: &ArrayView2<f64>, y: &ArrayView1<i32>) -> SklResult<Self::Fitted> {
521 if X.nrows() != y.len() {
522 return Err(SklearsError::InvalidInput(
523 "X and y must have the same number of samples".to_string(),
524 ));
525 }
526 if X.nrows() == 0 {
527 return Err(SklearsError::InvalidInput(
528 "Cannot fit VariationalGaussianProcessClassifier on empty data".to_string(),
529 ));
530 }
531
532 let mut kernel = self
533 .kernel
534 .ok_or_else(|| SklearsError::InvalidInput("Kernel must be specified".to_string()))?;
535
536 let mut classes_set: BTreeSet<i32> = BTreeSet::new();
537 for &label in y.iter() {
538 classes_set.insert(label);
539 }
540 if classes_set.len() != 2 {
541 return Err(SklearsError::InvalidInput(
542 "Variational GP classification requires exactly 2 classes".to_string(),
543 ));
544 }
545 let classes: Vec<i32> = classes_set.into_iter().collect();
546 let classes_arr = Array1::from(classes.clone());
547 let y_bernoulli: Array1<f64> = y.mapv(|label| if label == classes[0] { 0.0 } else { 1.0 });
548
549 let n = X.nrows();
550 let n_inducing = self.config.n_inducing.clamp(1, n);
551 let X_owned = X.to_owned();
552
553 let Z = match self.config.inducing_init {
554 InducingPointInit::Random => {
555 utils::random_inducing_points(X, n_inducing, self.config.random_state)?
556 }
557 InducingPointInit::Uniform => {
558 utils::uniform_inducing_points(X, n_inducing, self.config.random_state)?
559 }
560 InducingPointInit::Kmeans => {
561 utils::kmeans_inducing_points(X, n_inducing, self.config.random_state)?
562 }
563 };
564 let m_ind = Z.nrows();
565
566 let Kxx_diag = X_owned
567 .axis_iter(Axis(0))
568 .map(|x| kernel.kernel(&x, &x))
569 .collect::<Array1<f64>>();
570
571 let mut m = Array1::<f64>::zeros(m_ind);
572 let mut S = Array2::<f64>::eye(m_ind);
573
574 let mut elbo_history: Vec<f64> = Vec::with_capacity(self.config.max_iter);
575
576 for _iter in 0..self.config.max_iter {
577 let (Kmm, Knm, W) =
578 kernel_workspace(kernel.as_ref(), &X_owned, &Z, self.config.jitter)?;
579
580 let (elbo, grad_m, grad_S) =
581 elbo_and_gradients(&y_bernoulli, &Knm, &Kxx_diag, &W, &Kmm, &m, &S)?;
582
583 let converged = matches!(
584 elbo_history.last(),
585 Some(&prev) if (elbo - prev).abs() < self.config.tol
586 );
587 elbo_history.push(elbo);
588 if converged {
589 break;
590 }
591
592 if self.config.optimize_kernel {
593 let grad_kernel = kernel_param_gradient_fd(
594 kernel.as_ref(),
595 &X_owned,
596 &y_bernoulli,
597 &Z,
598 &m,
599 &S,
600 self.config.jitter,
601 )?;
602 if !grad_kernel.is_empty() {
603 let clipped = clip_vector_norm(grad_kernel, GRADIENT_CLIP_NORM);
604 let mut params = kernel.get_params();
605 for (p, g) in params.iter_mut().zip(clipped.iter()) {
606 *p += self.config.learning_rate * g;
607 if *p <= 1e-8 {
610 *p = 1e-8;
611 }
612 }
613 kernel.set_params(¶ms)?;
614 }
615 }
616
617 let grad_m_clipped = clip_vector_norm(grad_m, GRADIENT_CLIP_NORM);
618 let grad_S_clipped = clip_matrix_norm(grad_S, GRADIENT_CLIP_NORM_S);
619
620 m = &m + self.config.learning_rate * &grad_m_clipped;
621 let S_updated =
622 &S + (self.config.learning_rate * S_LEARNING_RATE_SCALE) * &grad_S_clipped;
623 let S_sym = 0.5 * (&S_updated + &S_updated.t());
624 let L_s_repair = utils::robust_cholesky(&S_sym)?;
630 S = L_s_repair.dot(&L_s_repair.t());
631 }
632
633 let final_elbo = elbo_history.last().copied().unwrap_or(f64::NEG_INFINITY);
634
635 Ok(VariationalGaussianProcessClassifier {
636 state: VariationalGpcTrained {
637 Z,
638 m,
639 S,
640 classes: classes_arr,
641 kernel,
642 jitter: self.config.jitter,
643 elbo_history,
644 final_elbo,
645 },
646 kernel: None,
647 config: self.config,
648 })
649 }
650}
651
652#[allow(non_snake_case)]
653impl VariationalGaussianProcessClassifier<VariationalGpcTrained> {
654 fn latent_moments(&self, X: &ArrayView2<f64>) -> SklResult<(Array1<f64>, Array1<f64>)> {
657 let X_owned = X.to_owned();
658 let (_, K_star_m, W) = kernel_workspace(
659 self.state.kernel.as_ref(),
660 &X_owned,
661 &self.state.Z,
662 self.state.jitter,
663 )?;
664
665 let n_test = X.nrows();
666 let mut mean = Array1::<f64>::zeros(n_test);
667 let mut var = Array1::<f64>::zeros(n_test);
668
669 for i in 0..n_test {
670 let k_i = K_star_m.row(i);
671 let w_i = W.row(i).to_owned();
672 let x_i = X_owned.row(i);
673
674 let mu_i = w_i.dot(&self.state.m);
675 let q_ii = k_i.dot(&w_i);
676 let k_ii = self.state.kernel.kernel(&x_i, &x_i);
677 let sw_i = self.state.S.dot(&w_i);
678 let extra = w_i.dot(&sw_i);
679
680 mean[i] = mu_i;
681 var[i] = (k_ii - q_ii + extra).max(1e-12);
682 }
683
684 Ok((mean, var))
685 }
686}
687
688impl VariationalGaussianProcessClassifier<VariationalGpcTrained> {
689 pub fn elbo_history(&self) -> &[f64] {
691 &self.state.elbo_history
692 }
693
694 pub fn elbo(&self) -> f64 {
696 self.state.final_elbo
697 }
698
699 pub fn inducing_points(&self) -> &Array2<f64> {
701 &self.state.Z
702 }
703
704 pub fn classes(&self) -> &Array1<i32> {
706 &self.state.classes
707 }
708}
709
710#[allow(non_snake_case)]
711impl PredictProba<ArrayView2<'_, f64>, Array1<f64>>
712 for VariationalGaussianProcessClassifier<VariationalGpcTrained>
713{
714 fn predict_proba(&self, X: &ArrayView2<f64>) -> SklResult<Array1<f64>> {
719 let (mean, var) = self.latent_moments(X)?;
720 let probs = mean
721 .iter()
722 .zip(var.iter())
723 .map(|(&mu, &v)| gauss_hermite_sigmoid_expectation(mu, v.sqrt()).clamp(0.0, 1.0))
724 .collect::<Array1<f64>>();
725 Ok(probs)
726 }
727}
728
729#[allow(non_snake_case)]
730impl Predict<ArrayView2<'_, f64>, Array1<i32>>
731 for VariationalGaussianProcessClassifier<VariationalGpcTrained>
732{
733 fn predict(&self, X: &ArrayView2<f64>) -> SklResult<Array1<i32>> {
734 let proba = self.predict_proba(X)?;
735 let preds = proba
736 .iter()
737 .map(|&p| {
738 if p >= 0.5 {
739 self.state.classes[1]
740 } else {
741 self.state.classes[0]
742 }
743 })
744 .collect::<Vec<i32>>();
745 Ok(Array1::from(preds))
746 }
747}
748
749impl Default for VariationalGaussianProcessClassifier<Untrained> {
750 fn default() -> Self {
751 Self::new()
752 }
753}
754
755pub type SparseGaussianProcessClassifier<S = Untrained> = VariationalGaussianProcessClassifier<S>;
763pub type SgpcTrained = VariationalGpcTrained;
765
766#[cfg(test)]
767#[allow(non_snake_case)]
768mod tests {
769 use super::*;
770 use crate::kernels::RBF;
771 use scirs2_core::ndarray::array;
772
773 fn linearly_separable_2class_data() -> (Array2<f64>, Array1<i32>) {
774 let X = array![
775 [-2.0, -2.0],
776 [-2.5, -1.5],
777 [-1.5, -2.5],
778 [-3.0, -2.0],
779 [-2.0, -3.0],
780 [-2.2, -1.8],
781 [-1.8, -2.2],
782 [-2.7, -2.3],
783 [-1.6, -1.9],
784 [-2.4, -2.6],
785 [2.0, 2.0],
786 [2.5, 1.5],
787 [1.5, 2.5],
788 [3.0, 2.0],
789 [2.0, 3.0],
790 [2.2, 1.8],
791 [1.8, 2.2],
792 [2.7, 2.3],
793 [1.6, 1.9],
794 [2.4, 2.6],
795 ];
796 let y = array![0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1];
797 (X, y)
798 }
799
800 #[test]
801 fn test_variational_gpc_elbo_overall_increases() {
802 let (X, y) = linearly_separable_2class_data();
803 let fitted = VariationalGaussianProcessClassifier::new()
804 .kernel(Box::new(RBF::new(1.5)))
805 .n_inducing(8)
806 .max_iter(200)
807 .learning_rate(0.01)
808 .optimize_kernel(false)
809 .random_state(Some(0))
810 .fit(&X.view(), &y.view())
811 .expect("variational GPC fit should succeed on separable toy data");
812
813 let history = fitted.elbo_history();
814 assert!(
815 history.len() >= 2,
816 "expected multiple ELBO iterations to be recorded, got {}",
817 history.len()
818 );
819 let first = history[0];
820 let last = *history.last().expect("history is non-empty");
821 assert!(
822 first.is_finite() && last.is_finite(),
823 "ELBO values must be finite"
824 );
825 assert!(
826 last > first,
827 "ELBO should increase overall across optimization: first={first}, last={last}"
828 );
829 }
830
831 #[test]
832 fn test_variational_gpc_training_accuracy() {
833 let (X, y) = linearly_separable_2class_data();
834 let fitted = VariationalGaussianProcessClassifier::new()
835 .kernel(Box::new(RBF::new(1.5)))
836 .n_inducing(8)
837 .max_iter(300)
838 .learning_rate(0.01)
839 .random_state(Some(0))
840 .fit(&X.view(), &y.view())
841 .expect("variational GPC fit should succeed on separable toy data");
842
843 let preds = fitted
844 .predict(&X.view())
845 .expect("predict should succeed after fitting");
846
847 let correct = preds.iter().zip(y.iter()).filter(|(&p, &t)| p == t).count();
848 let accuracy = correct as f64 / y.len() as f64;
849 assert!(
850 accuracy > 0.9,
851 "expected training accuracy > 0.9 on a well-separated toy set, got {accuracy}"
852 );
853 }
854
855 #[test]
856 fn test_variational_gpc_predict_proba_range() {
857 let (X, y) = linearly_separable_2class_data();
858 let fitted = VariationalGaussianProcessClassifier::new()
859 .kernel(Box::new(RBF::new(1.5)))
860 .n_inducing(8)
861 .max_iter(50)
862 .random_state(Some(0))
863 .fit(&X.view(), &y.view())
864 .expect("variational GPC fit should succeed on separable toy data");
865
866 let proba = fitted
867 .predict_proba(&X.view())
868 .expect("predict_proba should succeed after fitting");
869 for &p in proba.iter() {
870 assert!((0.0..=1.0).contains(&p), "probability out of range: {p}");
871 }
872 }
873
874 #[test]
875 fn test_variational_gpc_requires_two_classes() {
876 let X = array![[0.0, 0.0], [1.0, 1.0], [2.0, 2.0]];
877 let y = array![0, 0, 0];
878 let result = VariationalGaussianProcessClassifier::new()
879 .kernel(Box::new(RBF::new(1.0)))
880 .fit(&X.view(), &y.view());
881 assert!(result.is_err());
882 }
883
884 #[test]
885 fn test_variational_gpc_requires_kernel() {
886 let (X, y) = linearly_separable_2class_data();
887 let result = VariationalGaussianProcessClassifier::new().fit(&X.view(), &y.view());
888 assert!(result.is_err());
889 }
890
891 #[test]
892 fn test_sparse_gpc_alias_matches_variational() {
893 let (X, y) = linearly_separable_2class_data();
894 let fitted = SparseGaussianProcessClassifier::new()
895 .kernel(Box::new(RBF::new(1.5)))
896 .n_inducing(8)
897 .max_iter(20)
898 .random_state(Some(0))
899 .fit(&X.view(), &y.view())
900 .expect("alias type should fit identically to VariationalGaussianProcessClassifier");
901 assert!(fitted.elbo().is_finite());
902 }
903
904 #[test]
905 fn test_gauss_hermite_bernoulli_moments_sane() {
906 let (e_log_lik, _, _) = gauss_hermite_bernoulli_moments(0.0, 1e-6, 1.0);
908 assert!((e_log_lik - (0.5_f64).ln()).abs() < 1e-3);
909 }
910}