Skip to main content

qualia_core_db/solvers/learning/gaussian_process/
mod.rs

1//! Gaussian Process regression (PRML ch 6.4) — a nonparametric Bayesian regressor
2//! that returns a full predictive distribution `(mean, variance)` at every input,
3//! with the squared-exponential (RBF) kernel. The training solve reuses
4//! `linear_algebra::cholesky` (no new solver). Kernel-class `DenseLinear` (the
5//! `n×n` kernel solve) + `AllPairs` (the kernel evaluations).
6//!
7//! Given training `(X, y)` and noise variance `σ²ₙ`:
8//! `mean(x*) = k*ᵀ (K + σ²ₙI)⁻¹ y`,
9//! `var(x*)  = k(x*,x*) − k*ᵀ (K + σ²ₙI)⁻¹ k*`,
10//! the calibrated uncertainty that collapses near training points and widens away
11//! from them.
12
13use crate::solvers::learning::LearningError;
14use crate::solvers::linear_algebra::cholesky::{cholesky_factor, cholesky_solve};
15
16/// A fitted GP regressor (squared-exponential kernel).
17#[derive(Debug, Clone)]
18pub struct GpRegressor {
19    x_train: Vec<f64>,
20    alpha: Vec<f64>, // (K + σ²ₙI)⁻¹ y
21    l: Vec<f64>,     // Cholesky factor of (K + σ²ₙI), n×n
22    length_scale: f64,
23    signal_var: f64,
24    noise_var: f64,
25    n: usize,
26    p: usize,
27}
28
29/// Squared-exponential kernel `σ²_f · exp(−‖a−b‖² / (2ℓ²))`.
30fn rbf(a: &[f64], b: &[f64], length_scale: f64, signal_var: f64) -> f64 {
31    let d2: f64 = a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum();
32    signal_var * (-d2 / (2.0 * length_scale * length_scale)).exp()
33}
34
35impl GpRegressor {
36    /// Fit a GP with squared-exponential kernel (`length_scale ℓ`, `signal_var σ²_f`)
37    /// and Gaussian noise `noise_var σ²ₙ`. Fails closed on shape mismatch /
38    /// non-positive hyper-parameters / a non-PD kernel matrix.
39    pub fn fit(
40        x: &[f64],
41        y: &[f64],
42        n: usize,
43        p: usize,
44        length_scale: f64,
45        signal_var: f64,
46        noise_var: f64,
47    ) -> Result<Self, LearningError> {
48        if n == 0 || p == 0 || x.len() != n * p || y.len() != n {
49            return Err(LearningError::InvalidDimension);
50        }
51        if !(length_scale > 0.0) || !(signal_var > 0.0) || !(noise_var > 0.0) {
52            return Err(LearningError::InsufficientData);
53        }
54
55        // K + σ²ₙ I.
56        let mut k = vec![0.0; n * n];
57        for i in 0..n {
58            for j in i..n {
59                let v = rbf(
60                    &x[i * p..(i + 1) * p],
61                    &x[j * p..(j + 1) * p],
62                    length_scale,
63                    signal_var,
64                );
65                k[i * n + j] = v;
66                k[j * n + i] = v;
67            }
68            k[i * n + i] += noise_var;
69        }
70
71        let mut l = vec![0.0; n * n];
72        cholesky_factor(n, &k, &mut l).map_err(|_| LearningError::Singular)?;
73        let mut alpha = vec![0.0; n];
74        cholesky_solve(n, &l, y, &mut alpha)?;
75
76        Ok(Self {
77            x_train: x.to_vec(),
78            alpha,
79            l,
80            length_scale,
81            signal_var,
82            noise_var,
83            n,
84            p,
85        })
86    }
87
88    /// Training noise variance σ²ₙ used when fitting the regressor.
89    pub fn noise_variance(&self) -> f64 {
90        self.noise_var
91    }
92
93    /// Predictive distribution at one input: `(mean, variance)`. The variance
94    /// includes the model uncertainty (small near training data, large away from it).
95    pub fn predict_row(&self, x_star: &[f64]) -> (f64, f64) {
96        // k* = [k(x*, xᵢ)].
97        let mut kstar = vec![0.0; self.n];
98        for i in 0..self.n {
99            kstar[i] = rbf(
100                &self.x_train[i * self.p..(i + 1) * self.p],
101                x_star,
102                self.length_scale,
103                self.signal_var,
104            );
105        }
106        let mean: f64 = kstar.iter().zip(&self.alpha).map(|(k, a)| k * a).sum();
107        // var = k(x*,x*) − k*ᵀ (K+σ²I)⁻¹ k*.
108        let mut v = vec![0.0; self.n];
109        let _ = cholesky_solve(self.n, &self.l, &kstar, &mut v);
110        let reduction: f64 = kstar.iter().zip(&v).map(|(k, vi)| k * vi).sum();
111        let kxx = self.signal_var; // k(x*,x*) for the SE kernel
112        let floor = self.noise_var;
113        (mean, (kxx - reduction).max(floor))
114    }
115
116    /// Predictive `(mean, variance)` for each row of a row-major `m × p` matrix.
117    pub fn predict(&self, x: &[f64], m: usize) -> Vec<(f64, f64)> {
118        (0..m)
119            .map(|i| self.predict_row(&x[i * self.p..(i + 1) * self.p]))
120            .collect()
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn interpolates_training_points_with_low_variance() {
130        // Low noise → the GP nearly interpolates the training targets, with small
131        // predictive variance there.
132        let x: Vec<f64> = (0..8).map(|i| i as f64).collect();
133        let y: Vec<f64> = x.iter().map(|&xi| (xi * 0.7).sin()).collect();
134        let gp = GpRegressor::fit(&x, &y, 8, 1, 1.0, 1.0, 1e-6).unwrap();
135        for i in 0..8 {
136            let (m, v) = gp.predict_row(&[x[i]]);
137            assert!(
138                (m - y[i]).abs() < 1e-2,
139                "mean at train point {i}: {m} vs {}",
140                y[i]
141            );
142            assert!(v < 1e-2, "variance at train point should be small: {v}");
143        }
144    }
145
146    #[test]
147    fn variance_grows_far_from_data() {
148        let x: Vec<f64> = (0..6).map(|i| i as f64).collect();
149        let y: Vec<f64> = x.iter().map(|&xi| 0.5 * xi).collect();
150        let gp = GpRegressor::fit(&x, &y, 6, 1, 1.0, 1.0, 1e-4).unwrap();
151        let (_, v_near) = gp.predict_row(&[2.5]); // within the data
152        let (_, v_far) = gp.predict_row(&[50.0]); // far away
153        assert!(
154            v_far > v_near,
155            "var_far {v_far} should exceed var_near {v_near}"
156        );
157        // Far from all data the predictive variance tends to the prior signal var.
158        assert!((v_far - 1.0).abs() < 1e-3);
159    }
160
161    #[test]
162    fn predicts_a_smooth_function_between_points() {
163        // A GP on y=sin(x) predicts a sensible value between training points.
164        let x: Vec<f64> = (0..13).map(|i| i as f64 * 0.5).collect();
165        let y: Vec<f64> = x.iter().map(|&xi| xi.sin()).collect();
166        let gp = GpRegressor::fit(&x, &y, 13, 1, 1.0, 1.0, 1e-6).unwrap();
167        let (m, _) = gp.predict_row(&[1.25]); // between 1.0 and 1.5
168        assert!(
169            (m - (1.25f64).sin()).abs() < 0.1,
170            "interp {m} vs {}",
171            (1.25f64).sin()
172        );
173    }
174
175    #[test]
176    fn guards() {
177        assert_eq!(
178            GpRegressor::fit(&[1.0, 2.0], &[1.0], 2, 1, 1.0, 1.0, 1e-3).unwrap_err(),
179            LearningError::InvalidDimension
180        );
181        assert_eq!(
182            GpRegressor::fit(&[1.0, 2.0], &[1.0, 2.0], 2, 1, 0.0, 1.0, 1e-3).unwrap_err(),
183            LearningError::InsufficientData
184        );
185    }
186}