Skip to main content

oxicuda_cs/sbl/
relevance_vector.rs

1//! Relevance Vector Machine (Tipping 2001; Tipping & Faul 2003).
2//!
3//! A Bayesian sparse kernel regressor. Given training inputs `{x_i}` and targets
4//! `t ∈ ℝ^m`, the model is
5//!
6//! ```text
7//! t_i = Σ_j w_j φ_j(x_i) + ε,   ε ~ N(0, σ²),
8//! ```
9//!
10//! with a separate Automatic-Relevance-Determination (ARD) prior on each weight:
11//! `p(w_j) = N(0, α_j⁻¹)`. Maximising the marginal likelihood (the *evidence*) drives
12//! most `α_j → ∞`, forcing the corresponding `w_j` to exactly zero. The surviving
13//! basis functions are the **relevance vectors** — usually far fewer than an SVM's
14//! support vectors, and without the SVM's box-constraint tuning.
15//!
16//! This module implements both:
17//!
18//! - the classic EM-style evidence maximisation over a *fixed* design matrix
19//!   ([`rvm_fit_design`]), and
20//! - a kernel front-end ([`Rvm`]) that builds the design matrix `Φ_{ij} = k(x_i, x_j)`
21//!   (plus a bias column), fits the weights, prunes to the relevance set, and predicts
22//!   on unseen inputs via `ŷ(x⋆) = Σ_{j ∈ RV} w_j k(x⋆, x_j) + bias`.
23//!
24//! The marginal-likelihood updates use the MacKay re-estimation formulae:
25//!
26//! ```text
27//! Σ        = (A + βΦᵀΦ)⁻¹,   μ = βΣΦᵀt,   A = diag(α),   β = 1/σ²
28//! γ_j      = 1 − α_j Σ_{jj}
29//! α_j      ← γ_j / μ_j²
30//! β        ← (m − Σ_j γ_j) / ‖t − Φμ‖²
31//! ```
32//!
33//! # References
34//!
35//! - M. E. Tipping (2001), "Sparse Bayesian Learning and the Relevance Vector Machine",
36//!   JMLR 1:211-244.
37//! - M. E. Tipping & A. C. Faul (2003), "Fast Marginal Likelihood Maximisation for Sparse
38//!   Bayesian Models", AISTATS.
39
40use crate::error::{CsError, CsResult};
41use crate::linalg::cholesky::{cholesky_factor, cholesky_solve};
42use crate::linalg::{mat_t_vec, mat_vec, norm2};
43
44// ---------------------------------------------------------------------------
45// Fixed-design RVM (evidence maximisation)
46// ---------------------------------------------------------------------------
47
48/// Result of fitting an RVM over a fixed design matrix.
49#[derive(Debug, Clone)]
50pub struct RvmFit {
51    /// Posterior-mean weights `μ ∈ ℝ^n` (zero at pruned basis functions).
52    pub weights: Vec<f64>,
53    /// Final inverse-variance hyper-parameters `α_j` (`∞`-pruned set to `f64::INFINITY`).
54    pub alpha: Vec<f64>,
55    /// Indices of the surviving relevance vectors (those with finite `α_j`).
56    pub relevance: Vec<usize>,
57    /// Estimated noise precision `β = 1/σ²`.
58    pub beta: f64,
59    /// Number of EM iterations performed.
60    pub iterations: usize,
61}
62
63/// Threshold above which a basis function is considered pruned (`α_j → ∞`).
64const ALPHA_PRUNE: f64 = 1e12;
65
66/// Fit an RVM by evidence maximisation over the fixed `m × n` design matrix `phi`.
67///
68/// * `phi` — design matrix, row-major `[m × n]`. Column `j` is basis function `φ_j`
69///   evaluated at every training point.
70/// * `m`, `n` — number of training samples and basis functions.
71/// * `t` — target vector `ℝ^m`.
72/// * `max_iter` — maximum evidence-maximisation iterations.
73/// * `tol` — stop when the largest `log α_j` change is below `tol`.
74///
75/// # Errors
76/// * [`CsError::ShapeMismatch`] / [`CsError::DimensionMismatch`] on bad shapes.
77/// * [`CsError::InvalidParameter`] for `m == 0` or `n == 0`.
78pub fn rvm_fit_design(
79    phi: &[f64],
80    m: usize,
81    n: usize,
82    t: &[f64],
83    max_iter: usize,
84    tol: f64,
85) -> CsResult<RvmFit> {
86    if m == 0 || n == 0 {
87        return Err(CsError::InvalidParameter("rvm: m and n must be > 0".into()));
88    }
89    if phi.len() != m * n {
90        return Err(CsError::ShapeMismatch {
91            expected: vec![m, n],
92            got: vec![phi.len()],
93        });
94    }
95    if t.len() != m {
96        return Err(CsError::DimensionMismatch { a: t.len(), b: m });
97    }
98
99    // Pre-compute ΦᵀΦ (n×n) and Φᵀt (n).
100    let gram = gram_matrix(phi, m, n);
101    let phi_t_t = mat_t_vec(phi, m, n, t)?;
102
103    // Initialise hyper-parameters.
104    let mut alpha = vec![1.0_f64; n];
105    let t_var = (norm2(t) * norm2(t) / m as f64).max(1e-6);
106    let mut beta = 1.0 / (0.1 * t_var).max(1e-6); // start assuming 10% noise variance
107    let mut mu = vec![0.0_f64; n];
108    let mut iterations = 0usize;
109
110    for _ in 0..max_iter {
111        iterations += 1;
112        let active: Vec<usize> = (0..n).filter(|&j| alpha[j] < ALPHA_PRUNE).collect();
113        if active.is_empty() {
114            break;
115        }
116        let k = active.len();
117
118        // Build A + βΦᵀΦ restricted to active columns (k×k SPD).
119        let mut h = vec![0.0_f64; k * k];
120        for (ii, &i) in active.iter().enumerate() {
121            for (jj, &j) in active.iter().enumerate() {
122                h[ii * k + jj] = beta * gram[i * n + j];
123            }
124            h[ii * k + ii] += alpha[i];
125        }
126        let l = cholesky_factor(&h, k)?;
127
128        // μ_active = β Σ Φᵀt  ⇒ solve H μ = β (Φᵀt)_active.
129        let mut rhs = vec![0.0_f64; k];
130        for (ii, &i) in active.iter().enumerate() {
131            rhs[ii] = beta * phi_t_t[i];
132        }
133        let mu_active = cholesky_solve(&l, k, &rhs)?;
134
135        // Σ_{jj} for active set: solve H s = e_j, read s_j.
136        let mut sigma_diag = vec![0.0_f64; k];
137        for jj in 0..k {
138            let mut ej = vec![0.0_f64; k];
139            ej[jj] = 1.0;
140            let s = cholesky_solve(&l, k, &ej)?;
141            sigma_diag[jj] = s[jj];
142        }
143
144        // Hyper-parameter updates.
145        let mut max_log_change = 0.0_f64;
146        let mut gamma_sum = 0.0_f64;
147        let mut new_mu = vec![0.0_f64; n];
148        for (jj, &j) in active.iter().enumerate() {
149            let mu_j = mu_active[jj];
150            new_mu[j] = mu_j;
151            let gamma_j = (1.0 - alpha[j] * sigma_diag[jj]).clamp(1e-12, 1.0);
152            gamma_sum += gamma_j;
153            let mu_sq = (mu_j * mu_j).max(1e-300);
154            let alpha_new = (gamma_j / mu_sq).min(ALPHA_PRUNE * 10.0);
155            let log_change = (alpha_new.max(1e-300).ln() - alpha[j].max(1e-300).ln()).abs();
156            max_log_change = max_log_change.max(log_change);
157            alpha[j] = alpha_new;
158        }
159        mu = new_mu;
160
161        // Noise precision update.
162        let phi_mu = mat_vec(phi, m, n, &mu)?;
163        let mut resid_sq = 0.0_f64;
164        for i in 0..m {
165            let d = t[i] - phi_mu[i];
166            resid_sq += d * d;
167        }
168        let denom = (m as f64 - gamma_sum).max(1e-6);
169        beta = (denom / resid_sq.max(1e-12)).clamp(1e-6, 1e12);
170
171        if max_log_change < tol {
172            break;
173        }
174    }
175
176    let relevance: Vec<usize> = (0..n).filter(|&j| alpha[j] < ALPHA_PRUNE).collect();
177    // Zero-out pruned weights and mark pruned alphas as infinite.
178    for j in 0..n {
179        if alpha[j] >= ALPHA_PRUNE {
180            mu[j] = 0.0;
181            alpha[j] = f64::INFINITY;
182        }
183    }
184
185    Ok(RvmFit {
186        weights: mu,
187        alpha,
188        relevance,
189        beta,
190        iterations,
191    })
192}
193
194/// `ΦᵀΦ` for a row-major `m × n` matrix (returns `n × n`, row-major).
195fn gram_matrix(phi: &[f64], m: usize, n: usize) -> Vec<f64> {
196    let mut g = vec![0.0_f64; n * n];
197    for r in 0..m {
198        let row = r * n;
199        for i in 0..n {
200            let pri = phi[row + i];
201            if pri == 0.0 {
202                continue;
203            }
204            for j in 0..n {
205                g[i * n + j] += pri * phi[row + j];
206            }
207        }
208    }
209    g
210}
211
212// ---------------------------------------------------------------------------
213// Kernel front-end
214// ---------------------------------------------------------------------------
215
216/// Kernel choice for the [`Rvm`] regressor.
217#[derive(Debug, Clone)]
218pub enum RvmKernel {
219    /// Linear kernel `k(a, b) = aᵀb`.
220    Linear,
221    /// Gaussian RBF kernel `k(a, b) = exp(−‖a − b‖² / (2 ℓ²))` with length-scale `ℓ`.
222    Rbf {
223        /// Length-scale `ℓ > 0`.
224        length_scale: f64,
225    },
226    /// Inhomogeneous polynomial kernel `k(a, b) = (aᵀb + c)^d`.
227    Polynomial {
228        /// Additive constant `c ≥ 0`.
229        coef0: f64,
230        /// Integer degree `d ≥ 1`.
231        degree: u32,
232    },
233}
234
235impl RvmKernel {
236    /// Evaluate the kernel between two equal-length feature vectors.
237    fn eval(&self, a: &[f64], b: &[f64]) -> f64 {
238        let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
239        match *self {
240            RvmKernel::Linear => dot,
241            RvmKernel::Rbf { length_scale } => {
242                let mut sq = 0.0_f64;
243                for (x, y) in a.iter().zip(b.iter()) {
244                    let d = x - y;
245                    sq += d * d;
246                }
247                (-sq / (2.0 * length_scale * length_scale)).exp()
248            }
249            RvmKernel::Polynomial { coef0, degree } => (dot + coef0).powi(degree as i32),
250        }
251    }
252}
253
254/// Configuration for the kernel [`Rvm`].
255#[derive(Debug, Clone)]
256pub struct RvmConfig {
257    /// Kernel (default Gaussian RBF with length-scale `1.0`).
258    pub kernel: RvmKernel,
259    /// Whether to append a constant bias basis function (default `true`).
260    pub use_bias: bool,
261    /// Maximum evidence-maximisation iterations (default `300`).
262    pub max_iter: usize,
263    /// Convergence tolerance on `log α` (default `1 × 10⁻⁴`).
264    pub tol: f64,
265}
266
267impl Default for RvmConfig {
268    fn default() -> Self {
269        Self {
270            kernel: RvmKernel::Rbf { length_scale: 1.0 },
271            use_bias: true,
272            max_iter: 300,
273            tol: 1e-4,
274        }
275    }
276}
277
278/// A fitted kernel Relevance Vector Machine.
279#[derive(Debug, Clone)]
280pub struct Rvm {
281    config: RvmConfig,
282    /// Stored training inputs of the relevance vectors, row-major `[n_rv × d]`.
283    rv_inputs: Vec<f64>,
284    /// Weights of the relevance vectors (`[n_rv]`).
285    rv_weights: Vec<f64>,
286    /// Bias weight (`0.0` if no bias survived / disabled).
287    bias: f64,
288    /// Feature dimension `d`.
289    d: usize,
290    /// Noise precision `β`.
291    beta: f64,
292}
293
294impl Rvm {
295    /// Fit a kernel RVM to `n` training points `x ∈ ℝ^{n×d}` (row-major) and targets `t`.
296    ///
297    /// # Errors
298    /// * [`CsError::InvalidParameter`] for `n == 0`, `d == 0`, or a non-positive RBF
299    ///   length-scale.
300    /// * [`CsError::ShapeMismatch`] / [`CsError::DimensionMismatch`] on bad shapes.
301    pub fn fit(x: &[f64], n: usize, d: usize, t: &[f64], config: RvmConfig) -> CsResult<Self> {
302        if n == 0 || d == 0 {
303            return Err(CsError::InvalidParameter("rvm: n and d must be > 0".into()));
304        }
305        if x.len() != n * d {
306            return Err(CsError::ShapeMismatch {
307                expected: vec![n, d],
308                got: vec![x.len()],
309            });
310        }
311        if t.len() != n {
312            return Err(CsError::DimensionMismatch { a: t.len(), b: n });
313        }
314        if let RvmKernel::Rbf { length_scale } = config.kernel {
315            if length_scale <= 0.0 || !length_scale.is_finite() {
316                return Err(CsError::InvalidParameter(format!(
317                    "rvm: RBF length_scale must be > 0, got {length_scale}"
318                )));
319            }
320        }
321
322        // Build the design matrix Φ (n × n_basis) with optional bias column last.
323        let n_basis = if config.use_bias { n + 1 } else { n };
324        let mut phi = vec![0.0_f64; n * n_basis];
325        for i in 0..n {
326            let xi = &x[i * d..(i + 1) * d];
327            for j in 0..n {
328                let xj = &x[j * d..(j + 1) * d];
329                phi[i * n_basis + j] = config.kernel.eval(xi, xj);
330            }
331            if config.use_bias {
332                phi[i * n_basis + n] = 1.0;
333            }
334        }
335
336        let fit = rvm_fit_design(&phi, n, n_basis, t, config.max_iter, config.tol)?;
337
338        // Extract relevance vectors (kernel basis) and bias.
339        let mut rv_inputs = Vec::new();
340        let mut rv_weights = Vec::new();
341        let mut bias = 0.0_f64;
342        for &j in &fit.relevance {
343            if config.use_bias && j == n {
344                bias = fit.weights[j];
345            } else {
346                rv_inputs.extend_from_slice(&x[j * d..(j + 1) * d]);
347                rv_weights.push(fit.weights[j]);
348            }
349        }
350
351        Ok(Self {
352            config,
353            rv_inputs,
354            rv_weights,
355            bias,
356            d,
357            beta: fit.beta,
358        })
359    }
360
361    /// Number of relevance vectors retained (excludes the bias term).
362    #[must_use]
363    pub fn n_relevance(&self) -> usize {
364        self.rv_weights.len()
365    }
366
367    /// Estimated noise standard deviation `σ = 1/√β`.
368    #[must_use]
369    pub fn noise_std(&self) -> f64 {
370        1.0 / self.beta.max(1e-300).sqrt()
371    }
372
373    /// Predict the target at a single query input `x⋆ ∈ ℝ^d`.
374    ///
375    /// # Errors
376    /// [`CsError::DimensionMismatch`] if `query.len() != d`.
377    pub fn predict_one(&self, query: &[f64]) -> CsResult<f64> {
378        if query.len() != self.d {
379            return Err(CsError::DimensionMismatch {
380                a: query.len(),
381                b: self.d,
382            });
383        }
384        let mut y = self.bias;
385        for (k, w) in self.rv_weights.iter().enumerate() {
386            let rv = &self.rv_inputs[k * self.d..(k + 1) * self.d];
387            y += w * self.config.kernel.eval(query, rv);
388        }
389        Ok(y)
390    }
391
392    /// Predict targets for `n_q` query points `x ∈ ℝ^{n_q×d}` (row-major).
393    ///
394    /// # Errors
395    /// [`CsError::ShapeMismatch`] if `queries.len() != n_q * d`.
396    pub fn predict(&self, queries: &[f64], n_q: usize) -> CsResult<Vec<f64>> {
397        if queries.len() != n_q * self.d {
398            return Err(CsError::ShapeMismatch {
399                expected: vec![n_q, self.d],
400                got: vec![queries.len()],
401            });
402        }
403        let mut out = Vec::with_capacity(n_q);
404        for i in 0..n_q {
405            out.push(self.predict_one(&queries[i * self.d..(i + 1) * self.d])?);
406        }
407        Ok(out)
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn fixed_design_identity_is_sparse() {
417        // Φ = I_4, t = [1, 0, 0, 2] ⇒ relevance set should keep 0 and 3.
418        let phi = {
419            let mut p = vec![0.0_f64; 16];
420            for i in 0..4 {
421                p[i * 4 + i] = 1.0;
422            }
423            p
424        };
425        let t = [1.0_f64, 0.0, 0.0, 2.0];
426        let fit = rvm_fit_design(&phi, 4, 4, &t, 200, 1e-6).expect("ok");
427        assert!(fit.weights[0].abs() > 0.5, "w0 = {}", fit.weights[0]);
428        assert!(fit.weights[3].abs() > 1.0, "w3 = {}", fit.weights[3]);
429        assert!(fit.weights[1].abs() < 1e-3, "w1 = {}", fit.weights[1]);
430        assert!(fit.weights[2].abs() < 1e-3, "w2 = {}", fit.weights[2]);
431        assert!(fit.relevance.contains(&0) && fit.relevance.contains(&3));
432        assert!(!fit.relevance.contains(&1) && !fit.relevance.contains(&2));
433    }
434
435    #[test]
436    fn fixed_design_pruned_alpha_infinite() {
437        let phi = {
438            let mut p = vec![0.0_f64; 9];
439            for i in 0..3 {
440                p[i * 3 + i] = 1.0;
441            }
442            p
443        };
444        let t = [5.0_f64, 0.0, 0.0];
445        let fit = rvm_fit_design(&phi, 3, 3, &t, 200, 1e-6).expect("ok");
446        assert!(fit.alpha[1].is_infinite());
447        assert!(fit.alpha[2].is_infinite());
448        assert!(fit.alpha[0].is_finite());
449    }
450
451    #[test]
452    fn linear_kernel_recovers_line() {
453        // Targets t = 2x on a 1-D grid; linear kernel should reproduce it well.
454        let n = 12usize;
455        let xs: Vec<f64> = (0..n).map(|i| i as f64 / (n as f64)).collect();
456        let t: Vec<f64> = xs.iter().map(|&x| 2.0 * x).collect();
457        let cfg = RvmConfig {
458            kernel: RvmKernel::Linear,
459            use_bias: true,
460            max_iter: 400,
461            tol: 1e-6,
462        };
463        let rvm = Rvm::fit(&xs, n, 1, &t, cfg).expect("ok");
464        let pred = rvm.predict_one(&[0.5]).expect("ok");
465        assert!((pred - 1.0).abs() < 0.1, "pred(0.5) = {pred}");
466    }
467
468    #[test]
469    fn rbf_kernel_fits_sine() {
470        // Fit a smooth sine; RBF RVM should interpolate accurately and stay sparse.
471        let n = 25usize;
472        let xs: Vec<f64> = (0..n)
473            .map(|i| -3.0 + 6.0 * i as f64 / (n as f64 - 1.0))
474            .collect();
475        let t: Vec<f64> = xs.iter().map(|&x| x.sin()).collect();
476        let cfg = RvmConfig {
477            kernel: RvmKernel::Rbf { length_scale: 1.0 },
478            use_bias: true,
479            max_iter: 500,
480            tol: 1e-5,
481        };
482        let rvm = Rvm::fit(&xs, n, 1, &t, cfg).expect("ok");
483        // Prediction at a training-ish point.
484        let pred = rvm.predict_one(&[0.0]).expect("ok");
485        assert!(pred.abs() < 0.2, "pred(0) = {pred} (sin 0 = 0)");
486        let pred2 = rvm.predict_one(&[std::f64::consts::FRAC_PI_2]).expect("ok"); // sin(π/2) = 1
487        assert!((pred2 - 1.0).abs() < 0.25, "pred(π/2) = {pred2}");
488        // Relevance set should be a strict subset of the training points.
489        assert!(rvm.n_relevance() <= n, "n_rv = {}", rvm.n_relevance());
490    }
491
492    #[test]
493    fn predict_batch_matches_single() {
494        let n = 8usize;
495        let xs: Vec<f64> = (0..n).map(|i| i as f64 * 0.3).collect();
496        let t: Vec<f64> = xs.iter().map(|&x| 0.5 * x + 1.0).collect();
497        let cfg = RvmConfig {
498            kernel: RvmKernel::Linear,
499            ..Default::default()
500        };
501        let rvm = Rvm::fit(&xs, n, 1, &t, cfg).expect("ok");
502        let q = vec![0.1_f64, 0.7, 1.4];
503        let batch = rvm.predict(&q, 3).expect("ok");
504        for (i, qi) in q.iter().enumerate() {
505            let single = rvm.predict_one(&[*qi]).expect("ok");
506            assert!((batch[i] - single).abs() < 1e-12);
507        }
508    }
509
510    #[test]
511    fn multidim_input_rbf() {
512        // 2-D inputs, target = x0 + x1.
513        let pts = [
514            [0.0_f64, 0.0],
515            [1.0, 0.0],
516            [0.0, 1.0],
517            [1.0, 1.0],
518            [0.5, 0.5],
519            [0.2, 0.8],
520        ];
521        let n = pts.len();
522        let mut x = Vec::new();
523        let mut t = Vec::new();
524        for p in &pts {
525            x.extend_from_slice(p);
526            t.push(p[0] + p[1]);
527        }
528        let cfg = RvmConfig {
529            kernel: RvmKernel::Rbf { length_scale: 1.0 },
530            max_iter: 400,
531            ..Default::default()
532        };
533        let rvm = Rvm::fit(&x, n, 2, &t, cfg).expect("ok");
534        let pred = rvm.predict_one(&[1.0, 1.0]).expect("ok");
535        assert!((pred - 2.0).abs() < 0.4, "pred(1,1) = {pred}");
536    }
537
538    #[test]
539    fn polynomial_kernel_quadratic() {
540        // Targets t = x²; degree-2 polynomial kernel can fit exactly.
541        let n = 10usize;
542        let xs: Vec<f64> = (0..n).map(|i| -1.0 + 2.0 * i as f64 / 9.0).collect();
543        let t: Vec<f64> = xs.iter().map(|&x| x * x).collect();
544        let cfg = RvmConfig {
545            kernel: RvmKernel::Polynomial {
546                coef0: 1.0,
547                degree: 2,
548            },
549            use_bias: true,
550            max_iter: 500,
551            tol: 1e-6,
552        };
553        let rvm = Rvm::fit(&xs, n, 1, &t, cfg).expect("ok");
554        let pred = rvm.predict_one(&[0.5]).expect("ok");
555        assert!((pred - 0.25).abs() < 0.1, "pred(0.5) = {pred}");
556    }
557
558    #[test]
559    fn noise_std_positive() {
560        let n = 6usize;
561        let xs: Vec<f64> = (0..n).map(|i| i as f64).collect();
562        let t: Vec<f64> = xs.to_vec();
563        let cfg = RvmConfig {
564            kernel: RvmKernel::Linear,
565            ..Default::default()
566        };
567        let rvm = Rvm::fit(&xs, n, 1, &t, cfg).expect("ok");
568        assert!(rvm.noise_std() > 0.0 && rvm.noise_std().is_finite());
569        assert!(rvm.beta > 0.0);
570    }
571
572    #[test]
573    fn predict_dimension_mismatch() {
574        let n = 4usize;
575        let xs = vec![0.0_f64, 1.0, 2.0, 3.0];
576        let t = vec![0.0_f64, 1.0, 2.0, 3.0];
577        let rvm = Rvm::fit(
578            &xs,
579            n,
580            1,
581            &t,
582            RvmConfig {
583                kernel: RvmKernel::Linear,
584                ..Default::default()
585            },
586        )
587        .expect("ok");
588        assert!(matches!(
589            rvm.predict_one(&[1.0, 2.0]),
590            Err(CsError::DimensionMismatch { .. })
591        ));
592        // d = 1, so 3 values would be 3 valid queries; claim n_q = 5 to force a mismatch.
593        assert!(matches!(
594            rvm.predict(&[1.0, 2.0, 3.0], 5),
595            Err(CsError::ShapeMismatch { .. })
596        ));
597    }
598
599    #[test]
600    fn fit_rejects_bad_shapes() {
601        // t length mismatch.
602        assert!(matches!(
603            rvm_fit_design(&[1.0, 0.0, 0.0, 1.0], 2, 2, &[1.0], 10, 1e-6),
604            Err(CsError::DimensionMismatch { .. })
605        ));
606        // phi shape mismatch.
607        assert!(matches!(
608            rvm_fit_design(&[1.0, 0.0, 0.0], 2, 2, &[1.0, 0.0], 10, 1e-6),
609            Err(CsError::ShapeMismatch { .. })
610        ));
611        // zero size.
612        assert!(matches!(
613            rvm_fit_design(&[], 0, 0, &[], 10, 1e-6),
614            Err(CsError::InvalidParameter(_))
615        ));
616        // bad RBF length-scale.
617        assert!(matches!(
618            Rvm::fit(
619                &[0.0, 1.0],
620                2,
621                1,
622                &[0.0, 1.0],
623                RvmConfig {
624                    kernel: RvmKernel::Rbf { length_scale: -1.0 },
625                    ..Default::default()
626                }
627            ),
628            Err(CsError::InvalidParameter(_))
629        ));
630    }
631
632    #[test]
633    fn constant_target_uses_bias() {
634        // t ≡ 3 ⇒ best explained by the bias term alone.
635        let n = 7usize;
636        let xs: Vec<f64> = (0..n).map(|i| i as f64).collect();
637        let t = vec![3.0_f64; n];
638        let cfg = RvmConfig {
639            kernel: RvmKernel::Rbf { length_scale: 1.0 },
640            use_bias: true,
641            max_iter: 400,
642            tol: 1e-6,
643        };
644        let rvm = Rvm::fit(&xs, n, 1, &t, cfg).expect("ok");
645        let pred = rvm.predict_one(&[100.0]).expect("ok"); // far from data
646        assert!((pred - 3.0).abs() < 0.5, "pred = {pred}");
647    }
648}