Skip to main content

sklears_gaussian_process/
variational.rs

1//! Variational Gaussian Process classification
2//!
3//! This module implements Stochastic Variational Gaussian Process
4//! Classification (SVGP-C; Hensman, Matthews & Ghahramani, 2015) with a
5//! Bernoulli-logit likelihood. It follows the same sparse-variational
6//! machinery as [`crate::regression::VariationalSparseGaussianProcessRegressor`]
7//! (a set of inducing points `Z` with a variational posterior `q(u) = N(m, S)`
8//! over the inducing outputs `u = f(Z)`), but swaps the Gaussian likelihood
9//! for a non-conjugate Bernoulli one.
10//!
11//! # Model
12//!
13//! ```text
14//! p(u)       = N(0, K_mm)
15//! q(u)       = N(m, S)                                    (variational posterior)
16//! p(f_i | u) = N(k_i^T K_mm⁻¹ u,  K_ii - k_i^T K_mm⁻¹ k_i) (GP conditional)
17//! p(y_i|f_i) = Bernoulli(sigmoid(f_i))
18//! ```
19//!
20//! Marginalizing `u` out of `p(f_i | u)` under `q(u)` gives
21//! `q(f_i) = N(mu_i, sigma_i²)` with, writing `w_i = K_mm⁻¹ k_i`:
22//!
23//! ```text
24//! mu_i     = w_i · m
25//! sigma_i² = K_ii - k_i · w_i + w_i^T S w_i
26//! ```
27//!
28//! The evidence lower bound maximized during training is
29//!
30//! ```text
31//! ELBO = Σ_i E_{q(f_i)}[log p(y_i | f_i)]  -  KL(q(u) || p(u))
32//! ```
33//!
34//! `KL(q(u) || p(u))` is the standard multivariate-Gaussian KL divergence
35//! (computed via [`crate::utils::kl_divergence_gaussian`]). The likelihood
36//! term is non-conjugate (`f_i` enters through a sigmoid), so its expectation
37//! and the gradients needed to optimize `m` and `S` are estimated with
38//! 10-point Gauss-Hermite quadrature, using two classical identities for
39//! Gaussian expectations (Bonnet's and Price's theorems):
40//!
41//! ```text
42//! d/dmu    E_{N(mu,sigma²)}[g(f)] = E[g'(f)]
43//! d/dsigma² E_{N(mu,sigma²)}[g(f)] = 0.5 * E[g''(f)]
44//! ```
45//!
46//! with `g(f) = log p(y|f)`, so `g'(f) = y - sigmoid(f)` and
47//! `g''(f) = -sigmoid'(f)` (both already available from
48//! [`crate::classification::sigmoid`] / [`crate::classification::sigmoid_derivative`]).
49//! Chaining these through `mu_i = w_i · m` and `sigma_i² = ... + w_i^T S w_i`
50//! gives closed-form (quadrature-estimated) gradients of the ELBO with
51//! respect to `m` and `S`, which are optimized by plain gradient ascent
52//! (mirroring `VariationalSparseGaussianProcessRegressor`'s optimizer). Kernel
53//! hyperparameters are, optionally, also nudged by gradient ascent using a
54//! finite-difference estimate of the ELBO gradient (the same technique
55//! [`crate::kernel_trait::Kernel::compute_kernel_gradient`] and
56//! [`crate::marginal_likelihood::MarginalLikelihoodOptimizer`] already use
57//! elsewhere in this crate). Inducing point *locations* `Z` are fixed after
58//! initialization, exactly as in `VariationalSparseGaussianProcessRegressor`.
59use 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
73/// Physicists'-convention 10-point Gauss-Hermite nodes: for
74/// `∫ e^{-x²} h(x) dx ≈ Σ weight_k * h(node_k)`.
75const 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];
87/// Weights matching [`GH10_NODES`].
88const 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
101/// Generous cap on the Euclidean norm of the raw `m` (and kernel-parameter)
102/// gradients before they are scaled by the learning rate. `q(f_i)`'s mean and
103/// variance can be far from calibrated early in training (`S` starts at the
104/// identity), which can otherwise produce a very large first step; clipping
105/// is a standard, correctness-preserving optimization safeguard (it does not
106/// change any fixed point of the ascent, only how quickly early steps
107/// approach one).
108const GRADIENT_CLIP_NORM: f64 = 25.0;
109
110/// Tighter Frobenius-norm cap for the raw `S` gradient. The KL term
111/// contributes a `-0.5 S⁻¹` component whose magnitude grows without bound as
112/// `S` approaches singularity, which makes *plain* (non-natural) gradient
113/// ascent directly on the covariance matrix prone to a runaway
114/// shrink-and-invert feedback loop; a tighter clip than `m`'s keeps each step
115/// small enough that the PSD repair below stays within reach.
116const GRADIENT_CLIP_NORM_S: f64 = 5.0;
117
118/// `S` is updated with `learning_rate * S_LEARNING_RATE_SCALE` rather than
119/// the raw `learning_rate`: the covariance update is intrinsically more
120/// sensitive than the mean update (small negative moves compound through
121/// `S⁻¹` in the next iteration's KL gradient), so damping it relative to `m`
122/// keeps the ascent stable without changing the (m, S) fixed point that
123/// maximizes the ELBO.
124const S_LEARNING_RATE_SCALE: f64 = 0.1;
125
126/// Numerically stable softplus: `log(1 + exp(x))`.
127fn softplus(x: f64) -> f64 {
128    x.max(0.0) + (1.0 + (-x.abs()).exp()).ln()
129}
130
131/// Numerically stable log-sigmoid: `log(sigmoid(x))`.
132fn log_sigmoid(x: f64) -> f64 {
133    -softplus(-x)
134}
135
136/// Gauss-Hermite estimates of `E_q[log p(y|f)]`, `E_q[d/df log p(y|f)]` and
137/// `E_q[d²/df² log p(y|f)]` under `q(f) = N(mu, sigma²)`, for the
138/// Bernoulli-logit likelihood `p(y=1|f) = sigmoid(f)` (`y` given as `0.0` or
139/// `1.0`). These three moments are exactly what both the ELBO value and its
140/// gradients w.r.t. `mu` and `sigma²` need (see module docs).
141fn 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
164/// Gauss-Hermite estimate of `E_q[sigmoid(f)]` under `q(f) = N(mu, sigma²)`,
165/// used for a calibrated `predict_proba` (integrating the sigmoid over the
166/// posterior rather than evaluating it only at the mean).
167fn 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/// Configuration for [`VariationalGaussianProcessClassifier`].
197#[derive(Debug, Clone)]
198pub struct VariationalGpcConfig {
199    /// Number of inducing points.
200    pub n_inducing: usize,
201    /// Strategy used to place the inducing points (fixed after init).
202    pub inducing_init: InducingPointInit,
203    /// Maximum number of gradient-ascent iterations on the ELBO.
204    pub max_iter: usize,
205    /// Learning rate for the (plain) gradient ascent updates.
206    pub learning_rate: f64,
207    /// Convergence tolerance on successive ELBO values.
208    pub tol: f64,
209    /// Diagonal jitter added to `K_mm` before its Cholesky factorization.
210    pub jitter: f64,
211    /// Whether to also optimize kernel hyperparameters (via a
212    /// finite-difference ELBO gradient) alongside the variational
213    /// parameters.
214    pub optimize_kernel: bool,
215    /// Random seed controlling stochastic inducing-point initialization.
216    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/// Sparse variational Gaussian Process classifier with a Bernoulli-logit
235/// likelihood (SVGP-C).
236///
237/// # Examples
238///
239/// ```ignore
240/// let X = array![[-2.0, -2.0], [-2.5, -1.5], [2.0, 2.0], [2.5, 1.5]];
241/// let y = array![0, 0, 1, 1];
242///
243/// let kernel = RBF::new(1.5);
244/// let vgpc = VariationalGaussianProcessClassifier::new()
245///     .kernel(Box::new(kernel))
246///     .n_inducing(4);
247/// let fitted = vgpc.fit(&X.view(), &y.view()).expect("fit should succeed with valid training data");
248/// let predictions = fitted.predict(&X.view()).expect("predict should succeed on trained model");
249/// ```
250#[derive(Debug, Clone)]
251pub struct VariationalGaussianProcessClassifier<S = Untrained> {
252    state: S,
253    kernel: Option<Box<dyn Kernel>>,
254    config: VariationalGpcConfig,
255}
256
257/// Trained state for [`VariationalGaussianProcessClassifier`].
258#[derive(Debug, Clone)]
259#[allow(non_snake_case)]
260pub struct VariationalGpcTrained {
261    /// Inducing point locations.
262    pub Z: Array2<f64>,
263    /// Variational mean over the inducing outputs `u`.
264    pub m: Array1<f64>,
265    /// Variational covariance over the inducing outputs `u`.
266    pub S: Array2<f64>,
267    /// Sorted unique class labels seen during training; `classes[1]` is the
268    /// "positive" class used by `predict_proba`.
269    pub classes: Array1<i32>,
270    /// Kernel function (with whatever hyperparameters it had at the end of
271    /// fitting).
272    pub kernel: Box<dyn Kernel>,
273    /// Diagonal jitter used for `K_mm` during fitting (reused at predict
274    /// time for consistency).
275    pub jitter: f64,
276    /// ELBO value recorded at every iteration.
277    pub elbo_history: Vec<f64>,
278    /// Final ELBO value.
279    pub final_elbo: f64,
280}
281
282impl VariationalGaussianProcessClassifier<Untrained> {
283    /// Create a new `VariationalGaussianProcessClassifier` instance.
284    pub fn new() -> Self {
285        Self {
286            state: Untrained,
287            kernel: None,
288            config: VariationalGpcConfig::default(),
289        }
290    }
291
292    /// Set the kernel function.
293    pub fn kernel(mut self, kernel: Box<dyn Kernel>) -> Self {
294        self.kernel = Some(kernel);
295        self
296    }
297
298    /// Set the number of inducing points.
299    pub fn n_inducing(mut self, n_inducing: usize) -> Self {
300        self.config.n_inducing = n_inducing;
301        self
302    }
303
304    /// Set the inducing point initialization method.
305    pub fn inducing_init(mut self, inducing_init: InducingPointInit) -> Self {
306        self.config.inducing_init = inducing_init;
307        self
308    }
309
310    /// Set the maximum number of gradient-ascent iterations.
311    pub fn max_iter(mut self, max_iter: usize) -> Self {
312        self.config.max_iter = max_iter;
313        self
314    }
315
316    /// Set the learning rate.
317    pub fn learning_rate(mut self, learning_rate: f64) -> Self {
318        self.config.learning_rate = learning_rate;
319        self
320    }
321
322    /// Set the convergence tolerance.
323    pub fn tol(mut self, tol: f64) -> Self {
324        self.config.tol = tol;
325        self
326    }
327
328    /// Set the diagonal jitter added to `K_mm`.
329    pub fn jitter(mut self, jitter: f64) -> Self {
330        self.config.jitter = jitter;
331        self
332    }
333
334    /// Set whether kernel hyperparameters are optimized alongside `m`/`S`.
335    pub fn optimize_kernel(mut self, optimize_kernel: bool) -> Self {
336        self.config.optimize_kernel = optimize_kernel;
337        self
338    }
339
340    /// Set the random state.
341    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/// Compute `K_mm` (jittered, with its Cholesky factor consumed internally),
368/// the cross kernel matrix between `X` and `Z`, and, for every row `i`,
369/// `w_i = K_mm⁻¹ k_i` (stacked as the rows of `W`). Used both for training
370/// (with `X` = training inputs) and prediction (with `X` = test inputs).
371#[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/// ELBO value and its gradients w.r.t. `m` and `S`, for the Bernoulli-logit
398/// SVGP model described in the module docs.
399#[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        // q(f_i) = N(mu_i, sigma_i^2); see module docs for the derivation.
421        let mu_i = w_i.dot(m);
422        let q_ii = k_i.dot(&w_i); // = k_i^T Kmm^{-1} k_i, a single inverse power
423        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        // Chain rule: mu_i = w_i . m  =>  d mu_i/dm = w_i.
434        grad_m = &grad_m + e_dloglik_df * &w_i;
435
436        // sigma_i^2 = ... + w_i^T S w_i  =>  d sigma_i^2/dS = w_i w_i^T, and
437        // (Price's theorem) d E[g(f)]/d sigma^2 = 0.5 * E[g''(f)].
438        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    // d KL/dm = Kmm^{-1} m ; d KL/dS = 0.5 (Kmm^{-1} - S^{-1})
450    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/// Finite-difference estimate of `d ELBO / d kernel_params`, using the same
478/// technique already used elsewhere in this crate for kernel gradients (see
479/// [`crate::kernel_trait::Kernel::compute_kernel_gradient`] and
480/// [`crate::marginal_likelihood::MarginalLikelihoodOptimizer`]).
481#[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                        // Kernel hyperparameters (length scales, variances, ...)
608                        // must stay strictly positive.
609                        if *p <= 1e-8 {
610                            *p = 1e-8;
611                        }
612                    }
613                    kernel.set_params(&params)?;
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            // Repair to a strictly PSD matrix via `robust_cholesky`'s
625            // escalating-jitter search (far more robust than a single fixed
626            // regularization attempt), then reconstruct S = L L^T so the
627            // result is *exactly* PSD by construction rather than merely
628            // "hopefully regularized enough".
629            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    /// `q(f*)` mean/variance at test points, computed the same way as the
655    /// per-datum `q(f_i)` used during training.
656    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    /// Get the ELBO history recorded during optimization.
690    pub fn elbo_history(&self) -> &[f64] {
691        &self.state.elbo_history
692    }
693
694    /// Get the final ELBO value.
695    pub fn elbo(&self) -> f64 {
696        self.state.final_elbo
697    }
698
699    /// Get the inducing points.
700    pub fn inducing_points(&self) -> &Array2<f64> {
701        &self.state.Z
702    }
703
704    /// Get the sorted unique class labels seen during training.
705    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    /// Predict `P(y = classes()[1] | x)`, estimated by Gauss-Hermite
715    /// integration of `sigmoid` over the latent posterior `q(f*)` (a more
716    /// calibrated estimate than evaluating `sigmoid` at the posterior mean
717    /// alone).
718    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
755/// `SparseGaussianProcessClassifier` is the same sparse-variational-GP
756/// classifier as [`VariationalGaussianProcessClassifier`]: the SVGP
757/// formulation implemented in this module already performs the full
758/// variational treatment needed for sparse GP classification (an
759/// inducing-point posterior `q(u)` with a non-conjugate likelihood handled
760/// via Gauss-Hermite quadrature), so a separate, simpler approximation would
761/// not add anything — this is a thin alias rather than duplicated logic.
762pub type SparseGaussianProcessClassifier<S = Untrained> = VariationalGaussianProcessClassifier<S>;
763/// Trained state for [`SparseGaussianProcessClassifier`].
764pub 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        // At mu=0, sigma->0, E[log p(y|f)] should approach log(0.5).
907        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}