Skip to main content

scirs2_transform/
gpu.rs

1//! GPU-accelerated transformations
2//!
3//! This module provides GPU-accelerated implementations of dimensionality reduction
4//! and matrix operations. Currently provides basic stubs with CPU fallback.
5
6use crate::error::{Result, TransformError};
7use scirs2_core::gpu::{GpuBackend, GpuContext};
8use scirs2_core::ndarray::{Array1, Array2, ArrayView2};
9use scirs2_core::validation::{check_not_empty, check_positive, checkarray_finite};
10
11/// GPU-accelerated Principal Component Analysis
12#[cfg(feature = "gpu")]
13pub struct GpuPCA {
14    /// Number of components to compute
15    pub n_components: usize,
16    /// Whether to center the data
17    pub center: bool,
18    /// Principal components (loading vectors)
19    pub components: Option<Array2<f64>>,
20    /// Explained variance for each component
21    pub explained_variance: Option<Array1<f64>>,
22    /// Mean values for centering
23    pub mean: Option<Array1<f64>>,
24    /// GPU context for GPU operations
25    gpu_context: Option<GpuContext>,
26}
27
28#[cfg(feature = "gpu")]
29impl GpuPCA {
30    /// Create a new GPU PCA instance
31    ///
32    /// # Arguments
33    ///
34    /// * `n_components` - Number of principal components to compute
35    ///
36    /// # Returns
37    ///
38    /// Returns a new GpuPCA instance with GPU context initialized
39    ///
40    /// # Errors
41    ///
42    /// Returns an error if GPU initialization fails or if n_components is 0
43    ///
44    /// # Examples
45    ///
46    /// ```
47    /// # use scirs2_transform::gpu::GpuPCA;
48    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
49    /// let pca = GpuPCA::new(5)?;
50    /// # Ok(())
51    /// # }
52    /// ```
53    pub fn new(n_components: usize) -> Result<Self> {
54        check_positive(n_components, "n_components")?;
55
56        let gpu_context = GpuContext::new(GpuBackend::preferred()).map_err(|e| {
57            TransformError::ComputationError(format!("Failed to initialize GPU: {}", e))
58        })?;
59
60        Ok(GpuPCA {
61            n_components,
62            center: true,
63            components: None,
64            explained_variance: None,
65            mean: None,
66            gpu_context: Some(gpu_context),
67        })
68    }
69
70    /// Fit the PCA model using a CPU SVD-based backend.
71    ///
72    /// Currently delegates to the CPU SVD-based PCA implementation.
73    /// A wgpu-backed Jacobi SVD path is planned for v0.5.
74    ///
75    /// # Arguments
76    ///
77    /// * `x` - Input data matrix with shape (n_samples, n_features)
78    ///
79    /// # Errors
80    ///
81    /// Returns an error if the input is invalid or if `n_components` exceeds
82    /// `min(n_samples, n_features)`.
83    ///
84    /// # Examples
85    ///
86    /// ```
87    /// # use scirs2_transform::gpu::GpuPCA;
88    /// # use scirs2_core::ndarray::Array2;
89    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
90    /// let mut pca = GpuPCA::new(2)?;
91    /// let data = Array2::from_shape_vec((5, 4), vec![
92    ///     1.0, 2.0, 3.0, 4.0,
93    ///     2.0, 3.0, 4.0, 5.0,
94    ///     3.0, 4.0, 5.0, 6.0,
95    ///     4.0, 5.0, 6.0, 7.0,
96    ///     5.0, 6.0, 7.0, 8.0,
97    /// ])?;
98    /// pca.fit(&data.view())?;
99    /// assert!(pca.components.is_some());
100    /// # Ok(())
101    /// # }
102    /// ```
103    pub fn fit(&mut self, x: &ArrayView2<f64>) -> Result<()> {
104        check_not_empty(x, "x")?;
105        checkarray_finite(x, "x")?;
106
107        let (n_samples, n_features) = x.dim();
108        if self.n_components > n_features.min(n_samples) {
109            return Err(TransformError::InvalidInput(
110                "n_components cannot be larger than min(n_samples, n_features)".to_string(),
111            ));
112        }
113
114        // Delegate to CPU SVD-based PCA; GPU Jacobi SVD path planned for v0.5.
115        let mut cpu_pca = crate::reduction::PCA::new(self.n_components, self.center, false);
116        cpu_pca.fit(x)?;
117
118        self.components = cpu_pca.components().cloned();
119        self.mean = cpu_pca.mean().cloned();
120        // Store the explained variance ratio directly; GpuPCA::explained_variance_ratio()
121        // returns this field as-is (it is already normalised to sum ≈ 1).
122        self.explained_variance = cpu_pca.explained_variance_ratio().cloned();
123
124        Ok(())
125    }
126
127    /// Transform data using the fitted PCA model.
128    ///
129    /// Projects the input data onto the principal components computed during
130    /// [`GpuPCA::fit`].  Currently runs on the CPU; a wgpu-backed path is
131    /// planned for v0.5.
132    ///
133    /// # Arguments
134    ///
135    /// * `x` - Input data matrix with shape (n_samples, n_features)
136    ///
137    /// # Returns
138    ///
139    /// Transformed data matrix with shape (n_samples, n_components)
140    ///
141    /// # Errors
142    ///
143    /// Returns `NotFitted` if [`GpuPCA::fit`] has not been called yet, or
144    /// `InvalidInput` if the number of features does not match.
145    pub fn transform(&self, x: &ArrayView2<f64>) -> Result<Array2<f64>> {
146        check_not_empty(x, "x")?;
147        checkarray_finite(x, "x")?;
148
149        let components = self.components.as_ref().ok_or_else(|| {
150            TransformError::NotFitted(
151                "GpuPCA model has not been fitted; call fit() first".to_string(),
152            )
153        })?;
154
155        let (n_samples, n_features) = x.dim();
156        let n_comp_features = components.shape()[1];
157
158        if n_features != n_comp_features {
159            return Err(TransformError::InvalidInput(format!(
160                "x has {} features, but GpuPCA was fitted with {} features",
161                n_features, n_comp_features,
162            )));
163        }
164
165        // Center the data if the model was trained with centering.
166        let x_centered: Array2<f64> = if self.center {
167            if let Some(mean) = &self.mean {
168                let mut centered = Array2::zeros((n_samples, n_features));
169                for i in 0..n_samples {
170                    for j in 0..n_features {
171                        centered[[i, j]] = x[[i, j]] - mean[j];
172                    }
173                }
174                centered
175            } else {
176                // mean not available — use raw data
177                x.to_owned()
178            }
179        } else {
180            x.to_owned()
181        };
182
183        // Project onto principal components: result shape (n_samples, n_components).
184        let transformed = x_centered.dot(&components.t());
185        Ok(transformed)
186    }
187
188    /// Fit the PCA model and project data in one step.
189    ///
190    /// Equivalent to calling [`GpuPCA::fit`] followed by [`GpuPCA::transform`]
191    /// on the same data.
192    ///
193    /// # Arguments
194    ///
195    /// * `x` - Input data matrix with shape (n_samples, n_features)
196    ///
197    /// # Returns
198    ///
199    /// Transformed data matrix with shape (n_samples, n_components)
200    ///
201    /// # Errors
202    ///
203    /// Returns any error produced by [`GpuPCA::fit`] or [`GpuPCA::transform`].
204    pub fn fit_transform(&mut self, x: &ArrayView2<f64>) -> Result<Array2<f64>> {
205        self.fit(x)?;
206        self.transform(x)
207    }
208
209    /// Get the explained variance ratio for each principal component.
210    ///
211    /// The returned array sums to approximately 1.0 after fitting.
212    ///
213    /// # Returns
214    ///
215    /// Array of explained variance ratios with length `n_components`
216    ///
217    /// # Errors
218    ///
219    /// Returns `NotFitted` if [`GpuPCA::fit`] has not been called yet.
220    pub fn explained_variance_ratio(&self) -> Result<Array1<f64>> {
221        self.explained_variance
222            .as_ref()
223            .cloned()
224            .ok_or_else(|| TransformError::NotFitted("GpuPCA model not fitted".to_string()))
225    }
226}
227
228/// GPU-accelerated matrix operations for transformations
229#[cfg(feature = "gpu")]
230pub struct GpuMatrixOps {
231    #[allow(dead_code)]
232    gpu_context: GpuContext,
233}
234
235#[cfg(feature = "gpu")]
236impl GpuMatrixOps {
237    /// Create new GPU matrix operations instance
238    pub fn new() -> Result<Self> {
239        let gpu_context = GpuContext::new(GpuBackend::preferred()).map_err(|e| {
240            TransformError::ComputationError(format!("Failed to initialize GPU: {}", e))
241        })?;
242
243        Ok(GpuMatrixOps { gpu_context })
244    }
245
246    /// GPU-accelerated matrix multiplication (placeholder)
247    pub fn matmul(self_a: &ArrayView2<f64>, b: &ArrayView2<f64>) -> Result<Array2<f64>> {
248        Err(TransformError::NotImplemented(
249            "GPU matrix multiplication is not yet implemented. Use CPU operations instead."
250                .to_string(),
251        ))
252    }
253
254    /// GPU-accelerated SVD decomposition (placeholder)
255    pub fn svd(selfa: &ArrayView2<f64>) -> Result<(Array2<f64>, Array1<f64>, Array2<f64>)> {
256        Err(TransformError::NotImplemented(
257            "GPU SVD is not yet implemented. Use CPU operations instead.".to_string(),
258        ))
259    }
260
261    /// GPU-accelerated eigendecomposition (placeholder)
262    pub fn eigh(selfa: &ArrayView2<f64>) -> Result<(Array1<f64>, Array2<f64>)> {
263        Err(TransformError::NotImplemented(
264            "GPU eigendecomposition is not yet implemented. Use CPU operations instead."
265                .to_string(),
266        ))
267    }
268}
269
270/// GPU-accelerated t-SNE implementation
271#[cfg(feature = "gpu")]
272pub struct GpuTSNE {
273    /// Number of dimensions for the embedding
274    pub n_components: usize,
275    /// Perplexity parameter
276    pub perplexity: f64,
277    /// Learning rate
278    pub learning_rate: f64,
279    /// Maximum number of iterations
280    pub max_iter: usize,
281    /// GPU context
282    #[allow(dead_code)]
283    gpu_context: GpuContext,
284}
285
286#[cfg(feature = "gpu")]
287impl GpuTSNE {
288    /// Create new GPU t-SNE instance
289    pub fn new(n_components: usize) -> Result<Self> {
290        check_positive(n_components, "n_components")?;
291
292        let gpu_context = GpuContext::new(GpuBackend::preferred()).map_err(|e| {
293            TransformError::ComputationError(format!("Failed to initialize GPU: {}", e))
294        })?;
295
296        Ok(GpuTSNE {
297            n_components,
298            perplexity: 30.0,
299            learning_rate: 200.0,
300            max_iter: 1000,
301            gpu_context,
302        })
303    }
304
305    /// Set perplexity parameter
306    pub fn with_perplexity(mut self, perplexity: f64) -> Self {
307        self.perplexity = perplexity;
308        self
309    }
310
311    /// Set learning rate
312    pub fn with_learning_rate(mut self, learning_rate: f64) -> Self {
313        self.learning_rate = learning_rate;
314        self
315    }
316
317    /// Set maximum iterations
318    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
319        self.max_iter = max_iter;
320        self
321    }
322
323    /// Fit and transform data using GPU-accelerated t-SNE (placeholder)
324    pub fn fit_transform(selfx: &ArrayView2<f64>) -> Result<Array2<f64>> {
325        Err(TransformError::NotImplemented(
326            "GPU t-SNE is not yet implemented. Use CPU t-SNE instead.".to_string(),
327        ))
328    }
329}
330
331// Stub implementations when GPU feature is not enabled
332#[cfg(not(feature = "gpu"))]
333pub struct GpuPCA;
334
335#[cfg(not(feature = "gpu"))]
336pub struct GpuMatrixOps;
337
338#[cfg(not(feature = "gpu"))]
339pub struct GpuTSNE;
340
341#[cfg(not(feature = "gpu"))]
342impl GpuPCA {
343    pub fn new(_ncomponents: usize) -> Result<Self> {
344        Err(TransformError::FeatureNotEnabled(
345            "GPU acceleration requires the 'gpu' feature to be enabled".to_string(),
346        ))
347    }
348}
349
350#[cfg(not(feature = "gpu"))]
351impl GpuMatrixOps {
352    pub fn new() -> Result<Self> {
353        Err(TransformError::FeatureNotEnabled(
354            "GPU acceleration requires the 'gpu' feature to be enabled".to_string(),
355        ))
356    }
357}
358
359#[cfg(not(feature = "gpu"))]
360impl GpuTSNE {
361    pub fn new(_ncomponents: usize) -> Result<Self> {
362        Err(TransformError::FeatureNotEnabled(
363            "GPU acceleration requires the 'gpu' feature to be enabled".to_string(),
364        ))
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use scirs2_core::ndarray::Array2;
372
373    // ------------------------------------------------------------------
374    // Helper: construct a simple (5 × 4) test matrix with known structure.
375    // Each row is an arithmetic progression, so the data lies close to a
376    // 1-dimensional subspace; PCA with 2 components should capture ≥ 99 %
377    // of the variance.
378    // ------------------------------------------------------------------
379    #[cfg(feature = "gpu")]
380    fn sample_data_5x4() -> Array2<f64> {
381        Array2::from_shape_vec(
382            (5, 4),
383            vec![
384                1.0, 2.0, 3.0, 4.0, 2.0, 3.0, 4.0, 5.0, 3.0, 4.0, 5.0, 6.0, 4.0, 5.0, 6.0, 7.0,
385                5.0, 6.0, 7.0, 8.0,
386            ],
387        )
388        .expect("shape vec must be consistent")
389    }
390
391    // ------------------------------------------------------------------
392    // Construction tests
393    // ------------------------------------------------------------------
394
395    #[test]
396    #[cfg(feature = "gpu")]
397    fn test_gpu_pca_creation() {
398        let pca = GpuPCA::new(3);
399        assert!(pca.is_ok());
400        let pca = pca.expect("GpuPCA::new should succeed");
401        assert_eq!(pca.n_components, 3);
402        assert!(pca.center);
403        assert!(pca.components.is_none());
404        assert!(pca.explained_variance.is_none());
405        assert!(pca.mean.is_none());
406    }
407
408    #[test]
409    #[cfg(feature = "gpu")]
410    fn test_gpu_pca_invalid_components() {
411        let result = GpuPCA::new(0);
412        assert!(result.is_err());
413    }
414
415    // ------------------------------------------------------------------
416    // fit() tests — verify that fit populates all model fields correctly.
417    // ------------------------------------------------------------------
418
419    #[test]
420    #[cfg(feature = "gpu")]
421    fn test_gpu_pca_fit_populates_fields() {
422        let mut pca = GpuPCA::new(2).expect("GpuPCA::new should succeed");
423        let data = sample_data_5x4();
424
425        pca.fit(&data.view()).expect("GpuPCA::fit should succeed");
426
427        // All model fields should now be populated.
428        assert!(pca.components.is_some(), "components must be set after fit");
429        assert!(
430            pca.explained_variance.is_some(),
431            "explained_variance must be set after fit"
432        );
433        assert!(pca.mean.is_some(), "mean must be set after fit");
434    }
435
436    #[test]
437    #[cfg(feature = "gpu")]
438    fn test_gpu_pca_fit_components_shape() {
439        let mut pca = GpuPCA::new(2).expect("GpuPCA::new should succeed");
440        let data = sample_data_5x4();
441        pca.fit(&data.view()).expect("fit must succeed");
442
443        // components: (n_components, n_features) = (2, 4)
444        let comp = pca.components.as_ref().expect("components present");
445        assert_eq!(comp.shape(), &[2, 4]);
446    }
447
448    #[test]
449    #[cfg(feature = "gpu")]
450    fn test_gpu_pca_fit_mean_shape() {
451        let mut pca = GpuPCA::new(2).expect("GpuPCA::new should succeed");
452        let data = sample_data_5x4();
453        pca.fit(&data.view()).expect("fit must succeed");
454
455        let mean = pca.mean.as_ref().expect("mean present");
456        assert_eq!(mean.len(), 4, "mean must have one entry per feature");
457    }
458
459    #[test]
460    #[cfg(feature = "gpu")]
461    fn test_gpu_pca_fit_explained_variance_length() {
462        let mut pca = GpuPCA::new(2).expect("GpuPCA::new should succeed");
463        let data = sample_data_5x4();
464        pca.fit(&data.view()).expect("fit must succeed");
465
466        let ev = pca
467            .explained_variance
468            .as_ref()
469            .expect("explained_variance present");
470        assert_eq!(
471            ev.len(),
472            2,
473            "explained_variance must have n_components entries"
474        );
475    }
476
477    #[test]
478    #[cfg(feature = "gpu")]
479    fn test_gpu_pca_fit_explained_variance_non_increasing() {
480        let mut pca = GpuPCA::new(2).expect("GpuPCA::new should succeed");
481        let data = sample_data_5x4();
482        pca.fit(&data.view()).expect("fit must succeed");
483
484        let ev = pca
485            .explained_variance
486            .as_ref()
487            .expect("explained_variance present");
488        for i in 0..ev.len() - 1 {
489            assert!(
490                ev[i] >= ev[i + 1] - 1e-10,
491                "explained variance must be non-increasing: ev[{}]={} < ev[{}]={}",
492                i,
493                ev[i],
494                i + 1,
495                ev[i + 1]
496            );
497        }
498    }
499
500    #[test]
501    #[cfg(feature = "gpu")]
502    fn test_gpu_pca_fit_n_components_too_large() {
503        // n_components > min(n_samples, n_features) must fail
504        let mut pca = GpuPCA::new(10).expect("GpuPCA::new(10) should succeed");
505        let data = sample_data_5x4(); // 5 × 4 → max 4 components
506        let result = pca.fit(&data.view());
507        assert!(
508            result.is_err(),
509            "should fail when n_components > min(n_samples, n_features)"
510        );
511    }
512
513    // ------------------------------------------------------------------
514    // transform() tests
515    // ------------------------------------------------------------------
516
517    #[test]
518    #[cfg(feature = "gpu")]
519    fn test_gpu_pca_transform_shape() {
520        let mut pca = GpuPCA::new(2).expect("GpuPCA::new should succeed");
521        let data = sample_data_5x4();
522        pca.fit(&data.view()).expect("fit must succeed");
523
524        let transformed = pca
525            .transform(&data.view())
526            .expect("transform must succeed after fit");
527
528        // Output shape: (n_samples, n_components) = (5, 2)
529        assert_eq!(transformed.shape(), &[5, 2]);
530    }
531
532    #[test]
533    #[cfg(feature = "gpu")]
534    fn test_gpu_pca_transform_all_finite() {
535        let mut pca = GpuPCA::new(2).expect("GpuPCA::new should succeed");
536        let data = sample_data_5x4();
537        pca.fit(&data.view()).expect("fit must succeed");
538        let transformed = pca.transform(&data.view()).expect("transform must succeed");
539
540        assert!(
541            transformed.iter().all(|v| v.is_finite()),
542            "all transformed values must be finite"
543        );
544    }
545
546    #[test]
547    #[cfg(feature = "gpu")]
548    fn test_gpu_pca_transform_before_fit_returns_error() {
549        let pca = GpuPCA::new(2).expect("GpuPCA::new should succeed");
550        let data = sample_data_5x4();
551        let result = pca.transform(&data.view());
552        assert!(result.is_err(), "transform before fit must return an error");
553    }
554
555    // ------------------------------------------------------------------
556    // fit_transform() tests
557    // ------------------------------------------------------------------
558
559    #[test]
560    #[cfg(feature = "gpu")]
561    fn test_gpu_pca_fit_transform_shape() {
562        let mut pca = GpuPCA::new(2).expect("GpuPCA::new should succeed");
563        let data = sample_data_5x4();
564        let ft = pca
565            .fit_transform(&data.view())
566            .expect("fit_transform must succeed");
567        assert_eq!(ft.shape(), &[5, 2]);
568    }
569
570    #[test]
571    #[cfg(feature = "gpu")]
572    fn test_gpu_pca_fit_transform_matches_fit_then_transform() {
573        let data = sample_data_5x4();
574
575        let mut pca1 = GpuPCA::new(2).expect("GpuPCA::new should succeed");
576        let ft = pca1
577            .fit_transform(&data.view())
578            .expect("fit_transform must succeed");
579
580        let mut pca2 = GpuPCA::new(2).expect("GpuPCA::new should succeed");
581        pca2.fit(&data.view()).expect("fit must succeed");
582        let t = pca2
583            .transform(&data.view())
584            .expect("transform must succeed");
585
586        assert_eq!(ft.shape(), t.shape());
587        for (a, b) in ft.iter().zip(t.iter()) {
588            assert!(
589                (a - b).abs() < 1e-10,
590                "fit_transform and fit+transform must agree: {} vs {}",
591                a,
592                b
593            );
594        }
595    }
596
597    // ------------------------------------------------------------------
598    // explained_variance_ratio() method
599    // ------------------------------------------------------------------
600
601    #[test]
602    #[cfg(feature = "gpu")]
603    fn test_gpu_pca_explained_variance_ratio_before_fit_returns_error() {
604        let pca = GpuPCA::new(2).expect("GpuPCA::new should succeed");
605        assert!(pca.explained_variance_ratio().is_err());
606    }
607
608    #[test]
609    #[cfg(feature = "gpu")]
610    fn test_gpu_pca_explained_variance_ratio_after_fit() {
611        let mut pca = GpuPCA::new(2).expect("GpuPCA::new should succeed");
612        let data = sample_data_5x4();
613        pca.fit(&data.view()).expect("fit must succeed");
614
615        let ratio = pca
616            .explained_variance_ratio()
617            .expect("explained_variance_ratio must succeed after fit");
618        assert_eq!(ratio.len(), 2);
619        // All ratios must be non-negative
620        assert!(ratio.iter().all(|&v| v >= 0.0));
621    }
622
623    // ------------------------------------------------------------------
624    // GpuMatrixOps and GpuTSNE smoke tests (unchanged behaviour)
625    // ------------------------------------------------------------------
626
627    #[test]
628    #[cfg(feature = "gpu")]
629    fn test_gpu_matrix_ops_creation() {
630        let ops = GpuMatrixOps::new();
631        assert!(ops.is_ok());
632    }
633
634    #[test]
635    #[cfg(feature = "gpu")]
636    fn test_gpu_tsne_creation() {
637        let tsne = GpuTSNE::new(2);
638        assert!(tsne.is_ok());
639        let tsne = tsne.expect("GpuTSNE::new should succeed");
640        assert_eq!(tsne.n_components, 2);
641        assert_eq!(tsne.perplexity, 30.0);
642        assert_eq!(tsne.learning_rate, 200.0);
643        assert_eq!(tsne.max_iter, 1000);
644    }
645
646    #[test]
647    #[cfg(feature = "gpu")]
648    fn test_gpu_tsne_with_params() {
649        let tsne = GpuTSNE::new(3)
650            .expect("GpuTSNE::new should succeed")
651            .with_perplexity(50.0)
652            .with_learning_rate(100.0)
653            .with_max_iter(500);
654
655        assert_eq!(tsne.n_components, 3);
656        assert_eq!(tsne.perplexity, 50.0);
657        assert_eq!(tsne.learning_rate, 100.0);
658        assert_eq!(tsne.max_iter, 500);
659    }
660
661    #[test]
662    #[cfg(not(feature = "gpu"))]
663    fn test_gpu_features_disabled() {
664        assert!(GpuPCA::new(2).is_err());
665        assert!(GpuMatrixOps::new().is_err());
666        assert!(GpuTSNE::new(2).is_err());
667    }
668}