1use 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#[derive(Debug, Clone)]
110pub struct ConvolutionProcessConfig {
111 pub n_latent: usize,
116 pub default_length_scale: f64,
120 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#[derive(Debug, Clone)]
166pub struct ConvolutionProcess<S = Untrained> {
167 config: ConvolutionProcessConfig,
168 length_scales: Option<Vec<Vec<Array1<f64>>>>,
173 output_variances: Option<Vec<Vec<f64>>>,
178 noise_variances: Option<Vec<f64>>,
181 _state: S,
182}
183
184#[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 pub(crate) X_train: Vec<Array2<f64>>,
193 #[allow(dead_code)]
195 pub(crate) y_train: Vec<Array1<f64>>,
196 pub(crate) offsets: Vec<usize>,
199 pub(crate) n_total: usize,
201 pub(crate) length_scales: Vec<Vec<Array1<f64>>>,
203 pub(crate) output_variances: Vec<Vec<f64>>,
205 pub(crate) noise_variances: Vec<f64>,
207 pub(crate) L: Array2<f64>,
209 pub(crate) alpha: Array1<f64>,
211 pub(crate) log_marginal_likelihood_value: f64,
212}
213
214struct 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 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 pub fn n_latent(mut self, n_latent: usize) -> Self {
244 self.config.n_latent = n_latent;
245 self
246 }
247
248 pub fn default_length_scale(mut self, length_scale: f64) -> Self {
251 self.config.default_length_scale = length_scale;
252 self
253 }
254
255 pub fn noise_variance(mut self, noise_variance: f64) -> Self {
258 self.config.noise_variance = noise_variance;
259 self
260 }
261
262 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 pub fn output_variances(mut self, output_variances: Vec<Vec<f64>>) -> Self {
275 self.output_variances = Some(output_variances);
276 self
277 }
278
279 pub fn noise_variances(mut self, noise_variances: Vec<f64>) -> Self {
281 self.noise_variances = Some(noise_variances);
282 self
283 }
284
285 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
440fn 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 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
476fn 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#[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#[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 pub fn trained_state(&self) -> &ConvolutionProcessTrained {
650 &self._state
651 }
652
653 pub fn n_outputs(&self) -> usize {
655 self._state.n_outputs
656 }
657
658 pub fn n_latent(&self) -> usize {
660 self._state.n_latent
661 }
662
663 pub fn log_marginal_likelihood(&self) -> f64 {
665 self._state.log_marginal_likelihood_value
666 }
667
668 pub fn noise_variances(&self) -> &[f64] {
671 &self._state.noise_variances
672 }
673
674 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 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 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 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 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 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 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 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 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 let cp = ConvolutionProcess::new();
900 let result = cp.fit(&Vec::<Array2<f64>>::new(), &Vec::<Array1<f64>>::new());
901 assert!(result.is_err());
902
903 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 #[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 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 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 #[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 assert_abs_diff_eq!(
1018 cp_fitted.log_marginal_likelihood(),
1019 gpr_fitted.log_marginal_likelihood(),
1020 epsilon = 1e-6
1021 );
1022 }
1023
1024 #[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 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 #[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 assert!(k01_same.abs() < (k00 * k11).sqrt());
1116 }
1117
1118 #[test]
1128 fn test_two_correlated_outputs_information_sharing() {
1129 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 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 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 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 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}