Skip to main content

sklears_gaussian_process/
convolution_processes.rs

1//! Convolution Processes for multi-output Gaussian processes
2//!
3//! This module implements the *Convolution Process* (also called "process
4//! convolution" or "convolved multi-output GP") construction of Boyle & Frean
5//! (2005, "Dependent Gaussian Processes", NeurIPS) and Álvarez & Lawrence (2009,
6//! "Convolved Gaussian Process Models for Multi-output Regression", NeurIPS; see
7//! also Álvarez, Rosasco & Lawrence 2012, "Kernels for Vector-Valued Functions: A
8//! Review"). Several correlated output functions are modelled as convolutions of a
9//! small, *shared* set of latent white-noise processes with per-output *smoothing
10//! kernels*:
11//!
12//! ```text
13//! f_d(x) = sum_{q=1}^{Q}  ∫  G_{d,q}(x - z) u_q(z) dz ,   d = 1..D_outputs
14//! ```
15//!
16//! where `u_1, ..., u_Q` (`Q = n_latent`) are independent latent white-noise
17//! processes, `cov[u_q(z), u_q'(z')] = delta_{q,q'} * delta(z - z')`, and each
18//! output's smoothing kernel `G_{d,q}` is chosen Gaussian-shaped:
19//!
20//! ```text
21//! G_{d,q}(t) = S_{d,q} * N(t; 0, P_{d,q}) ,   P_{d,q} = diag(ell_{d,q,1}^2, ..., ell_{d,q,K}^2)
22//! ```
23//!
24//! with `N(.; 0, P)` the *normalized* zero-mean Gaussian density with covariance
25//! `P`, and `K` the number of input dimensions.
26//!
27//! # Closed-form induced covariance
28//!
29//! A convolution of two Gaussian densities is again Gaussian:
30//! `∫ N(x - z; 0, A) * N(x' - z; 0, B) dz = N(x - x'; 0, A + B)`. Applying this
31//! identity to `cov[f_d(x), f_d'(x')]` (the white-noise covariance collapses the
32//! double integral over `u_q` to a single integral over `z`) gives, summing the `Q`
33//! independent latent contributions:
34//!
35//! ```text
36//! k_{d,d'}(x, x') = sum_{q=1}^{Q} S_{d,q} S_{d',q} * N(x - x'; 0, P_{d,q} + P_{d',q})
37//! ```
38//!
39//! This is the "smoothing-kernel-width" parameterization: `P_{d,q}` (equivalently
40//! `ell_{d,q}`) is the covariance/width of output `d`'s smoothing kernel for latent
41//! process `q`. This module exposes, instead of the raw width `ell_{d,q}`, the
42//! *effective output length scale* `L_{d,q} in R^K` -- interpreted exactly like
43//! [`crate::kernels::ARDRBF`]'s `length_scales`, i.e. the length scale output `d`'s
44//! *own* auto-covariance would have -- together with a *signal variance*
45//! `v_{d,q} >= 0` (latent `q`'s contribution to `k_{d,d}(x,x)`), because those are
46//! the quantities a user actually reasons about. The two parameterizations are
47//! related by
48//!
49//! ```text
50//! ell_{d,q} = L_{d,q} / sqrt(2)   and   S_{d,q} = sqrt(v_{d,q}) * (2*pi)^(K/4) * sqrt(prod_k L_{d,q,k})
51//! ```
52//!
53//! chosen precisely so that substituting back collapses the induced covariance to:
54//!
55//! ```text
56//! k_{d,d'}(x,x') = sum_{q=1}^{Q} sqrt(v_{d,q} * v_{d',q})
57//!     * [ prod_{k=1}^{K} sqrt( 2 L_{d,q,k} L_{d',q,k} / (L_{d,q,k}^2 + L_{d',q,k}^2) ) ]
58//!     * exp( - sum_{k=1}^{K} (x_k - x'_k)^2 / (L_{d,q,k}^2 + L_{d',q,k}^2) )
59//! ```
60//!
61//! (implemented by `latent_covariance` / `induced_covariance_sum` below, in
62//! log-space for numerical stability). This has two properties that make it a
63//! trustworthy building block for a multi-output GP:
64//!
65//! * **Auto-covariance reduces to ARD-RBF.** For `d == d'` the bracketed prefactor
66//!   collapses to exactly `1` (each factor becomes `sqrt(2 L^2 / (2 L^2)) = 1`) and
67//!   the formula reduces *exactly* to a variance-scaled ARD-RBF kernel:
68//!   `k_{d,d}(x,x') = sum_q v_{d,q} * exp(-0.5 * sum_k (x_k - x'_k)^2 / L_{d,q,k}^2)`.
69//!   So with `n_latent = 1`, a single output, and the default `v_{0,0} = 1`, this
70//!   model's induced kernel is *identical* to [`crate::kernels::RBF`] /
71//!   [`crate::kernels::ARDRBF`] with the same length scale -- see
72//!   `test_degenerate_single_output_matches_standard_gpr` and
73//!   `test_auto_covariance_matches_ard_rbf`.
74//! * **Guaranteed positive semi-definiteness.** The full multi-output Gram matrix
75//!   (stacking every output's auto- and cross-covariance blocks) is PSD in exact
76//!   arithmetic for *any* choice of positive length scales/variances, because it is,
77//!   by construction, the covariance matrix of a finite collection of linear
78//!   functionals of the well-defined latent white-noise processes `u_q` -- it is not
79//!   an ad hoc matrix that merely happens to pass a PSD check after the fact. (Finite
80//!   precision can still make the matrix numerically indefinite, which is why
81//!   [`crate::utils::robust_cholesky`] -- with its adaptive nugget -- is used rather
82//!   than a bare Cholesky factorization.)
83//!
84//! # Data layout
85//!
86//! Unlike [`crate::linear_model_coregionalization`] and
87//! [`crate::intrinsic_coregionalization`] (which require every output to be observed
88//! at the *same* input locations, via `Y: (n_samples, n_outputs)`), a convolution
89//! process is fit from one `(X_d, y_d)` pair *per output*
90//! (`Fit<Vec<Array2<f64>>, Vec<Array1<f64>>>`): each output's smoothing kernel is
91//! independent of the others, so different outputs may be sampled at different
92//! locations, or in different numbers. This is one of the main practical advantages
93//! of the convolution-process construction over LMC/ICM, and is directly exercised
94//! by `test_two_correlated_outputs_information_sharing` (output 2 has far fewer
95//! observations than output 1, at different input locations, yet its predictions
96//! benefit from output 1's data through the induced cross-covariance).
97
98use scirs2_core::ndarray::{Array1, Array2, ArrayView1};
99use sklears_core::{
100    error::{Result as SklResult, SklearsError},
101    traits::{Estimator, Fit, Predict, Untrained},
102};
103
104use crate::utils;
105
106/// Configuration for a [`ConvolutionProcess`].
107///
108/// See the module documentation for the mathematical role of each parameter.
109#[derive(Debug, Clone)]
110pub struct ConvolutionProcessConfig {
111    /// Number of shared latent white-noise processes `Q`. Each output is a sum of
112    /// `Q` independent convolutions against these shared latents; `n_latent = 1`
113    /// (the default) is the simplest well-defined case, and is already sufficient
114    /// to induce full cross-output correlation.
115    pub n_latent: usize,
116    /// Default per-dimension effective output length scale `L_{d,q,k}`, used to
117    /// initialize every output/latent/dimension's length scale when an explicit
118    /// override is not supplied via [`ConvolutionProcess::length_scales`].
119    pub default_length_scale: f64,
120    /// Default noise variance added to the diagonal of every output's covariance
121    /// block, used when an explicit override is not supplied via
122    /// [`ConvolutionProcess::noise_variances`].
123    pub noise_variance: f64,
124}
125
126impl Default for ConvolutionProcessConfig {
127    fn default() -> Self {
128        Self {
129            n_latent: 1,
130            default_length_scale: 1.0,
131            noise_variance: 1e-10,
132        }
133    }
134}
135
136/// Convolution Process (a.k.a. Convolved / Dependent Gaussian Process) for
137/// multi-output regression.
138///
139/// See the module documentation for the generative model and the closed-form
140/// induced covariance this type trains against. Use the builder methods below to
141/// configure it, then call [`Fit::fit`] with one input matrix and one target vector
142/// *per output*.
143///
144/// # Examples
145///
146/// ```
147/// use sklears_gaussian_process::ConvolutionProcess;
148/// use sklears_core::traits::{Fit, Predict};
149/// use scirs2_core::ndarray::array;
150///
151/// let x1 = array![[0.0], [1.0], [2.0], [3.0]];
152/// let y1 = array![0.0, 1.0, 4.0, 9.0];
153/// let x2 = array![[0.5], [2.5]];
154/// let y2 = array![2.0, 17.0];
155///
156/// let cp = ConvolutionProcess::new().n_latent(1).noise_variance(1e-6);
157/// let fitted = cp
158///     .fit(&vec![x1, x2], &vec![y1, y2])
159///     .expect("fit should succeed with valid training data");
160/// let predictions = fitted
161///     .predict(&vec![array![[1.5]], array![[1.0]]])
162///     .expect("predict should succeed on a trained model");
163/// assert_eq!(predictions.len(), 2);
164/// ```
165#[derive(Debug, Clone)]
166pub struct ConvolutionProcess<S = Untrained> {
167    config: ConvolutionProcessConfig,
168    /// Optional explicit `[output][latent] -> per-dimension length scale` override.
169    /// When `None`, every output/latent/dimension defaults to
170    /// `config.default_length_scale` (resolved once the number of outputs and the
171    /// input dimensionality are known, at `fit` time).
172    length_scales: Option<Vec<Vec<Array1<f64>>>>,
173    /// Optional explicit `[output][latent] -> signal variance` override. When
174    /// `None`, defaults to `1 / n_latent` for every output/latent pair, so each
175    /// output's total prior variance `k_{d,d}(x,x)` is `1.0` by default (matching
176    /// the usual convention of a normalized kernel).
177    output_variances: Option<Vec<Vec<f64>>>,
178    /// Optional explicit per-output noise variance override. When `None`, every
179    /// output defaults to `config.noise_variance`.
180    noise_variances: Option<Vec<f64>>,
181    _state: S,
182}
183
184/// Trained state of a [`ConvolutionProcess`].
185#[derive(Debug, Clone)]
186#[allow(non_snake_case)]
187pub struct ConvolutionProcessTrained {
188    pub(crate) n_outputs: usize,
189    pub(crate) n_latent: usize,
190    pub(crate) n_features: usize,
191    /// Per-output training inputs, `X_train[d]` has shape `(n_samples_d, n_features)`.
192    pub(crate) X_train: Vec<Array2<f64>>,
193    /// Per-output training targets, `y_train[d]` has length `n_samples_d`.
194    #[allow(dead_code)]
195    pub(crate) y_train: Vec<Array1<f64>>,
196    /// Row offset of output `d`'s block within the stacked covariance / alpha
197    /// vector: output `d`'s rows occupy `offsets[d] .. offsets[d] + n_samples_d`.
198    pub(crate) offsets: Vec<usize>,
199    /// Total number of stacked training rows across all outputs.
200    pub(crate) n_total: usize,
201    /// Resolved `[output][latent] -> length scale` table actually used for training.
202    pub(crate) length_scales: Vec<Vec<Array1<f64>>>,
203    /// Resolved `[output][latent] -> signal variance` table actually used for training.
204    pub(crate) output_variances: Vec<Vec<f64>>,
205    /// Resolved per-output noise variance actually used for training.
206    pub(crate) noise_variances: Vec<f64>,
207    /// Cholesky factor of the stacked (noise-regularized) covariance matrix.
208    pub(crate) L: Array2<f64>,
209    /// Solved coefficients `alpha = K_reg^{-1} y` (stacked across outputs).
210    pub(crate) alpha: Array1<f64>,
211    pub(crate) log_marginal_likelihood_value: f64,
212}
213
214/// Bundle of fully-resolved (defaults-applied, validated) hyperparameters, computed
215/// once the number of outputs and input dimensionality are known at `fit` time.
216struct ResolvedHyperparameters {
217    n_features: usize,
218    length_scales: Vec<Vec<Array1<f64>>>,
219    output_variances: Vec<Vec<f64>>,
220    noise_variances: Vec<f64>,
221}
222
223impl Default for ConvolutionProcess<Untrained> {
224    fn default() -> Self {
225        Self::new()
226    }
227}
228
229#[allow(non_snake_case)]
230impl ConvolutionProcess<Untrained> {
231    /// Create a new, unconfigured Convolution Process (`n_latent = 1` by default).
232    pub fn new() -> Self {
233        Self {
234            config: ConvolutionProcessConfig::default(),
235            length_scales: None,
236            output_variances: None,
237            noise_variances: None,
238            _state: Untrained,
239        }
240    }
241
242    /// Set the number of shared latent processes `Q`.
243    pub fn n_latent(mut self, n_latent: usize) -> Self {
244        self.config.n_latent = n_latent;
245        self
246    }
247
248    /// Set the default per-dimension length scale used when [`Self::length_scales`]
249    /// is not supplied explicitly.
250    pub fn default_length_scale(mut self, length_scale: f64) -> Self {
251        self.config.default_length_scale = length_scale;
252        self
253    }
254
255    /// Set the default (uniform, per-output) noise variance used when
256    /// [`Self::noise_variances`] is not supplied explicitly.
257    pub fn noise_variance(mut self, noise_variance: f64) -> Self {
258        self.config.noise_variance = noise_variance;
259        self
260    }
261
262    /// Explicitly set the `[output][latent] -> per-dimension length scale` table.
263    /// The outer `Vec` must have one entry per output (matching the length of the
264    /// `X` passed to [`Fit::fit`]), each inner `Vec` one entry per latent process
265    /// (matching [`Self::n_latent`]), and each [`Array1`] one length scale per input
266    /// dimension. Validated (and defaulted, if omitted) at `fit` time.
267    pub fn length_scales(mut self, length_scales: Vec<Vec<Array1<f64>>>) -> Self {
268        self.length_scales = Some(length_scales);
269        self
270    }
271
272    /// Explicitly set the `[output][latent] -> signal variance` table (same outer/
273    /// inner shape as [`Self::length_scales`], minus the per-dimension axis).
274    pub fn output_variances(mut self, output_variances: Vec<Vec<f64>>) -> Self {
275        self.output_variances = Some(output_variances);
276        self
277    }
278
279    /// Explicitly set a per-output noise variance (one entry per output).
280    pub fn noise_variances(mut self, noise_variances: Vec<f64>) -> Self {
281        self.noise_variances = Some(noise_variances);
282        self
283    }
284
285    /// Resolve (validating or defaulting) the hyperparameters needed to fit against
286    /// `X`, an owning `(n_outputs)`-length collection of per-output design matrices.
287    fn resolve_hyperparameters(&self, X: &[Array2<f64>]) -> SklResult<ResolvedHyperparameters> {
288        let n_outputs = X.len();
289        if n_outputs == 0 {
290            return Err(SklearsError::InvalidInput(
291                "ConvolutionProcess requires at least one output".to_string(),
292            ));
293        }
294        if self.config.n_latent == 0 {
295            return Err(SklearsError::InvalidInput(
296                "n_latent must be at least 1".to_string(),
297            ));
298        }
299        let n_features = X[0].ncols();
300        if n_features == 0 {
301            return Err(SklearsError::InvalidInput(
302                "Input data must have at least one feature".to_string(),
303            ));
304        }
305        for (d, Xd) in X.iter().enumerate() {
306            if Xd.ncols() != n_features {
307                return Err(SklearsError::InvalidInput(format!(
308                    "Output {} has {} input features, but output 0 has {}; every \
309                     output must share the same input dimensionality",
310                    d,
311                    Xd.ncols(),
312                    n_features
313                )));
314            }
315            if Xd.nrows() == 0 {
316                return Err(SklearsError::InvalidInput(format!(
317                    "Output {} has no training samples",
318                    d
319                )));
320            }
321        }
322
323        let n_latent = self.config.n_latent;
324
325        let length_scales = if let Some(ref ls) = self.length_scales {
326            if ls.len() != n_outputs {
327                return Err(SklearsError::InvalidInput(format!(
328                    "length_scales has {} outputs, expected {}",
329                    ls.len(),
330                    n_outputs
331                )));
332            }
333            for (d, ls_d) in ls.iter().enumerate() {
334                if ls_d.len() != n_latent {
335                    return Err(SklearsError::InvalidInput(format!(
336                        "length_scales[{}] has {} latent entries, expected n_latent={}",
337                        d,
338                        ls_d.len(),
339                        n_latent
340                    )));
341                }
342                for (q, ls_dq) in ls_d.iter().enumerate() {
343                    if ls_dq.len() != n_features {
344                        return Err(SklearsError::InvalidInput(format!(
345                            "length_scales[{}][{}] has {} dimensions, expected {}",
346                            d,
347                            q,
348                            ls_dq.len(),
349                            n_features
350                        )));
351                    }
352                    if ls_dq.iter().any(|&v| v <= 0.0 || !v.is_finite()) {
353                        return Err(SklearsError::InvalidInput(format!(
354                            "length_scales[{}][{}] must be strictly positive and finite",
355                            d, q
356                        )));
357                    }
358                }
359            }
360            ls.clone()
361        } else {
362            if self.config.default_length_scale <= 0.0
363                || !self.config.default_length_scale.is_finite()
364            {
365                return Err(SklearsError::InvalidInput(
366                    "default_length_scale must be strictly positive and finite".to_string(),
367                ));
368            }
369            (0..n_outputs)
370                .map(|_| {
371                    (0..n_latent)
372                        .map(|_| Array1::from_elem(n_features, self.config.default_length_scale))
373                        .collect()
374                })
375                .collect()
376        };
377
378        let output_variances = if let Some(ref ov) = self.output_variances {
379            if ov.len() != n_outputs {
380                return Err(SklearsError::InvalidInput(format!(
381                    "output_variances has {} outputs, expected {}",
382                    ov.len(),
383                    n_outputs
384                )));
385            }
386            for (d, ov_d) in ov.iter().enumerate() {
387                if ov_d.len() != n_latent {
388                    return Err(SklearsError::InvalidInput(format!(
389                        "output_variances[{}] has {} latent entries, expected n_latent={}",
390                        d,
391                        ov_d.len(),
392                        n_latent
393                    )));
394                }
395                if ov_d.iter().any(|&v| v < 0.0 || !v.is_finite()) {
396                    return Err(SklearsError::InvalidInput(format!(
397                        "output_variances[{}] must be non-negative and finite",
398                        d
399                    )));
400                }
401            }
402            ov.clone()
403        } else {
404            let default_variance = 1.0 / n_latent as f64;
405            vec![vec![default_variance; n_latent]; n_outputs]
406        };
407
408        let noise_variances = if let Some(ref nv) = self.noise_variances {
409            if nv.len() != n_outputs {
410                return Err(SklearsError::InvalidInput(format!(
411                    "noise_variances has {} entries, expected {}",
412                    nv.len(),
413                    n_outputs
414                )));
415            }
416            if nv.iter().any(|&v| v < 0.0 || !v.is_finite()) {
417                return Err(SklearsError::InvalidInput(
418                    "noise_variances must be non-negative and finite".to_string(),
419                ));
420            }
421            nv.clone()
422        } else {
423            if self.config.noise_variance < 0.0 || !self.config.noise_variance.is_finite() {
424                return Err(SklearsError::InvalidInput(
425                    "noise_variance must be non-negative and finite".to_string(),
426                ));
427            }
428            vec![self.config.noise_variance; n_outputs]
429        };
430
431        Ok(ResolvedHyperparameters {
432            n_features,
433            length_scales,
434            output_variances,
435            noise_variances,
436        })
437    }
438}
439
440/// Closed-form induced covariance contributed by a *single* latent process between
441/// two (output, point) pairs. See the module documentation for the derivation; `l1`,
442/// `l2` are the two outputs' per-dimension effective length scales `L_{d,q,:}` and
443/// `L_{d',q,:}` for this latent process, `v1`, `v2` their signal variances.
444///
445/// Computed in log-space (`log_shape - exponent`, exponentiated once at the end)
446/// since both the prefactor (a product over dimensions) and the exponential decay
447/// can otherwise under/overflow for higher-dimensional inputs.
448fn latent_covariance(
449    x1: &ArrayView1<f64>,
450    x2: &ArrayView1<f64>,
451    l1: &Array1<f64>,
452    l2: &Array1<f64>,
453    v1: f64,
454    v2: f64,
455) -> f64 {
456    if v1 <= 0.0 || v2 <= 0.0 {
457        return 0.0;
458    }
459    let n_dims = l1.len();
460    let mut log_shape = 0.0_f64;
461    let mut exponent = 0.0_f64;
462    for k in 0..n_dims {
463        let l1k = l1[k];
464        let l2k = l2[k];
465        let sum_sq = l1k * l1k + l2k * l2k;
466        // By AM-GM, 2*l1k*l2k <= sum_sq, so this ratio is in (0, 1] and the log is <= 0:
467        // mismatched length scales strictly attenuate the cross-covariance amplitude.
468        log_shape += 0.5 * (2.0 * l1k * l2k / sum_sq).ln();
469        let diff = x1[k] - x2[k];
470        exponent += diff * diff / sum_sq;
471    }
472    let amplitude = (v1 * v2).sqrt();
473    amplitude * (log_shape - exponent).exp()
474}
475
476/// Sum of [`latent_covariance`] over all `n_latent` shared latent processes, i.e.
477/// the full induced covariance `k_{d,d'}(x1, x2)` between output `d` (parameterized
478/// by `ls_d`/`ov_d`) and output `d'` (parameterized by `ls_dp`/`ov_dp`).
479fn induced_covariance_sum(
480    x1: &ArrayView1<f64>,
481    x2: &ArrayView1<f64>,
482    ls_d: &[Array1<f64>],
483    ls_dp: &[Array1<f64>],
484    ov_d: &[f64],
485    ov_dp: &[f64],
486) -> f64 {
487    let n_latent = ls_d.len();
488    let mut total = 0.0;
489    for q in 0..n_latent {
490        total += latent_covariance(x1, x2, &ls_d[q], &ls_dp[q], ov_d[q], ov_dp[q]);
491    }
492    total
493}
494
495/// Compute the row offset of each output's block within the stacked training
496/// vector/matrix, plus the total stacked size.
497#[allow(non_snake_case)]
498fn compute_offsets(X: &[Array2<f64>]) -> (Vec<usize>, usize) {
499    let mut offsets = Vec::with_capacity(X.len());
500    let mut total = 0usize;
501    for Xd in X {
502        offsets.push(total);
503        total += Xd.nrows();
504    }
505    (offsets, total)
506}
507
508/// Build the full stacked multi-output covariance matrix (no noise added) between
509/// every training point of every output, using the given (already-resolved)
510/// hyperparameters. Block `(d, d')` of the result is the induced cross-covariance
511/// `k_{d,d'}(X[d], X[d'])`; the diagonal blocks (`d == d'`) are each output's
512/// auto-covariance.
513#[allow(non_snake_case)]
514fn build_stacked_covariance(
515    X: &[Array2<f64>],
516    length_scales: &[Vec<Array1<f64>>],
517    output_variances: &[Vec<f64>],
518    offsets: &[usize],
519    n_total: usize,
520) -> Array2<f64> {
521    let n_outputs = X.len();
522    let mut K = Array2::<f64>::zeros((n_total, n_total));
523    for d in 0..n_outputs {
524        for i in 0..X[d].nrows() {
525            let row_idx = offsets[d] + i;
526            let xi = X[d].row(i);
527            for dp in 0..n_outputs {
528                for j in 0..X[dp].nrows() {
529                    let col_idx = offsets[dp] + j;
530                    let xj = X[dp].row(j);
531                    K[[row_idx, col_idx]] = induced_covariance_sum(
532                        &xi,
533                        &xj,
534                        &length_scales[d],
535                        &length_scales[dp],
536                        &output_variances[d],
537                        &output_variances[dp],
538                    );
539                }
540            }
541        }
542    }
543    K
544}
545
546impl Estimator for ConvolutionProcess<Untrained> {
547    type Config = ConvolutionProcessConfig;
548    type Error = SklearsError;
549    type Float = f64;
550
551    fn config(&self) -> &Self::Config {
552        &self.config
553    }
554}
555
556impl Estimator for ConvolutionProcess<ConvolutionProcessTrained> {
557    type Config = ConvolutionProcessConfig;
558    type Error = SklearsError;
559    type Float = f64;
560
561    fn config(&self) -> &Self::Config {
562        &self.config
563    }
564}
565
566#[allow(non_snake_case)]
567impl Fit<Vec<Array2<f64>>, Vec<Array1<f64>>> for ConvolutionProcess<Untrained> {
568    type Fitted = ConvolutionProcess<ConvolutionProcessTrained>;
569
570    fn fit(self, X: &Vec<Array2<f64>>, Y: &Vec<Array1<f64>>) -> SklResult<Self::Fitted> {
571        if X.len() != Y.len() {
572            return Err(SklearsError::InvalidInput(format!(
573                "Number of outputs in X ({}) must match Y ({})",
574                X.len(),
575                Y.len()
576            )));
577        }
578        for (d, (Xd, Yd)) in X.iter().zip(Y.iter()).enumerate() {
579            if Xd.nrows() != Yd.len() {
580                return Err(SklearsError::InvalidInput(format!(
581                    "Output {}: X has {} samples but y has {}",
582                    d,
583                    Xd.nrows(),
584                    Yd.len()
585                )));
586            }
587        }
588
589        let ResolvedHyperparameters {
590            n_features,
591            length_scales,
592            output_variances,
593            noise_variances,
594        } = self.resolve_hyperparameters(X)?;
595
596        let n_outputs = X.len();
597        let n_latent = self.config.n_latent;
598        let (offsets, n_total) = compute_offsets(X);
599
600        let mut K =
601            build_stacked_covariance(X, &length_scales, &output_variances, &offsets, n_total);
602        for d in 0..n_outputs {
603            for i in 0..X[d].nrows() {
604                K[[offsets[d] + i, offsets[d] + i]] += noise_variances[d];
605            }
606        }
607
608        let L = utils::robust_cholesky(&K)?;
609
610        let mut y_stacked = Array1::<f64>::zeros(n_total);
611        for d in 0..n_outputs {
612            for i in 0..Y[d].len() {
613                y_stacked[offsets[d] + i] = Y[d][i];
614            }
615        }
616
617        let alpha = utils::triangular_solve(&L, &y_stacked)?;
618        let log_marginal_likelihood_value = utils::log_marginal_likelihood(&L, &alpha, &y_stacked);
619
620        let trained = ConvolutionProcessTrained {
621            n_outputs,
622            n_latent,
623            n_features,
624            X_train: X.clone(),
625            y_train: Y.clone(),
626            offsets,
627            n_total,
628            length_scales,
629            output_variances,
630            noise_variances,
631            L,
632            alpha,
633            log_marginal_likelihood_value,
634        };
635
636        Ok(ConvolutionProcess {
637            config: self.config,
638            length_scales: self.length_scales,
639            output_variances: self.output_variances,
640            noise_variances: self.noise_variances,
641            _state: trained,
642        })
643    }
644}
645
646#[allow(non_snake_case)]
647impl ConvolutionProcess<ConvolutionProcessTrained> {
648    /// Access the full trained state.
649    pub fn trained_state(&self) -> &ConvolutionProcessTrained {
650        &self._state
651    }
652
653    /// Number of outputs the model was fitted on.
654    pub fn n_outputs(&self) -> usize {
655        self._state.n_outputs
656    }
657
658    /// Number of shared latent processes `Q`.
659    pub fn n_latent(&self) -> usize {
660        self._state.n_latent
661    }
662
663    /// Log marginal likelihood of the training data under the fitted model.
664    pub fn log_marginal_likelihood(&self) -> f64 {
665        self._state.log_marginal_likelihood_value
666    }
667
668    /// Resolved per-output noise variance actually used for training (one entry per
669    /// output, in fit order).
670    pub fn noise_variances(&self) -> &[f64] {
671        &self._state.noise_variances
672    }
673
674    /// Evaluate the model's induced covariance `k_{d,d'}(x, x')` between output
675    /// `output_d` and output `output_dp` (which may be equal) at arbitrary points,
676    /// using the fitted (or defaulted) hyperparameters. See the module
677    /// documentation for the closed-form formula being evaluated.
678    ///
679    /// This is the direct, low-level way to inspect whether -- and how strongly --
680    /// the model represents correlation between two outputs: for two genuinely
681    /// independent single-output GPs this would be identically zero everywhere,
682    /// whereas a convolution process sharing at least one latent process between
683    /// `output_d` and `output_dp` gives a nontrivial value.
684    pub fn induced_covariance(
685        &self,
686        output_d: usize,
687        output_dp: usize,
688        x: &ArrayView1<f64>,
689        xp: &ArrayView1<f64>,
690    ) -> SklResult<f64> {
691        let state = &self._state;
692        if output_d >= state.n_outputs || output_dp >= state.n_outputs {
693            return Err(SklearsError::InvalidInput(format!(
694                "Output index out of range: got ({}, {}), model has {} outputs",
695                output_d, output_dp, state.n_outputs
696            )));
697        }
698        if x.len() != state.n_features || xp.len() != state.n_features {
699            return Err(SklearsError::InvalidInput(format!(
700                "Expected points with {} features",
701                state.n_features
702            )));
703        }
704        Ok(induced_covariance_sum(
705            x,
706            xp,
707            &state.length_scales[output_d],
708            &state.length_scales[output_dp],
709            &state.output_variances[output_d],
710            &state.output_variances[output_dp],
711        ))
712    }
713
714    /// Rebuild the full (noise-free) stacked multi-output covariance matrix over
715    /// the training points, using the fitted hyperparameters. This is the matrix
716    /// whose positive semi-definiteness is the key theoretical guarantee of the
717    /// convolution-process construction (see module docs); exposed for diagnostics
718    /// and testing.
719    pub fn training_covariance_matrix(&self) -> Array2<f64> {
720        let state = &self._state;
721        build_stacked_covariance(
722            &state.X_train,
723            &state.length_scales,
724            &state.output_variances,
725            &state.offsets,
726            state.n_total,
727        )
728    }
729
730    /// Predict the posterior mean and standard deviation for each output at the
731    /// given per-output test points. `X_test` must have exactly `n_outputs()`
732    /// entries (one design matrix per output, in the same order as at `fit` time);
733    /// pass a zero-row matrix for any output you are not interested in predicting.
734    pub fn predict_with_std(
735        &self,
736        X_test: &[Array2<f64>],
737    ) -> SklResult<Vec<(Array1<f64>, Array1<f64>)>> {
738        let state = &self._state;
739        if X_test.len() != state.n_outputs {
740            return Err(SklearsError::InvalidInput(format!(
741                "predict_with_std expected {} outputs, got {}",
742                state.n_outputs,
743                X_test.len()
744            )));
745        }
746
747        let mut results = Vec::with_capacity(state.n_outputs);
748        for (d, Xd) in X_test.iter().enumerate() {
749            if Xd.nrows() > 0 && Xd.ncols() != state.n_features {
750                return Err(SklearsError::InvalidInput(format!(
751                    "Output {} test inputs have {} features, expected {}",
752                    d,
753                    Xd.ncols(),
754                    state.n_features
755                )));
756            }
757            let n_star = Xd.nrows();
758            let mut mean_d = Array1::<f64>::zeros(n_star);
759            let mut std_d = Array1::<f64>::zeros(n_star);
760
761            for t in 0..n_star {
762                let x_star = Xd.row(t);
763
764                let mut k_star = Array1::<f64>::zeros(state.n_total);
765                for dp in 0..state.n_outputs {
766                    for j in 0..state.X_train[dp].nrows() {
767                        let x_train_j = state.X_train[dp].row(j);
768                        k_star[state.offsets[dp] + j] = induced_covariance_sum(
769                            &x_star,
770                            &x_train_j,
771                            &state.length_scales[d],
772                            &state.length_scales[dp],
773                            &state.output_variances[d],
774                            &state.output_variances[dp],
775                        );
776                    }
777                }
778
779                let mean = k_star.dot(&state.alpha);
780
781                // Predictive variance: k_** - k_*^T K_reg^{-1} k_*, mirroring
782                // gpr.rs::predict_with_std -- `triangular_solve` performs a *full*
783                // solve, so the quadratic form dots the *original* k_star against
784                // the solved vector `v`, not `v` against itself.
785                let v = utils::triangular_solve(&state.L, &k_star)?;
786                let k_star_star = induced_covariance_sum(
787                    &x_star,
788                    &x_star,
789                    &state.length_scales[d],
790                    &state.length_scales[d],
791                    &state.output_variances[d],
792                    &state.output_variances[d],
793                );
794                let var = (k_star_star - k_star.dot(&v)).max(0.0);
795
796                mean_d[t] = mean;
797                std_d[t] = var.sqrt();
798            }
799
800            results.push((mean_d, std_d));
801        }
802        Ok(results)
803    }
804}
805
806#[allow(non_snake_case)]
807impl Predict<Vec<Array2<f64>>, Vec<Array1<f64>>> for ConvolutionProcess<ConvolutionProcessTrained> {
808    fn predict(&self, X: &Vec<Array2<f64>>) -> SklResult<Vec<Array1<f64>>> {
809        let with_std = self.predict_with_std(X)?;
810        Ok(with_std.into_iter().map(|(mean, _std)| mean).collect())
811    }
812}
813
814#[allow(non_snake_case)]
815#[cfg(test)]
816mod tests {
817    use super::*;
818    use crate::gpr::GaussianProcessRegressor;
819    use crate::kernels::{Kernel, ARDRBF, RBF};
820    use approx::assert_abs_diff_eq;
821    // SciRS2 Policy - Use scirs2-core for array! macro and types
822    use scirs2_core::ndarray::array;
823
824    #[test]
825    fn test_convolution_process_creation() {
826        let cp = ConvolutionProcess::new().n_latent(3).noise_variance(1e-4);
827        assert_eq!(cp.config.n_latent, 3);
828        assert_abs_diff_eq!(cp.config.noise_variance, 1e-4, epsilon = 1e-16);
829        assert_abs_diff_eq!(cp.config.default_length_scale, 1.0, epsilon = 1e-16);
830    }
831
832    #[test]
833    fn test_convolution_process_fit_predict_shapes() {
834        let x1 = array![[0.0], [1.0], [2.0]];
835        let y1 = array![1.0, 2.0, 1.5];
836        let x2 = array![[0.5], [1.5]];
837        let y2 = array![0.5, 1.0];
838
839        let cp = ConvolutionProcess::new().n_latent(1).noise_variance(1e-6);
840        let fitted = cp
841            .fit(&vec![x1, x2], &vec![y1, y2])
842            .expect("fit should succeed");
843
844        assert_eq!(fitted.n_outputs(), 2);
845        assert_eq!(fitted.n_latent(), 1);
846        assert!(fitted.log_marginal_likelihood().is_finite());
847
848        let x1_test = array![[0.25], [1.25]];
849        let x2_test = array![[0.75]];
850        let preds = fitted
851            .predict(&vec![x1_test.clone(), x2_test.clone()])
852            .expect("predict should succeed");
853
854        assert_eq!(preds.len(), 2);
855        assert_eq!(preds[0].len(), 2);
856        assert_eq!(preds[1].len(), 1);
857
858        let with_std = fitted
859            .predict_with_std(&[x1_test, x2_test])
860            .expect("predict_with_std should succeed");
861        assert_eq!(with_std.len(), 2);
862        for (mean, std) in &with_std {
863            for &s in std.iter() {
864                assert!(s >= 0.0, "std must be non-negative, got {}", s);
865            }
866            for &m in mean.iter() {
867                assert!(m.is_finite());
868            }
869        }
870    }
871
872    #[test]
873    fn test_convolution_process_errors() {
874        let x1 = array![[0.0], [1.0]];
875        let y1 = array![1.0, 2.0];
876
877        // Mismatched number of outputs between X and y.
878        let cp = ConvolutionProcess::new();
879        let result = cp.fit(&vec![x1.clone()], &vec![y1.clone(), array![0.0]]);
880        assert!(result.is_err());
881
882        // Mismatched sample count within an output.
883        let cp = ConvolutionProcess::new();
884        let bad_y = array![1.0, 2.0, 3.0];
885        let result = cp.fit(&vec![x1.clone()], &vec![bad_y]);
886        assert!(result.is_err());
887
888        // n_latent = 0 is invalid.
889        let cp = ConvolutionProcess::new().n_latent(0);
890        let result = cp.fit(&vec![x1.clone()], &vec![y1.clone()]);
891        assert!(result.is_err());
892
893        // Non-positive default length scale is invalid.
894        let cp = ConvolutionProcess::new().default_length_scale(0.0);
895        let result = cp.fit(&vec![x1.clone()], &vec![y1.clone()]);
896        assert!(result.is_err());
897
898        // Empty output list.
899        let cp = ConvolutionProcess::new();
900        let result = cp.fit(&Vec::<Array2<f64>>::new(), &Vec::<Array1<f64>>::new());
901        assert!(result.is_err());
902
903        // Mismatched feature dimensionality across outputs.
904        let cp = ConvolutionProcess::new();
905        let x2_wrong_dim = array![[0.0, 1.0], [1.0, 2.0]];
906        let y2 = array![1.0, 2.0];
907        let result = cp.fit(&vec![x1.clone(), x2_wrong_dim], &vec![y1.clone(), y2]);
908        assert!(result.is_err());
909    }
910
911    #[test]
912    fn test_log_marginal_likelihood_finite() {
913        let x1 = array![[0.0], [1.0], [2.0], [3.0]];
914        let y1 = array![1.0, 2.0, 3.0, 4.0];
915
916        let cp = ConvolutionProcess::new().n_latent(1).noise_variance(1e-6);
917        let fitted = cp.fit(&vec![x1], &vec![y1]).expect("fit should succeed");
918
919        assert!(fitted.log_marginal_likelihood().is_finite());
920    }
921
922    /// **Required test 1: PSD check.** The induced multi-output covariance matrix
923    /// (over a small two-output toy dataset, with a nontrivial `n_latent = 2`
924    /// structure using distinct length scales per output/latent) must be positive
925    /// semi-definite: `fit()` succeeding demonstrates a successful Cholesky
926    /// factorization of `K + noise*I`, and we additionally verify every eigenvalue
927    /// of the raw (noise-free) induced covariance matrix is non-negative.
928    #[test]
929    fn test_psd_multi_output_covariance() {
930        let x1 = array![[0.0], [1.0], [2.0]];
931        let y1 = array![0.1, 0.4, 0.2];
932        let x2 = array![[0.2], [0.9], [2.1], [3.0]];
933        let y2 = array![0.3, 0.5, 0.1, 0.6];
934
935        let length_scales = vec![
936            vec![Array1::from_elem(1, 0.5), Array1::from_elem(1, 2.0)],
937            vec![Array1::from_elem(1, 0.8), Array1::from_elem(1, 1.5)],
938        ];
939        let output_variances = vec![vec![0.6, 0.4], vec![0.3, 0.7]];
940
941        let cp = ConvolutionProcess::new()
942            .n_latent(2)
943            .length_scales(length_scales)
944            .output_variances(output_variances)
945            .noise_variance(1e-6);
946
947        let fitted = cp.fit(&vec![x1, x2], &vec![y1, y2]).expect(
948            "fit should succeed: the stacked covariance must be PSD for robust_cholesky to succeed",
949        );
950
951        let k = fitted.training_covariance_matrix();
952        assert_eq!(k.shape(), &[7, 7]);
953
954        // Symmetric.
955        for i in 0..k.nrows() {
956            for j in 0..k.ncols() {
957                assert_abs_diff_eq!(k[[i, j]], k[[j, i]], epsilon = 1e-10);
958            }
959        }
960
961        // Every eigenvalue is non-negative (up to numerical tolerance).
962        use scirs2_linalg::compat::{ArrayLinalgExt, UPLO};
963        let (eigenvalues, _eigenvectors) = k
964            .eigh(UPLO::Lower)
965            .expect("eigendecomposition should succeed");
966        for &ev in eigenvalues.iter() {
967            assert!(
968                ev >= -1e-8,
969                "covariance matrix has a negative eigenvalue: {}",
970                ev
971            );
972        }
973    }
974
975    /// **Required test 2: degenerate case.** With `n_latent = 1` and a single
976    /// output, the model's induced kernel is mathematically identical to a
977    /// standard RBF kernel (see module docs), so its posterior mean/std should
978    /// match [`GaussianProcessRegressor`] with an [`RBF`] kernel of the same
979    /// length scale, essentially exactly (both ultimately call the same
980    /// `robust_cholesky` / `triangular_solve` routines over the same matrix).
981    #[test]
982    fn test_degenerate_single_output_matches_standard_gpr() {
983        let x = array![[0.0], [1.0], [2.0], [3.0], [4.0]];
984        let y = array![0.5, 2.0, 1.5, 3.0, 2.5];
985        let length_scale = 1.3;
986        let noise = 1e-6;
987
988        let cp = ConvolutionProcess::new()
989            .n_latent(1)
990            .default_length_scale(length_scale)
991            .noise_variance(noise);
992        let cp_fitted = cp
993            .fit(&vec![x.clone()], &vec![y.clone()])
994            .expect("fit should succeed");
995
996        let gpr = GaussianProcessRegressor::new()
997            .kernel(Box::new(RBF::new(length_scale)))
998            .alpha(noise);
999        let gpr_fitted = gpr.fit(&x, &y).expect("fit should succeed");
1000
1001        let x_test = array![[0.5], [1.5], [2.5], [3.5], [5.0]];
1002        let cp_result = cp_fitted
1003            .predict_with_std(std::slice::from_ref(&x_test))
1004            .expect("predict should succeed");
1005        let (cp_mean, cp_std) = &cp_result[0];
1006        let (gpr_mean, gpr_std) = gpr_fitted
1007            .predict_with_std(&x_test)
1008            .expect("predict should succeed");
1009
1010        for i in 0..x_test.nrows() {
1011            assert_abs_diff_eq!(cp_mean[i], gpr_mean[i], epsilon = 1e-6);
1012            assert_abs_diff_eq!(cp_std[i], gpr_std[i], epsilon = 1e-6);
1013        }
1014
1015        // Also cross-check the log marginal likelihoods, computed independently by
1016        // the two implementations over what should be the same K + noise*I matrix.
1017        assert_abs_diff_eq!(
1018            cp_fitted.log_marginal_likelihood(),
1019            gpr_fitted.log_marginal_likelihood(),
1020            epsilon = 1e-6
1021        );
1022    }
1023
1024    /// The single-output/single-latent auto-covariance must equal
1025    /// `variance * ARDRBF(length_scales)` exactly, directly checking the closed-form
1026    /// formula (isolated from the rest of the GP machinery) against a known-correct
1027    /// reference kernel implementation.
1028    #[test]
1029    fn test_auto_covariance_matches_ard_rbf() {
1030        let length_scales = Array1::from_vec(vec![0.7, 1.3]);
1031        let variance = 2.5;
1032
1033        let cp = ConvolutionProcess::new()
1034            .n_latent(1)
1035            .length_scales(vec![vec![length_scales.clone()]])
1036            .output_variances(vec![vec![variance]])
1037            .noise_variance(1e-8);
1038
1039        let x = array![[0.0, 0.0], [1.0, 2.0]];
1040        let y = array![0.0, 0.0];
1041        let fitted = cp.fit(&vec![x], &vec![y]).expect("fit should succeed");
1042
1043        let ard = ARDRBF::new(length_scales);
1044        let x1 = array![0.3, -0.2];
1045        let x2 = array![1.1, 0.4];
1046
1047        let cp_val = fitted
1048            .induced_covariance(0, 0, &x1.view(), &x2.view())
1049            .expect("induced_covariance should succeed");
1050        let ard_val = variance * ard.kernel(&x1.view(), &x2.view());
1051
1052        assert_abs_diff_eq!(cp_val, ard_val, epsilon = 1e-10);
1053
1054        // And at x1 == x2, the auto-covariance must equal the variance exactly
1055        // (ARD-RBF kernel is 1 at zero distance).
1056        let self_cov = fitted
1057            .induced_covariance(0, 0, &x1.view(), &x1.view())
1058            .expect("induced_covariance should succeed");
1059        assert_abs_diff_eq!(self_cov, variance, epsilon = 1e-10);
1060    }
1061
1062    /// The induced cross-covariance must be symmetric under swapping (output,
1063    /// point) pairs, and must respect the Cauchy-Schwarz bound
1064    /// `|k_{d,d'}(x,x)| <= sqrt(k_{d,d}(x,x) * k_{d',d'}(x,x))` that any valid
1065    /// cross-covariance of jointly well-defined processes has to satisfy.
1066    #[test]
1067    fn test_induced_covariance_symmetry_and_cauchy_schwarz() {
1068        let length_scales = vec![
1069            vec![Array1::from_elem(1, 0.6)],
1070            vec![Array1::from_elem(1, 1.4)],
1071        ];
1072        let output_variances = vec![vec![1.2], vec![0.8]];
1073
1074        let x1 = array![[0.0], [1.0]];
1075        let y1 = array![0.0, 0.0];
1076        let x2 = array![[0.5], [1.5]];
1077        let y2 = array![0.0, 0.0];
1078
1079        let cp = ConvolutionProcess::new()
1080            .n_latent(1)
1081            .length_scales(length_scales)
1082            .output_variances(output_variances)
1083            .noise_variance(1e-8);
1084        let fitted = cp
1085            .fit(&vec![x1, x2], &vec![y1, y2])
1086            .expect("fit should succeed");
1087
1088        let a = array![0.3];
1089        let b = array![1.7];
1090
1091        let k01 = fitted
1092            .induced_covariance(0, 1, &a.view(), &b.view())
1093            .expect("induced_covariance should succeed");
1094        let k10 = fitted
1095            .induced_covariance(1, 0, &b.view(), &a.view())
1096            .expect("induced_covariance should succeed");
1097        assert_abs_diff_eq!(k01, k10, epsilon = 1e-12);
1098
1099        let k00 = fitted
1100            .induced_covariance(0, 0, &a.view(), &a.view())
1101            .expect("induced_covariance should succeed");
1102        let k11 = fitted
1103            .induced_covariance(1, 1, &a.view(), &a.view())
1104            .expect("induced_covariance should succeed");
1105        let k01_same = fitted
1106            .induced_covariance(0, 1, &a.view(), &a.view())
1107            .expect("induced_covariance should succeed");
1108        assert!(
1109            k01_same.abs() <= (k00 * k11).sqrt() + 1e-12,
1110            "cross-covariance {} violates Cauchy-Schwarz bound {}",
1111            k01_same,
1112            (k00 * k11).sqrt()
1113        );
1114        // With distinct length scales the bound is strict (not attained).
1115        assert!(k01_same.abs() < (k00 * k11).sqrt());
1116    }
1117
1118    /// **Required test 3: two correlated outputs.** Output 2 is a scaled version of
1119    /// output 1 (`y2 = 2 * y1`), driven by the same shared latent process, but is
1120    /// only sparsely observed at locations *disjoint* from output 1's. We verify
1121    /// both (a) the fitted model's cross-covariance between the two outputs is
1122    /// nontrivially nonzero, and (b) that this cross-covariance actually gets used
1123    /// during inference: predicting output 2 far from output 2's own data is
1124    /// substantially more accurate under the joint convolution-process fit than
1125    /// under a standalone single-output GP fit only on output 2's sparse data --
1126    /// i.e. the model is not equivalent to two independent single-output GPs.
1127    #[test]
1128    fn test_two_correlated_outputs_information_sharing() {
1129        // Output 1: densely sampled sin(x).
1130        let x1 = array![[0.0], [0.5], [1.0], [1.5], [2.0], [2.5], [3.0]];
1131        let y1: Array1<f64> = x1.column(0).mapv(|v: f64| v.sin());
1132
1133        // Output 2: y2 = 2 * sin(x), sparsely sampled at locations disjoint from x1.
1134        let x2 = array![[0.25], [1.25]];
1135        let y2: Array1<f64> = x2.column(0).mapv(|v: f64| 2.0 * v.sin());
1136
1137        let length_scale = 0.8;
1138        let noise = 1e-4;
1139
1140        // Signal variances scaled 1:4, matching Var[2*f] = 4*Var[f] -- i.e. the
1141        // model's prior belief about the two outputs' relationship matches the
1142        // true generative scaling of 2x.
1143        let cp = ConvolutionProcess::new()
1144            .n_latent(1)
1145            .default_length_scale(length_scale)
1146            .output_variances(vec![vec![1.0], vec![4.0]])
1147            .noise_variance(noise);
1148
1149        let fitted = cp
1150            .fit(&vec![x1, x2.clone()], &vec![y1, y2.clone()])
1151            .expect("fit should succeed");
1152
1153        // (a) Structural check: nontrivially nonzero cross-covariance. Two
1154        // independent single-output GPs would have cross-covariance identically
1155        // zero everywhere.
1156        let probe = array![1.0];
1157        let cross = fitted
1158            .induced_covariance(0, 1, &probe.view(), &probe.view())
1159            .expect("induced_covariance should succeed");
1160        assert!(
1161            cross.abs() > 0.5,
1162            "cross-covariance between correlated outputs should be substantial, got {}",
1163            cross
1164        );
1165
1166        // (b) Quantitative check: information sharing improves the sparse output's
1167        // predictions far from its own data.
1168        let x_star = array![[2.6]];
1169        let true_value = 2.0 * 2.6_f64.sin();
1170
1171        let empty_output_0 = Array2::<f64>::zeros((0, 1));
1172        let joint_pred = fitted
1173            .predict_with_std(&[empty_output_0, x_star.clone()])
1174            .expect("predict should succeed");
1175        let cp_mean = joint_pred[1].0[0];
1176
1177        let standalone_gpr = GaussianProcessRegressor::new()
1178            .kernel(Box::new(RBF::new(length_scale)))
1179            .alpha(noise);
1180        let standalone_fitted = standalone_gpr.fit(&x2, &y2).expect("fit should succeed");
1181        let standalone_mean = standalone_fitted
1182            .predict(&x_star)
1183            .expect("predict should succeed")[0];
1184
1185        let cp_error = (cp_mean - true_value).abs();
1186        let standalone_error = (standalone_mean - true_value).abs();
1187
1188        assert!(
1189            cp_error < standalone_error,
1190            "convolution process should predict the sparse output more accurately \
1191             by borrowing strength from the correlated dense output (cp_error = {}, \
1192             standalone_error = {}, true_value = {}, cp_mean = {}, standalone_mean = {})",
1193            cp_error,
1194            standalone_error,
1195            true_value,
1196            cp_mean,
1197            standalone_mean
1198        );
1199    }
1200}