use crate::utils::gemm::{gemm, gram};
use faer::linalg::matmul::triangular::BlockStructure;
use faer::linalg::triangular_solve::{
solve_lower_triangular_in_place, solve_upper_triangular_in_place,
};
use faer::{Accum, Mat, MatRef, Par, Side};
use rayon::prelude::*;
use crate::ml::gp::kernels::{fill_matern52_cross_1d, fill_matern52_cross_1d_par};
use crate::prelude::*;
use crate::utils::faer_parallelism;
const GP_DEFAULT_CHUNK: usize = 2048;
const GP_MIN_LANDMARKS: usize = 2;
#[derive(Clone, Copy, Debug)]
pub struct LandmarkGpParams {
pub length_scale: f64,
pub sigma: f64,
pub jitter: f64,
pub max_jitter_retries: usize,
pub chunk_size: usize,
}
impl LandmarkGpParams {
pub fn new(
length_scale: f64,
sigma: f64,
jitter: f64,
max_jitter_retries: usize,
chunk_size: usize,
) -> Self {
Self {
length_scale,
sigma,
jitter,
max_jitter_retries,
chunk_size,
}
}
}
impl Default for LandmarkGpParams {
fn default() -> Self {
Self {
length_scale: 1.0,
sigma: 1.0,
jitter: 1e-6,
max_jitter_retries: 3,
chunk_size: GP_DEFAULT_CHUNK,
}
}
}
fn validate_inputs<T>(
x: &[f64],
y: MatRef<'_, T>,
landmarks: &[f64],
sample_weights: Option<&[f64]>,
params: &LandmarkGpParams,
) -> Result<(), BixverseErrors>
where
T: BixverseFloat,
{
if x.is_empty() {
return Err(BixverseErrors::GpEmptyInput { name: "x" });
}
if y.ncols() == 0 {
return Err(BixverseErrors::GpEmptyInput { name: "y" });
}
if x.len() != y.nrows() {
return Err(BixverseErrors::GpDimensionMismatch {
n_x: x.len(),
n_y: y.nrows(),
});
}
if landmarks.len() < GP_MIN_LANDMARKS {
return Err(BixverseErrors::GpEmptyInput { name: "landmarks" });
}
if !params.length_scale.is_finite() || params.length_scale <= 0.0 {
return Err(BixverseErrors::GpInvalidHyperparameter {
name: "length_scale",
value: params.length_scale,
});
}
if !params.sigma.is_finite() || params.sigma <= 0.0 {
return Err(BixverseErrors::GpInvalidHyperparameter {
name: "sigma",
value: params.sigma,
});
}
if !params.jitter.is_finite() || params.jitter < 0.0 {
return Err(BixverseErrors::GpInvalidHyperparameter {
name: "jitter",
value: params.jitter,
});
}
if x.iter().any(|v| !v.is_finite()) {
return Err(BixverseErrors::GpNonFiniteInput { name: "x" });
}
if landmarks.iter().any(|v| !v.is_finite()) {
return Err(BixverseErrors::GpNonFiniteInput { name: "landmarks" });
}
let y_bad = (0..y.ncols())
.into_par_iter()
.any(|j| (0..y.nrows()).any(|i| !y[(i, j)].is_finite()));
if y_bad {
return Err(BixverseErrors::GpNonFiniteInput { name: "y" });
}
if let Some(w) = sample_weights {
if w.len() != x.len() {
return Err(BixverseErrors::GpDimensionMismatch {
n_x: x.len(),
n_y: w.len(),
});
}
if w.iter().any(|v| !v.is_finite() || *v < 0.0) {
return Err(BixverseErrors::GpNonFiniteInput {
name: "sample_weights",
});
}
if w.iter().all(|v| *v == 0.0) {
return Err(BixverseErrors::GpAllWeightsZero);
}
}
let first = landmarks[0];
if landmarks.iter().all(|v| *v == first) {
return Err(BixverseErrors::GpDegenerateLandmarks);
}
Ok(())
}
#[allow(clippy::type_complexity)]
fn factorise_landmarks(
k_uu: &Mat<f64>,
params: &LandmarkGpParams,
) -> Result<(faer::linalg::solvers::Llt<f64>, f64), BixverseErrors> {
let m = k_uu.nrows();
let mut jitter = params.jitter;
let mut last_tried = jitter;
for _ in 0..=params.max_jitter_retries {
let mut kp = k_uu.clone();
for i in 0..m {
kp[(i, i)] += jitter;
}
if let Ok(factorisation) = kp.llt(Side::Lower) {
return Ok((factorisation, jitter));
}
last_tried = jitter;
jitter = if jitter == 0.0 { 1e-12 } else { jitter * 10.0 };
}
Err(BixverseErrors::GpLandmarkCholeskyFailed {
jitter: last_tried,
n_landmarks: m,
})
}
pub fn fit_landmark_gp<T>(
x: &[f64],
y: MatRef<'_, T>,
landmarks: &[f64],
sample_weights: Option<&[f64]>,
params: &LandmarkGpParams,
) -> Result<LandmarkGpFit, BixverseErrors>
where
T: BixverseFloat,
{
let n = x.len();
let m = landmarks.len();
let p = y.ncols();
validate_inputs(x, y, landmarks, sample_weights, params)?;
let mut k_uu = Mat::<f64>::zeros(m, m);
fill_matern52_cross_1d(k_uu.as_mut(), landmarks, landmarks, params.length_scale);
let (llt_p, jitter_used) = factorise_landmarks(&k_uu, params)?;
let lp = llt_p.L();
let chunk = if params.chunk_size == 0 {
GP_DEFAULT_CHUNK
} else {
params.chunk_size
};
let n_threads = std::thread::available_parallelism()
.map(|v| v.get())
.unwrap_or(1);
let slab = n.div_ceil(n_threads).max(1);
let slab_starts: Vec<usize> = (0..n).step_by(slab).collect();
let partials: Vec<(Mat<f64>, Mat<f64>)> = slab_starts
.par_iter()
.map(|&slab_start| {
let slab_end = (slab_start + slab).min(n);
let chunk = chunk.min(slab_end - slab_start).max(1);
let mut g_local = Mat::<f64>::zeros(m, m);
let mut p_local = Mat::<f64>::zeros(m, p);
let mut a = Mat::<f64>::zeros(m, chunk);
let mut r = Mat::<f64>::zeros(chunk, p);
for start in (slab_start..slab_end).step_by(chunk) {
let c = (start + chunk).min(slab_end) - start;
fill_matern52_cross_1d(
a.as_mut().subcols_mut(0, c),
landmarks,
&x[start..start + c],
params.length_scale,
);
solve_lower_triangular_in_place(lp, a.as_mut().subcols_mut(0, c), Par::Seq);
for j in 0..p {
for i in 0..c {
r[(i, j)] = y[(start + i, j)].to_f64().unwrap_or(f64::NAN);
}
}
if let Some(w) = sample_weights {
for i in 0..c {
let s = w[start + i].sqrt();
for k in 0..m {
a[(k, i)] *= s;
}
for j in 0..p {
r[(i, j)] *= s;
}
}
}
gram(
g_local.as_mut(),
BlockStructure::TriangularLower,
Accum::Add,
a.as_ref().subcols(0, c),
a.as_ref().subcols(0, c).transpose(),
1.0_f64,
Par::Seq,
);
gemm(
p_local.as_mut(),
Accum::Add,
a.as_ref().subcols(0, c),
r.as_ref().subrows(0, c),
1.0_f64,
Par::Seq,
);
}
(g_local, p_local)
})
.collect();
let mut g = Mat::<f64>::zeros(m, m);
let mut weights = Mat::<f64>::zeros(m, p);
for (g_local, p_local) in &partials {
for j in 0..m {
for i in j..m {
g[(i, j)] += g_local[(i, j)];
}
}
for j in 0..p {
for i in 0..m {
weights[(i, j)] += p_local[(i, j)];
}
}
}
let inv_sigma_sq = 1.0 / (params.sigma * params.sigma);
let mut b = Mat::<f64>::zeros(m, m);
for j in 0..m {
for i in j..m {
let v = g[(i, j)] * inv_sigma_sq;
b[(i, j)] = v;
b[(j, i)] = v;
}
b[(j, j)] += 1.0;
}
let llt_b = b
.llt(Side::Lower)
.map_err(|_| BixverseErrors::GpPosteriorCholeskyFailed)?;
let lb = llt_b.L();
let par = faer_parallelism();
for j in 0..p {
for i in 0..m {
weights[(i, j)] *= inv_sigma_sq;
}
}
solve_lower_triangular_in_place(lb, weights.as_mut(), par);
solve_upper_triangular_in_place(lb.transpose(), weights.as_mut(), par);
solve_upper_triangular_in_place(lp.transpose(), weights.as_mut(), par);
Ok(LandmarkGpFit {
landmarks: landmarks.to_vec(),
weights,
length_scale: params.length_scale,
jitter_used,
k_uu,
})
}
#[derive(Clone, Debug)]
pub struct LandmarkGpFit {
pub landmarks: Vec<f64>,
pub weights: Mat<f64>,
pub length_scale: f64,
pub jitter_used: f64,
k_uu: Mat<f64>,
}
impl LandmarkGpFit {
pub fn n_outputs(&self) -> usize {
self.weights.ncols()
}
pub fn predict<T>(&self, x_new: &[f64]) -> Mat<T>
where
T: BixverseFloat,
{
let mut k_new = Mat::<f64>::zeros(x_new.len(), self.landmarks.len());
fill_matern52_cross_1d_par(k_new.as_mut(), x_new, &self.landmarks, self.length_scale);
self.apply_weights(k_new.as_ref())
}
pub fn predict_on_landmarks<T>(&self) -> Mat<T>
where
T: BixverseFloat,
{
self.apply_weights(self.k_uu.as_ref())
}
fn apply_weights<T>(&self, k_cross: MatRef<'_, f64>) -> Mat<T>
where
T: BixverseFloat,
{
let mut out = Mat::<f64>::zeros(k_cross.nrows(), self.weights.ncols());
gemm(
out.as_mut(),
Accum::Replace,
k_cross,
self.weights.as_ref(),
1.0_f64,
faer_parallelism(),
);
Mat::<T>::from_fn(out.nrows(), out.ncols(), |i, j| {
T::from_f64(out[(i, j)]).unwrap_or_else(T::zero)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn grid(lo: f64, hi: f64, n: usize) -> Vec<f64> {
if n == 1 {
return vec![lo];
}
let step = (hi - lo) / (n - 1) as f64;
let mut out: Vec<f64> = (0..n).map(|i| lo + step * i as f64).collect();
out[n - 1] = hi;
out
}
#[test]
fn test_landmark_gp_interpolates_at_tiny_sigma() {
let x: Vec<f64> = grid(0.0, 1.0, 25);
let y = Mat::<f64>::from_fn(x.len(), 1, |i, _| (3.0 * x[i]).sin());
let params = LandmarkGpParams {
sigma: 1e-4,
jitter: 1e-10,
..Default::default()
};
let fit = fit_landmark_gp(&x, y.as_ref(), &x, None, ¶ms).unwrap();
let pred: Mat<f64> = fit.predict_on_landmarks();
for i in 0..x.len() {
assert_relative_eq!(pred[(i, 0)], y[(i, 0)], epsilon = 1e-3);
}
}
#[test]
fn test_landmark_gp_recovers_a_smooth_function() {
let x: Vec<f64> = grid(0.0, 1.0, 200);
let f = |t: f64| (2.0 * std::f64::consts::PI * t).sin();
let y = Mat::<f64>::from_fn(x.len(), 1, |i, _| f(x[i]));
let landmarks = grid(0.0, 1.0, 40);
let params = LandmarkGpParams {
length_scale: 0.15,
sigma: 0.05,
..Default::default()
};
let fit = fit_landmark_gp(&x, y.as_ref(), &landmarks, None, ¶ms).unwrap();
let pred: Mat<f64> = fit.predict_on_landmarks();
for (i, &t) in landmarks.iter().enumerate() {
assert!(
(pred[(i, 0)] - f(t)).abs() < 0.05,
"landmark {} at t={}: got {}, want {}",
i,
t,
pred[(i, 0)],
f(t)
);
}
}
#[test]
fn test_landmark_gp_chunking_is_invariant() {
let x: Vec<f64> = grid(-1.0, 2.0, 137);
let y = Mat::<f64>::from_fn(x.len(), 3, |i, j| (x[i] * (j + 1) as f64).cos());
let landmarks = grid(-1.0, 2.0, 20);
let base = LandmarkGpParams {
length_scale: 0.4,
sigma: 0.2,
..Default::default()
};
let one_shot = fit_landmark_gp(
&x,
y.as_ref(),
&landmarks,
None,
&LandmarkGpParams {
chunk_size: 4096,
..base
},
)
.unwrap();
let chunked = fit_landmark_gp(
&x,
y.as_ref(),
&landmarks,
None,
&LandmarkGpParams {
chunk_size: 7,
..base
},
)
.unwrap();
for j in 0..3 {
for i in 0..landmarks.len() {
assert_relative_eq!(
one_shot.weights[(i, j)],
chunked.weights[(i, j)],
epsilon = 1e-9,
max_relative = 1e-7
);
}
}
}
#[test]
fn test_landmark_gp_uniform_weights_match_unweighted() {
let x: Vec<f64> = grid(0.0, 1.0, 61);
let y = Mat::<f64>::from_fn(x.len(), 2, |i, j| x[i].powi(j as i32 + 1));
let landmarks = grid(0.0, 1.0, 15);
let params = LandmarkGpParams {
length_scale: 0.3,
sigma: 0.1,
..Default::default()
};
let plain = fit_landmark_gp(&x, y.as_ref(), &landmarks, None, ¶ms).unwrap();
let weighted = fit_landmark_gp(
&x,
y.as_ref(),
&landmarks,
Some(&vec![1.0; x.len()]),
¶ms,
)
.unwrap();
for j in 0..2 {
for i in 0..landmarks.len() {
assert_relative_eq!(
plain.weights[(i, j)],
weighted.weights[(i, j)],
epsilon = 1e-10,
max_relative = 1e-9
);
}
}
}
#[test]
fn test_landmark_gp_matches_mellon_reference() {
let x: Vec<f64> = grid(0.0, 1.0, 60);
let landmarks = grid(0.0, 1.0, 12);
let y = Mat::<f64>::from_fn(60, 2, |i, j| {
if j == 0 {
(3.0 * x[i]).sin()
} else {
(2.0 * x[i]).cos()
}
});
let want_default: [[f64; 2]; 12] = [
[0.3539910533495122, 0.9557370626602998],
[0.45860317497407643, 0.9227324648238153],
[0.5635334042170455, 0.8661092108664761],
[0.6594165102705661, 0.7854533705009715],
[0.7360541752407392, 0.6822244585700202],
[0.784678516820574, 0.5598666389154093],
[0.7999122445616568, 0.423657703757],
[0.7809292078791386, 0.28029708866765085],
[0.7315603203894544, 0.13725078488221495],
[0.6593248125430881, 0.0018968648884448041],
[0.5736043429117991, -0.11942927668599138],
[0.48342829487977995, -0.22231117542051806],
];
let want_tight: [[f64; 2]; 12] = [
[0.0234549590946689, 0.9780991676470202],
[0.26317299032134295, 0.9868910259026406],
[0.5177675908376118, 0.9339767189083742],
[0.7295837169477231, 0.8532715614628628],
[0.8856831320496343, 0.7461492453229132],
[0.9771156022101442, 0.6138245434522089],
[0.9963285483204488, 0.46106437022572266],
[0.9418762515598207, 0.29328314035705316],
[0.8185313482486412, 0.11657557620418924],
[0.6331241780975282, -0.06576534988479371],
[0.396731427365446, -0.24998402840753614],
[0.16164981869415487, -0.3930057188672961],
];
for (ls, sigma, want) in [(1.0_f64, 1.0_f64, &want_default), (0.3, 0.2, &want_tight)] {
let fit = fit_landmark_gp(
&x,
y.as_ref(),
&landmarks,
None,
&LandmarkGpParams {
length_scale: ls,
sigma,
..Default::default()
},
)
.unwrap();
let got: Mat<f64> = fit.predict_on_landmarks();
assert_eq!(got.nrows(), 12);
assert_eq!(got.ncols(), 2);
for (i, row) in want.iter().enumerate() {
for (j, expected) in row.iter().enumerate() {
assert_relative_eq!(got[(i, j)], expected, epsilon = 1e-8, max_relative = 1e-8);
}
}
}
}
#[test]
fn test_landmark_gp_integer_weights_match_duplicated_observations() {
let x: Vec<f64> = grid(0.0, 1.0, 17);
let y = Mat::<f64>::from_fn(x.len(), 2, |i, j| (x[i] * (j + 2) as f64).sin());
let landmarks = grid(0.0, 1.0, 9);
let params = LandmarkGpParams {
length_scale: 0.35,
sigma: 0.25,
..Default::default()
};
let counts: Vec<usize> = (0..x.len()).map(|i| 2 + i % 3).collect();
let w: Vec<f64> = counts.iter().map(|&c| c as f64).collect();
let weighted = fit_landmark_gp(&x, y.as_ref(), &landmarks, Some(&w), ¶ms).unwrap();
let mut x_rep: Vec<f64> = Vec::new();
let mut rows: Vec<usize> = Vec::new();
for (i, &c) in counts.iter().enumerate() {
for _ in 0..c {
x_rep.push(x[i]);
rows.push(i);
}
}
let y_rep = Mat::<f64>::from_fn(rows.len(), 2, |i, j| y[(rows[i], j)]);
let duplicated =
fit_landmark_gp(&x_rep, y_rep.as_ref(), &landmarks, None, ¶ms).unwrap();
for j in 0..2 {
for i in 0..landmarks.len() {
assert_relative_eq!(
weighted.weights[(i, j)],
duplicated.weights[(i, j)],
epsilon = 1e-9,
max_relative = 1e-7
);
}
}
}
#[test]
fn test_landmark_gp_weight_scaling_trades_against_sigma() {
let x: Vec<f64> = grid(0.0, 1.0, 41);
let y = Mat::<f64>::from_fn(x.len(), 2, |i, j| (x[i] + j as f64).exp());
let landmarks = grid(0.0, 1.0, 11);
let w: Vec<f64> = (0..x.len())
.map(|i| 0.05 + 0.9 * ((i * 7 % 13) as f64 / 13.0))
.collect();
let c = 4.0_f64;
let w_scaled: Vec<f64> = w.iter().map(|v| v * c).collect();
let base = LandmarkGpParams {
length_scale: 0.3,
sigma: 0.2,
..Default::default()
};
let compensated = LandmarkGpParams {
sigma: base.sigma * c.sqrt(),
..base
};
let plain = fit_landmark_gp(&x, y.as_ref(), &landmarks, Some(&w), &base).unwrap();
let scaled =
fit_landmark_gp(&x, y.as_ref(), &landmarks, Some(&w_scaled), &compensated).unwrap();
for j in 0..2 {
for i in 0..landmarks.len() {
assert_relative_eq!(
plain.weights[(i, j)],
scaled.weights[(i, j)],
epsilon = 1e-9,
max_relative = 1e-7
);
}
}
}
#[test]
fn test_landmark_gp_rejects_all_zero_weights() {
let x: Vec<f64> = grid(0.0, 1.0, 10);
let y = Mat::<f64>::from_fn(10, 1, |i, _| i as f64);
let landmarks = grid(0.0, 1.0, 5);
assert!(matches!(
fit_landmark_gp(
&x,
y.as_ref(),
&landmarks,
Some(&[0.0; 10]),
&LandmarkGpParams::default()
),
Err(BixverseErrors::GpAllWeightsZero)
));
}
#[test]
fn test_landmark_gp_zero_weights_drop_observations() {
let x: Vec<f64> = grid(0.0, 1.0, 40);
let y = Mat::<f64>::from_fn(x.len(), 1, |i, _| (4.0 * x[i]).sin());
let landmarks = grid(0.0, 1.0, 12);
let params = LandmarkGpParams {
length_scale: 0.3,
sigma: 0.2,
..Default::default()
};
let mut w = vec![1.0; x.len()];
w[20..].fill(0.0);
let masked = fit_landmark_gp(&x, y.as_ref(), &landmarks, Some(&w), ¶ms).unwrap();
let x_head = x[..20].to_vec();
let y_head = Mat::<f64>::from_fn(20, 1, |i, _| y[(i, 0)]);
let head = fit_landmark_gp(&x_head, y_head.as_ref(), &landmarks, None, ¶ms).unwrap();
for i in 0..landmarks.len() {
assert_relative_eq!(
masked.weights[(i, 0)],
head.weights[(i, 0)],
epsilon = 1e-9,
max_relative = 1e-7
);
}
}
#[test]
fn test_landmark_gp_handles_fewer_points_than_landmarks() {
let x: Vec<f64> = grid(0.0, 1.0, 5);
let y = Mat::<f64>::from_fn(5, 2, |i, j| (i + j) as f64);
let landmarks = grid(0.0, 1.0, 50);
let fit = fit_landmark_gp(
&x,
y.as_ref(),
&landmarks,
None,
&LandmarkGpParams::default(),
)
.unwrap();
let pred: Mat<f64> = fit.predict_on_landmarks();
for j in 0..2 {
for i in 0..50 {
assert!(pred[(i, j)].is_finite());
}
}
}
#[test]
fn test_predict_on_landmarks_matches_generic_predict() {
let x: Vec<f64> = grid(0.0, 1.0, 50);
let y = Mat::<f64>::from_fn(50, 2, |i, j| (x[i] * (j + 2) as f64).exp());
let landmarks = grid(0.0, 1.0, 17);
let fit = fit_landmark_gp(
&x,
y.as_ref(),
&landmarks,
None,
&LandmarkGpParams::default(),
)
.unwrap();
let cached: Mat<f64> = fit.predict_on_landmarks();
let generic: Mat<f64> = fit.predict(&landmarks);
for j in 0..2 {
for i in 0..17 {
assert_relative_eq!(
cached[(i, j)],
generic[(i, j)],
epsilon = 1e-12,
max_relative = 1e-12
);
}
}
}
#[test]
fn test_landmark_gp_accepts_f32_responses() {
let x: Vec<f64> = grid(0.0, 1.0, 60);
let y32 = Mat::<f32>::from_fn(60, 1, |i, _| (2.0 * x[i] as f32).sin());
let y64 = Mat::<f64>::from_fn(60, 1, |i, _| y32[(i, 0)] as f64);
let landmarks = grid(0.0, 1.0, 20);
let params = LandmarkGpParams {
length_scale: 0.3,
sigma: 0.1,
..Default::default()
};
let fit32 = fit_landmark_gp(&x, y32.as_ref(), &landmarks, None, ¶ms).unwrap();
let fit64 = fit_landmark_gp(&x, y64.as_ref(), &landmarks, None, ¶ms).unwrap();
let pred32: Mat<f32> = fit32.predict_on_landmarks();
let pred64: Mat<f64> = fit64.predict_on_landmarks();
for i in 0..20 {
assert_relative_eq!(pred32[(i, 0)] as f64, pred64[(i, 0)], epsilon = 1e-5);
}
}
#[test]
fn test_landmark_gp_rejects_bad_inputs() {
let x: Vec<f64> = grid(0.0, 1.0, 10);
let y = Mat::<f64>::from_fn(10, 1, |i, _| i as f64);
let landmarks = grid(0.0, 1.0, 5);
let ok = LandmarkGpParams::default();
assert!(matches!(
fit_landmark_gp(&x[..5], y.as_ref(), &landmarks, None, &ok),
Err(BixverseErrors::GpDimensionMismatch { n_x: 5, n_y: 10 })
));
assert!(matches!(
fit_landmark_gp::<f64>(&[], Mat::<f64>::zeros(0, 1).as_ref(), &landmarks, None, &ok),
Err(BixverseErrors::GpEmptyInput { .. })
));
assert!(matches!(
fit_landmark_gp(&x, y.as_ref(), &landmarks[..1], None, &ok),
Err(BixverseErrors::GpEmptyInput { .. })
));
assert!(matches!(
fit_landmark_gp(
&x,
y.as_ref(),
&landmarks,
None,
&LandmarkGpParams { sigma: 0.0, ..ok }
),
Err(BixverseErrors::GpInvalidHyperparameter { name: "sigma", .. })
));
assert!(matches!(
fit_landmark_gp(
&x,
y.as_ref(),
&landmarks,
None,
&LandmarkGpParams {
length_scale: -1.0,
..ok
}
),
Err(BixverseErrors::GpInvalidHyperparameter {
name: "length_scale",
..
})
));
let mut x_nan = x.clone();
x_nan[3] = f64::NAN;
assert!(matches!(
fit_landmark_gp(&x_nan, y.as_ref(), &landmarks, None, &ok),
Err(BixverseErrors::GpNonFiniteInput { name: "x" })
));
let mut y_inf = y.clone();
y_inf[(2, 0)] = f64::INFINITY;
assert!(matches!(
fit_landmark_gp(&x, y_inf.as_ref(), &landmarks, None, &ok),
Err(BixverseErrors::GpNonFiniteInput { name: "y" })
));
assert!(matches!(
fit_landmark_gp(&x, y.as_ref(), &[0.5; 6], None, &ok),
Err(BixverseErrors::GpDegenerateLandmarks)
));
assert!(matches!(
fit_landmark_gp(&x, y.as_ref(), &landmarks, Some(&[1.0; 3]), &ok),
Err(BixverseErrors::GpDimensionMismatch { .. })
));
}
#[test]
fn test_jitter_used_is_reported() {
let x: Vec<f64> = grid(0.0, 1.0, 20);
let y = Mat::<f64>::from_fn(20, 1, |i, _| i as f64);
let landmarks = grid(0.0, 1.0, 8);
let fit = fit_landmark_gp(
&x,
y.as_ref(),
&landmarks,
None,
&LandmarkGpParams {
jitter: 1e-3,
..Default::default()
},
)
.unwrap();
assert_relative_eq!(fit.jitter_used, 1e-3, epsilon = 1e-15);
}
#[test]
fn test_jitter_ladder_escalates_and_reports_the_last_rung() {
let landmarks = vec![0.0_f64, 0.25, 0.25, 0.5, 0.75, 1.0];
let x: Vec<f64> = grid(0.0, 1.0, 30);
let y = Mat::<f64>::from_fn(30, 1, |i, _| x[i].sin());
let err = fit_landmark_gp(
&x,
y.as_ref(),
&landmarks,
None,
&LandmarkGpParams {
jitter: 0.0,
max_jitter_retries: 0,
..Default::default()
},
)
.unwrap_err();
match err {
BixverseErrors::GpLandmarkCholeskyFailed {
jitter,
n_landmarks,
} => {
assert_eq!(n_landmarks, 6);
assert_eq!(jitter, 0.0);
}
other => panic!("expected GpLandmarkCholeskyFailed, got {:?}", other),
}
let err = fit_landmark_gp(
&x,
y.as_ref(),
&landmarks,
None,
&LandmarkGpParams {
jitter: f64::MIN_POSITIVE,
max_jitter_retries: 1,
..Default::default()
},
)
.unwrap_err();
match err {
BixverseErrors::GpLandmarkCholeskyFailed { jitter, .. } => {
assert_eq!(
jitter,
f64::MIN_POSITIVE * 10.0,
"reported jitter is not the last rung actually attempted"
);
}
other => panic!("expected GpLandmarkCholeskyFailed, got {:?}", other),
}
let fit = fit_landmark_gp(
&x,
y.as_ref(),
&landmarks,
None,
&LandmarkGpParams {
jitter: 0.0,
max_jitter_retries: 12,
..Default::default()
},
)
.unwrap();
assert!(
fit.jitter_used > 0.0,
"the ladder never escalated off the requested jitter"
);
assert!(
fit.weights
.col_iter()
.all(|c| c.iter().all(|v| v.is_finite())),
"escalation produced a non-finite fit"
);
}
}