Skip to main content

uncertain_numerics/
kernel.rs

1//! Covariance kernels for one-dimensional probabilistic numerical methods.
2
3use crate::KernelError;
4
5/// Contract for a scalar covariance kernel.
6///
7/// Implementations are expected to represent symmetric positive-semidefinite
8/// kernels on finite scalar inputs. The trait deliberately exposes only pointwise
9/// covariance evaluation; matrix construction belongs to a later numerical layer.
10pub trait ScalarKernel {
11    /// Evaluate the covariance `k(x, y)`.
12    ///
13    /// Callers should provide finite coordinates. Implementations may propagate
14    /// IEEE-754 non-finite values when supplied non-finite inputs.
15    #[must_use]
16    fn covariance(&self, x: f64, y: f64) -> f64;
17}
18
19/// Squared-exponential (radial-basis-function) covariance kernel.
20///
21/// The parameterization is
22///
23/// ```text
24/// k(x, y) = signal_variance * exp(-0.5 * ((x - y) / length_scale)^2),
25/// ```
26///
27/// with strictly positive finite signal variance and length scale.
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct RbfKernel {
30    signal_variance: f64,
31    length_scale: f64,
32}
33
34impl RbfKernel {
35    /// Construct an RBF kernel with validated hyperparameters.
36    ///
37    /// # Errors
38    ///
39    /// Returns [`KernelError`] when either parameter is non-finite or not
40    /// strictly positive.
41    pub fn new(signal_variance: f64, length_scale: f64) -> Result<Self, KernelError> {
42        if !signal_variance.is_finite() {
43            return Err(KernelError::NonFiniteSignalVariance);
44        }
45        if signal_variance <= 0.0 {
46            return Err(KernelError::NonPositiveSignalVariance);
47        }
48        if !length_scale.is_finite() {
49            return Err(KernelError::NonFiniteLengthScale);
50        }
51        if length_scale <= 0.0 {
52            return Err(KernelError::NonPositiveLengthScale);
53        }
54
55        Ok(Self {
56            signal_variance,
57            length_scale,
58        })
59    }
60
61    /// Return the signal variance \(\sigma^2\).
62    #[must_use]
63    pub const fn signal_variance(&self) -> f64 {
64        self.signal_variance
65    }
66
67    /// Return the length scale \(\ell\).
68    #[must_use]
69    pub const fn length_scale(&self) -> f64 {
70        self.length_scale
71    }
72}
73
74impl ScalarKernel for RbfKernel {
75    fn covariance(&self, x: f64, y: f64) -> f64 {
76        let scaled_distance = (x - y) / self.length_scale;
77        self.signal_variance * (-0.5 * scaled_distance * scaled_distance).exp()
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::{RbfKernel, ScalarKernel};
84    use crate::KernelError;
85
86    const TOLERANCE: f64 = 1.0e-12;
87
88    fn assert_close(actual: f64, expected: f64) {
89        let scale = expected.abs().max(1.0);
90        assert!(
91            (actual - expected).abs() <= TOLERANCE * scale,
92            "expected {expected:.16e}, got {actual:.16e}"
93        );
94    }
95
96    #[test]
97    fn rejects_invalid_signal_variance() {
98        assert_eq!(
99            RbfKernel::new(f64::NAN, 1.0),
100            Err(KernelError::NonFiniteSignalVariance)
101        );
102        assert_eq!(
103            RbfKernel::new(f64::INFINITY, 1.0),
104            Err(KernelError::NonFiniteSignalVariance)
105        );
106        assert_eq!(
107            RbfKernel::new(0.0, 1.0),
108            Err(KernelError::NonPositiveSignalVariance)
109        );
110        assert_eq!(
111            RbfKernel::new(-1.0, 1.0),
112            Err(KernelError::NonPositiveSignalVariance)
113        );
114    }
115
116    #[test]
117    fn rejects_invalid_length_scale() {
118        assert_eq!(
119            RbfKernel::new(1.0, f64::NAN),
120            Err(KernelError::NonFiniteLengthScale)
121        );
122        assert_eq!(
123            RbfKernel::new(1.0, f64::INFINITY),
124            Err(KernelError::NonFiniteLengthScale)
125        );
126        assert_eq!(
127            RbfKernel::new(1.0, 0.0),
128            Err(KernelError::NonPositiveLengthScale)
129        );
130        assert_eq!(
131            RbfKernel::new(1.0, -1.0),
132            Err(KernelError::NonPositiveLengthScale)
133        );
134    }
135
136    #[test]
137    fn exposes_validated_parameters() {
138        let kernel = RbfKernel::new(2.5, 0.75).expect("parameters are valid");
139
140        assert_close(kernel.signal_variance(), 2.5);
141        assert_close(kernel.length_scale(), 0.75);
142    }
143
144    #[test]
145    fn diagonal_equals_signal_variance() {
146        let kernel = RbfKernel::new(3.0, 1.4).expect("parameters are valid");
147
148        for x in [-10.0, -1.0, 0.0, 0.5, 12.0] {
149            assert_close(kernel.covariance(x, x), 3.0);
150        }
151    }
152
153    #[test]
154    fn covariance_is_symmetric() {
155        let kernel = RbfKernel::new(1.7, 0.9).expect("parameters are valid");
156
157        for (x, y) in [(-3.0, 0.5), (-0.2, 4.1), (1.0, 1.0), (8.0, -2.0)] {
158            assert_close(kernel.covariance(x, y), kernel.covariance(y, x));
159        }
160    }
161
162    #[test]
163    fn covariance_is_stationary() {
164        let kernel = RbfKernel::new(1.2, 2.3).expect("parameters are valid");
165        let shift = 17.0;
166
167        assert_close(
168            kernel.covariance(-1.5, 3.25),
169            kernel.covariance(-1.5 + shift, 3.25 + shift),
170        );
171    }
172
173    #[test]
174    fn covariance_decays_with_distance() {
175        let kernel = RbfKernel::new(2.0, 1.0).expect("parameters are valid");
176        let at_zero = kernel.covariance(0.0, 0.0);
177        let at_one = kernel.covariance(0.0, 1.0);
178        let at_two = kernel.covariance(0.0, 2.0);
179
180        assert!(at_zero > at_one);
181        assert!(at_one > at_two);
182        assert!(at_two > 0.0);
183    }
184
185    #[test]
186    fn representative_gram_matrix_is_positive_semidefinite() {
187        let kernel = RbfKernel::new(1.3, 0.8).expect("parameters are valid");
188        let points = [-1.0, -0.25, 0.5, 2.0];
189        let coefficient_sets = [
190            [1.0, 0.0, 0.0, 0.0],
191            [1.0, -1.0, 0.5, 0.25],
192            [-2.0, 0.75, 1.25, -0.5],
193            [1.0, 1.0, 1.0, 1.0],
194        ];
195
196        for coefficients in coefficient_sets {
197            let quadratic_form = coefficients
198                .iter()
199                .enumerate()
200                .map(|(i, &left)| {
201                    coefficients
202                        .iter()
203                        .enumerate()
204                        .map(|(j, &right)| left * kernel.covariance(points[i], points[j]) * right)
205                        .sum::<f64>()
206                })
207                .sum::<f64>();
208
209            assert!(
210                quadratic_form >= -TOLERANCE,
211                "quadratic form should be non-negative, got {quadratic_form:.16e}"
212            );
213        }
214    }
215}