Skip to main content

sklears_decomposition/
visualization.rs

1//! Visualization and interpretation utilities for matrix decomposition
2//!
3//! This module provides tools for visualizing and interpreting the results of
4//! matrix decomposition algorithms including:
5//! - Component visualization utilities
6//! - Loading plot generation
7//! - Biplot creation
8//! - Component contribution analysis
9//! - Feature importance ranking
10
11use scirs2_core::ndarray::{Array1, Array2, Axis};
12use sklears_core::{
13    error::{Result, SklearsError},
14    types::Float,
15};
16
17/// Component visualization data structure
18#[derive(Debug, Clone)]
19pub struct ComponentVisualization {
20    /// Component loadings/weights
21    pub loadings: Array2<Float>,
22    /// Component scores (transformed data)
23    pub scores: Array2<Float>,
24    /// Explained variance for each component
25    pub explained_variance: Array1<Float>,
26    /// Explained variance ratio for each component
27    pub explained_variance_ratio: Array1<Float>,
28    /// Feature names (optional)
29    pub feature_names: Option<Vec<String>>,
30    /// Component names (optional)
31    pub component_names: Option<Vec<String>>,
32}
33
34impl ComponentVisualization {
35    /// Create a new ComponentVisualization
36    pub fn new(
37        loadings: Array2<Float>,
38        scores: Array2<Float>,
39        explained_variance: Array1<Float>,
40    ) -> Self {
41        let total_variance = explained_variance.sum();
42        let explained_variance_ratio = if total_variance > 0.0 {
43            explained_variance.mapv(|x| x / total_variance)
44        } else {
45            Array1::zeros(explained_variance.len())
46        };
47
48        Self {
49            loadings,
50            scores,
51            explained_variance,
52            explained_variance_ratio,
53            feature_names: None,
54            component_names: None,
55        }
56    }
57
58    /// Set feature names
59    pub fn with_feature_names(mut self, names: Vec<String>) -> Self {
60        self.feature_names = Some(names);
61        self
62    }
63
64    /// Set component names
65    pub fn with_component_names(mut self, names: Vec<String>) -> Self {
66        self.component_names = Some(names);
67        self
68    }
69
70    /// Get loading plot data for specified components
71    pub fn loading_plot_data(&self, comp1: usize, comp2: usize) -> Result<LoadingPlotData> {
72        if comp1 >= self.loadings.ncols() || comp2 >= self.loadings.ncols() {
73            return Err(SklearsError::InvalidInput(
74                "Component indices out of bounds".to_string(),
75            ));
76        }
77
78        let loadings1 = self.loadings.column(comp1).to_owned();
79        let loadings2 = self.loadings.column(comp2).to_owned();
80
81        Ok(LoadingPlotData {
82            x_loadings: loadings1,
83            y_loadings: loadings2,
84            component1: comp1,
85            component2: comp2,
86            explained_variance1: self.explained_variance_ratio[comp1],
87            explained_variance2: self.explained_variance_ratio[comp2],
88            feature_names: self.feature_names.clone(),
89        })
90    }
91
92    /// Generate biplot data combining scores and loadings
93    pub fn biplot_data(&self, comp1: usize, comp2: usize) -> Result<BiplotData> {
94        if comp1 >= self.scores.ncols() || comp2 >= self.scores.ncols() {
95            return Err(SklearsError::InvalidInput(
96                "Component indices out of bounds".to_string(),
97            ));
98        }
99
100        let scores1 = self.scores.column(comp1).to_owned();
101        let scores2 = self.scores.column(comp2).to_owned();
102
103        let loading_plot = self.loading_plot_data(comp1, comp2)?;
104
105        Ok(BiplotData {
106            x_scores: scores1,
107            y_scores: scores2,
108            loading_plot,
109        })
110    }
111
112    /// Calculate feature importance for each component
113    pub fn feature_importance(&self) -> Array2<Float> {
114        // Calculate importance as squared loadings normalized by explained variance
115        let mut importance = Array2::zeros(self.loadings.dim());
116
117        for i in 0..self.loadings.ncols() {
118            let variance_weight = self.explained_variance_ratio[i];
119            for j in 0..self.loadings.nrows() {
120                importance[[j, i]] = self.loadings[[j, i]].powi(2) * variance_weight;
121            }
122        }
123
124        importance
125    }
126
127    /// Get the most important features for each component
128    pub fn top_features_per_component(&self, n_features: usize) -> Vec<Vec<FeatureImportance>> {
129        let importance = self.feature_importance();
130        let mut results = Vec::new();
131
132        for comp in 0..importance.ncols() {
133            let mut feature_scores: Vec<(usize, Float)> = importance
134                .column(comp)
135                .iter()
136                .enumerate()
137                .map(|(idx, &score)| (idx, score))
138                .collect();
139
140            // Sort by importance (descending)
141            feature_scores
142                .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
143
144            let top_features: Vec<FeatureImportance> = feature_scores
145                .into_iter()
146                .take(n_features)
147                .map(|(idx, score)| FeatureImportance {
148                    feature_index: idx,
149                    feature_name: self
150                        .feature_names
151                        .as_ref()
152                        .and_then(|names| names.get(idx))
153                        .cloned()
154                        .unwrap_or_else(|| format!("feature_{idx}")),
155                    importance_score: score,
156                    loading_value: self.loadings[[idx, comp]],
157                })
158                .collect();
159
160            results.push(top_features);
161        }
162
163        results
164    }
165
166    /// Calculate component contribution to total variance
167    pub fn component_contributions(&self) -> ComponentContribution {
168        let cumulative_variance: Array1<Float> = {
169            let mut cum = Array1::zeros(self.explained_variance_ratio.len());
170            let mut sum = 0.0;
171            for i in 0..self.explained_variance_ratio.len() {
172                sum += self.explained_variance_ratio[i];
173                cum[i] = sum;
174            }
175            cum
176        };
177
178        let total_variance_explained = cumulative_variance[cumulative_variance.len() - 1];
179
180        ComponentContribution {
181            individual_variance: self.explained_variance_ratio.clone(),
182            cumulative_variance,
183            total_variance_explained,
184        }
185    }
186
187    /// Generate scree plot data (eigenvalues vs component number)
188    pub fn scree_plot_data(&self) -> ScreePlotData {
189        let component_numbers: Array1<Float> =
190            Array1::from_shape_fn(self.explained_variance.len(), |i| (i + 1) as Float);
191
192        ScreePlotData {
193            component_numbers,
194            eigenvalues: self.explained_variance.clone(),
195            explained_variance_ratios: self.explained_variance_ratio.clone(),
196        }
197    }
198
199    /// Find optimal number of components using various criteria
200    pub fn optimal_components(&self) -> OptimalComponentsAnalysis {
201        let n_components = self.explained_variance.len();
202
203        // Kaiser criterion: eigenvalues > mean eigenvalue
204        let mean_eigenvalue = self.explained_variance.mean().unwrap_or(0.0);
205        let kaiser_components = self
206            .explained_variance
207            .iter()
208            .position(|&x| x <= mean_eigenvalue)
209            .unwrap_or(n_components);
210
211        // Elbow method: find the "elbow" in the scree plot
212        let elbow_components = self.find_elbow_point();
213
214        // 95% variance criterion
215        let variance_95_components = self
216            .explained_variance_ratio
217            .iter()
218            .scan(0.0, |acc, &x| {
219                *acc += x;
220                Some(*acc)
221            })
222            .position(|x| x >= 0.95)
223            .map(|x| x + 1)
224            .unwrap_or(n_components);
225
226        OptimalComponentsAnalysis {
227            kaiser_criterion: kaiser_components,
228            elbow_method: elbow_components,
229            variance_95_percent: variance_95_components,
230            total_components: n_components,
231        }
232    }
233
234    /// Find elbow point using second derivative method
235    fn find_elbow_point(&self) -> usize {
236        let n = self.explained_variance.len();
237        if n < 3 {
238            return n;
239        }
240
241        let mut second_derivatives = Vec::new();
242
243        // Calculate second derivatives
244        for i in 1..n - 1 {
245            let second_deriv = self.explained_variance[i - 1] - 2.0 * self.explained_variance[i]
246                + self.explained_variance[i + 1];
247            second_derivatives.push(second_deriv.abs());
248        }
249
250        // Find the point with maximum second derivative
251        let elbow_idx = second_derivatives
252            .iter()
253            .enumerate()
254            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
255            .map(|(idx, _)| idx + 2) // Adjust for the offset
256            .unwrap_or(n);
257
258        elbow_idx.min(n)
259    }
260}
261
262/// Loading plot data structure
263#[derive(Debug, Clone)]
264pub struct LoadingPlotData {
265    /// Loadings for first component (x-axis)
266    pub x_loadings: Array1<Float>,
267    /// Loadings for second component (y-axis)
268    pub y_loadings: Array1<Float>,
269    /// Index of first component
270    pub component1: usize,
271    /// Index of second component
272    pub component2: usize,
273    /// Explained variance ratio for first component
274    pub explained_variance1: Float,
275    /// Explained variance ratio for second component
276    pub explained_variance2: Float,
277    /// Feature names
278    pub feature_names: Option<Vec<String>>,
279}
280
281impl LoadingPlotData {
282    /// Get axis labels with explained variance information
283    pub fn axis_labels(&self) -> (String, String) {
284        let x_label = format!(
285            "PC{} ({:.1}%)",
286            self.component1 + 1,
287            self.explained_variance1 * 100.0
288        );
289        let y_label = format!(
290            "PC{} ({:.1}%)",
291            self.component2 + 1,
292            self.explained_variance2 * 100.0
293        );
294        (x_label, y_label)
295    }
296
297    /// Get feature coordinates for plotting
298    pub fn feature_coordinates(&self) -> Vec<(Float, Float, String)> {
299        let n_features = self.x_loadings.len();
300        let mut coordinates = Vec::with_capacity(n_features);
301
302        for i in 0..n_features {
303            let name = self
304                .feature_names
305                .as_ref()
306                .and_then(|names| names.get(i))
307                .cloned()
308                .unwrap_or_else(|| format!("feature_{i}"));
309
310            coordinates.push((self.x_loadings[i], self.y_loadings[i], name));
311        }
312
313        coordinates
314    }
315}
316
317/// Biplot data combining scores and loadings
318#[derive(Debug, Clone)]
319pub struct BiplotData {
320    /// Scores for first component (x-axis)
321    pub x_scores: Array1<Float>,
322    /// Scores for second component (y-axis)
323    pub y_scores: Array1<Float>,
324    /// Loading plot data
325    pub loading_plot: LoadingPlotData,
326}
327
328impl BiplotData {
329    /// Get sample coordinates for plotting
330    pub fn sample_coordinates(&self) -> Vec<(Float, Float)> {
331        self.x_scores
332            .iter()
333            .zip(self.y_scores.iter())
334            .map(|(&x, &y)| (x, y))
335            .collect()
336    }
337
338    /// Scale loadings for better visualization with scores
339    pub fn scaled_loadings(&self, scale_factor: Float) -> Vec<(Float, Float, String)> {
340        self.loading_plot
341            .feature_coordinates()
342            .into_iter()
343            .map(|(x, y, name)| (x * scale_factor, y * scale_factor, name))
344            .collect()
345    }
346}
347
348/// Feature importance information
349#[derive(Debug, Clone)]
350pub struct FeatureImportance {
351    /// Index of the feature
352    pub feature_index: usize,
353    /// Name of the feature
354    pub feature_name: String,
355    /// Importance score (squared loading weighted by explained variance)
356    pub importance_score: Float,
357    /// Raw loading value
358    pub loading_value: Float,
359}
360
361/// Component contribution analysis
362#[derive(Debug, Clone)]
363pub struct ComponentContribution {
364    /// Individual explained variance ratio for each component
365    pub individual_variance: Array1<Float>,
366    /// Cumulative explained variance ratio
367    pub cumulative_variance: Array1<Float>,
368    /// Total variance explained by all components
369    pub total_variance_explained: Float,
370}
371
372impl ComponentContribution {
373    /// Get components needed to explain a certain percentage of variance
374    pub fn components_for_variance(&self, threshold: Float) -> usize {
375        self.cumulative_variance
376            .iter()
377            .position(|&x| x >= threshold)
378            .map(|x| x + 1)
379            .unwrap_or(self.cumulative_variance.len())
380    }
381
382    /// Get variance explained by first n components
383    pub fn variance_explained_by_n_components(&self, n: usize) -> Float {
384        if n == 0 {
385            0.0
386        } else if n >= self.cumulative_variance.len() {
387            self.total_variance_explained
388        } else {
389            self.cumulative_variance[n - 1]
390        }
391    }
392}
393
394/// Scree plot data
395#[derive(Debug, Clone)]
396pub struct ScreePlotData {
397    /// Component numbers (1-indexed)
398    pub component_numbers: Array1<Float>,
399    /// Eigenvalues for each component
400    pub eigenvalues: Array1<Float>,
401    /// Explained variance ratios
402    pub explained_variance_ratios: Array1<Float>,
403}
404
405/// Optimal number of components analysis
406#[derive(Debug, Clone)]
407pub struct OptimalComponentsAnalysis {
408    /// Kaiser criterion (eigenvalues > mean)
409    pub kaiser_criterion: usize,
410    /// Elbow method result
411    pub elbow_method: usize,
412    /// Components for 95% variance
413    pub variance_95_percent: usize,
414    /// Total number of components
415    pub total_components: usize,
416}
417
418impl OptimalComponentsAnalysis {
419    /// Get recommendation based on multiple criteria
420    pub fn recommendation(&self) -> ComponentRecommendation {
421        let criteria = vec![
422            self.kaiser_criterion,
423            self.elbow_method,
424            self.variance_95_percent,
425        ];
426
427        // Simple consensus: take the median
428        let mut sorted_criteria = criteria.clone();
429        sorted_criteria.sort();
430        let median = sorted_criteria[sorted_criteria.len() / 2];
431
432        ComponentRecommendation {
433            recommended_components: median,
434            confidence: self.calculate_confidence(&criteria),
435            reasoning: self.generate_reasoning(&criteria),
436        }
437    }
438
439    fn calculate_confidence(&self, criteria: &[usize]) -> Float {
440        let max_val = *criteria.iter().max().unwrap_or(&1);
441        let min_val = *criteria.iter().min().unwrap_or(&1);
442
443        if max_val == min_val {
444            1.0 // Perfect agreement
445        } else {
446            1.0 - (max_val - min_val) as Float / self.total_components as Float
447        }
448    }
449
450    fn generate_reasoning(&self, _criteria: &[usize]) -> String {
451        format!(
452            "Kaiser criterion suggests {} components, elbow method suggests {}, \
453             and 95% variance threshold suggests {} components.",
454            self.kaiser_criterion, self.elbow_method, self.variance_95_percent
455        )
456    }
457}
458
459/// Component recommendation result
460#[derive(Debug, Clone)]
461pub struct ComponentRecommendation {
462    /// Recommended number of components
463    pub recommended_components: usize,
464    /// Confidence in the recommendation (0.0 to 1.0)
465    pub confidence: Float,
466    /// Reasoning behind the recommendation
467    pub reasoning: String,
468}
469
470/// Visualization utilities for specific decomposition methods
471pub struct DecompositionVisualizer;
472
473impl DecompositionVisualizer {
474    /// Create visualization for PCA results
475    pub fn pca_visualization(
476        components: &Array2<Float>,
477        transformed_data: &Array2<Float>,
478        explained_variance: &Array1<Float>,
479    ) -> ComponentVisualization {
480        ComponentVisualization::new(
481            components.t().to_owned(), // Transpose to get features x components
482            transformed_data.clone(),
483            explained_variance.clone(),
484        )
485    }
486
487    /// Create visualization for ICA results
488    pub fn ica_visualization(
489        mixing_matrix: &Array2<Float>,
490        sources: &Array2<Float>,
491    ) -> ComponentVisualization {
492        // For ICA, use unit variance for all components
493        let n_components = mixing_matrix.ncols();
494        let explained_variance = Array1::ones(n_components);
495
496        ComponentVisualization::new(mixing_matrix.clone(), sources.clone(), explained_variance)
497    }
498
499    /// Create visualization for NMF results
500    pub fn nmf_visualization(
501        components: &Array2<Float>,
502        transformed_data: &Array2<Float>,
503    ) -> ComponentVisualization {
504        // For NMF, calculate pseudo-variance from component norms
505        let explained_variance = components
506            .axis_iter(Axis(0))
507            .map(|comp| comp.dot(&comp))
508            .collect::<Array1<Float>>();
509
510        ComponentVisualization::new(
511            components.t().to_owned(),
512            transformed_data.clone(),
513            explained_variance,
514        )
515    }
516
517    /// Create visualization for Factor Analysis results
518    pub fn factor_analysis_visualization(
519        loadings: &Array2<Float>,
520        factors: &Array2<Float>,
521        explained_variance: &Array1<Float>,
522    ) -> ComponentVisualization {
523        ComponentVisualization::new(
524            loadings.clone(),
525            factors.clone(),
526            explained_variance.clone(),
527        )
528    }
529}
530
531#[allow(non_snake_case)]
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use approx::assert_abs_diff_eq;
536
537    #[test]
538    fn test_component_visualization_creation() {
539        let loadings = Array2::from_shape_vec((3, 2), vec![0.8, 0.2, 0.6, 0.5, 0.1, 0.9])
540            .expect("shape and data length should match");
541        let scores = Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 0.5, 1.5, 2.5, 3.5])
542            .expect("shape and data length should match");
543        let explained_variance = Array1::from_vec(vec![2.0, 1.0]);
544
545        let viz = ComponentVisualization::new(loadings, scores, explained_variance);
546
547        assert_eq!(viz.explained_variance_ratio.len(), 2);
548        assert_abs_diff_eq!(viz.explained_variance_ratio[0], 2.0 / 3.0, epsilon = 1e-10);
549        assert_abs_diff_eq!(viz.explained_variance_ratio[1], 1.0 / 3.0, epsilon = 1e-10);
550    }
551
552    #[test]
553    fn test_loading_plot_data() {
554        let loadings = Array2::from_shape_vec((3, 2), vec![0.8, 0.2, 0.6, 0.5, 0.1, 0.9])
555            .expect("shape and data length should match");
556        let scores = Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 0.5, 1.5, 2.5, 3.5])
557            .expect("shape and data length should match");
558        let explained_variance = Array1::from_vec(vec![2.0, 1.0]);
559
560        let viz = ComponentVisualization::new(loadings, scores, explained_variance);
561        let loading_plot = viz
562            .loading_plot_data(0, 1)
563            .expect("operation should succeed");
564
565        assert_eq!(loading_plot.x_loadings.len(), 3);
566        assert_eq!(loading_plot.y_loadings.len(), 3);
567        assert_eq!(loading_plot.component1, 0);
568        assert_eq!(loading_plot.component2, 1);
569
570        let (x_label, y_label) = loading_plot.axis_labels();
571        assert!(x_label.contains("PC1"));
572        assert!(y_label.contains("PC2"));
573    }
574
575    #[test]
576    fn test_feature_importance() {
577        let loadings = Array2::from_shape_vec((3, 2), vec![0.8, 0.2, 0.6, 0.5, 0.1, 0.9])
578            .expect("shape and data length should match");
579        let scores = Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 0.5, 1.5, 2.5, 3.5])
580            .expect("shape and data length should match");
581        let explained_variance = Array1::from_vec(vec![2.0, 1.0]);
582
583        let viz = ComponentVisualization::new(loadings, scores, explained_variance);
584        let importance = viz.feature_importance();
585
586        assert_eq!(importance.dim(), (3, 2));
587        // Check that importance is always non-negative
588        for &val in importance.iter() {
589            assert!(val >= 0.0);
590        }
591    }
592
593    #[test]
594    fn test_top_features_per_component() {
595        let loadings = Array2::from_shape_vec((3, 2), vec![0.8, 0.2, 0.6, 0.5, 0.1, 0.9])
596            .expect("shape and data length should match");
597        let scores = Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 0.5, 1.5, 2.5, 3.5])
598            .expect("shape and data length should match");
599        let explained_variance = Array1::from_vec(vec![2.0, 1.0]);
600
601        let viz = ComponentVisualization::new(loadings, scores, explained_variance);
602        let top_features = viz.top_features_per_component(2);
603
604        assert_eq!(top_features.len(), 2); // 2 components
605        assert_eq!(top_features[0].len(), 2); // Top 2 features for component 0
606        assert_eq!(top_features[1].len(), 2); // Top 2 features for component 1
607    }
608
609    #[test]
610    fn test_component_contributions() {
611        let loadings = Array2::from_shape_vec((3, 2), vec![0.8, 0.2, 0.6, 0.5, 0.1, 0.9])
612            .expect("shape and data length should match");
613        let scores = Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 0.5, 1.5, 2.5, 3.5])
614            .expect("shape and data length should match");
615        let explained_variance = Array1::from_vec(vec![2.0, 1.0]);
616
617        let viz = ComponentVisualization::new(loadings, scores, explained_variance);
618        let contributions = viz.component_contributions();
619
620        assert_eq!(contributions.cumulative_variance.len(), 2);
621        assert!(contributions.cumulative_variance[0] <= contributions.cumulative_variance[1]);
622        assert_abs_diff_eq!(contributions.total_variance_explained, 1.0, epsilon = 1e-10);
623    }
624
625    #[test]
626    fn test_optimal_components_analysis() {
627        let loadings = Array2::from_shape_vec(
628            (5, 5),
629            vec![
630                0.8, 0.2, 0.1, 0.05, 0.02, 0.6, 0.5, 0.3, 0.1, 0.01, 0.1, 0.9, 0.2, 0.05, 0.01,
631                0.05, 0.1, 0.8, 0.4, 0.1, 0.02, 0.01, 0.1, 0.7, 0.6,
632            ],
633        )
634        .expect("operation should succeed");
635        let scores = Array2::zeros((10, 5));
636        let explained_variance = Array1::from_vec(vec![3.0, 2.0, 1.5, 0.8, 0.2]);
637
638        let viz = ComponentVisualization::new(loadings, scores, explained_variance);
639        let analysis = viz.optimal_components();
640
641        assert!(analysis.kaiser_criterion <= 5);
642        assert!(analysis.elbow_method <= 5);
643        assert!(analysis.variance_95_percent <= 5);
644        assert_eq!(analysis.total_components, 5);
645
646        let recommendation = analysis.recommendation();
647        assert!(recommendation.recommended_components <= 5);
648        assert!(recommendation.confidence >= 0.0 && recommendation.confidence <= 1.0);
649        assert!(!recommendation.reasoning.is_empty());
650    }
651
652    #[test]
653    fn test_decomposition_visualizer() {
654        let components = Array2::from_shape_vec((2, 3), vec![0.8, 0.6, 0.1, 0.2, 0.5, 0.9])
655            .expect("shape and data length should match");
656        let transformed_data =
657            Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 0.5, 1.5, 2.5, 3.5])
658                .expect("shape and data length should match");
659        let explained_variance = Array1::from_vec(vec![2.0, 1.0]);
660
661        let viz = DecompositionVisualizer::pca_visualization(
662            &components,
663            &transformed_data,
664            &explained_variance,
665        );
666
667        assert_eq!(viz.loadings.dim(), (3, 2)); // Transposed
668        assert_eq!(viz.scores.dim(), (4, 2));
669        assert_eq!(viz.explained_variance.len(), 2);
670    }
671
672    #[test]
673    fn test_biplot_data() {
674        let loadings = Array2::from_shape_vec((3, 2), vec![0.8, 0.2, 0.6, 0.5, 0.1, 0.9])
675            .expect("shape and data length should match");
676        let scores = Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 0.5, 1.5, 2.5, 3.5])
677            .expect("shape and data length should match");
678        let explained_variance = Array1::from_vec(vec![2.0, 1.0]);
679
680        let viz = ComponentVisualization::new(loadings, scores, explained_variance);
681        let biplot = viz.biplot_data(0, 1).expect("operation should succeed");
682
683        assert_eq!(biplot.x_scores.len(), 4);
684        assert_eq!(biplot.y_scores.len(), 4);
685
686        let sample_coords = biplot.sample_coordinates();
687        assert_eq!(sample_coords.len(), 4);
688
689        let scaled_loadings = biplot.scaled_loadings(1.5);
690        assert_eq!(scaled_loadings.len(), 3);
691    }
692}