use scirs2_core::ndarray::{Array1, Array2, Axis};
use sklears_core::{
error::{Result, SklearsError},
types::Float,
};
#[derive(Debug, Clone)]
pub struct ComponentVisualization {
pub loadings: Array2<Float>,
pub scores: Array2<Float>,
pub explained_variance: Array1<Float>,
pub explained_variance_ratio: Array1<Float>,
pub feature_names: Option<Vec<String>>,
pub component_names: Option<Vec<String>>,
}
impl ComponentVisualization {
pub fn new(
loadings: Array2<Float>,
scores: Array2<Float>,
explained_variance: Array1<Float>,
) -> Self {
let total_variance = explained_variance.sum();
let explained_variance_ratio = if total_variance > 0.0 {
explained_variance.mapv(|x| x / total_variance)
} else {
Array1::zeros(explained_variance.len())
};
Self {
loadings,
scores,
explained_variance,
explained_variance_ratio,
feature_names: None,
component_names: None,
}
}
pub fn with_feature_names(mut self, names: Vec<String>) -> Self {
self.feature_names = Some(names);
self
}
pub fn with_component_names(mut self, names: Vec<String>) -> Self {
self.component_names = Some(names);
self
}
pub fn loading_plot_data(&self, comp1: usize, comp2: usize) -> Result<LoadingPlotData> {
if comp1 >= self.loadings.ncols() || comp2 >= self.loadings.ncols() {
return Err(SklearsError::InvalidInput(
"Component indices out of bounds".to_string(),
));
}
let loadings1 = self.loadings.column(comp1).to_owned();
let loadings2 = self.loadings.column(comp2).to_owned();
Ok(LoadingPlotData {
x_loadings: loadings1,
y_loadings: loadings2,
component1: comp1,
component2: comp2,
explained_variance1: self.explained_variance_ratio[comp1],
explained_variance2: self.explained_variance_ratio[comp2],
feature_names: self.feature_names.clone(),
})
}
pub fn biplot_data(&self, comp1: usize, comp2: usize) -> Result<BiplotData> {
if comp1 >= self.scores.ncols() || comp2 >= self.scores.ncols() {
return Err(SklearsError::InvalidInput(
"Component indices out of bounds".to_string(),
));
}
let scores1 = self.scores.column(comp1).to_owned();
let scores2 = self.scores.column(comp2).to_owned();
let loading_plot = self.loading_plot_data(comp1, comp2)?;
Ok(BiplotData {
x_scores: scores1,
y_scores: scores2,
loading_plot,
})
}
pub fn feature_importance(&self) -> Array2<Float> {
let mut importance = Array2::zeros(self.loadings.dim());
for i in 0..self.loadings.ncols() {
let variance_weight = self.explained_variance_ratio[i];
for j in 0..self.loadings.nrows() {
importance[[j, i]] = self.loadings[[j, i]].powi(2) * variance_weight;
}
}
importance
}
pub fn top_features_per_component(&self, n_features: usize) -> Vec<Vec<FeatureImportance>> {
let importance = self.feature_importance();
let mut results = Vec::new();
for comp in 0..importance.ncols() {
let mut feature_scores: Vec<(usize, Float)> = importance
.column(comp)
.iter()
.enumerate()
.map(|(idx, &score)| (idx, score))
.collect();
feature_scores
.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let top_features: Vec<FeatureImportance> = feature_scores
.into_iter()
.take(n_features)
.map(|(idx, score)| FeatureImportance {
feature_index: idx,
feature_name: self
.feature_names
.as_ref()
.and_then(|names| names.get(idx))
.cloned()
.unwrap_or_else(|| format!("feature_{idx}")),
importance_score: score,
loading_value: self.loadings[[idx, comp]],
})
.collect();
results.push(top_features);
}
results
}
pub fn component_contributions(&self) -> ComponentContribution {
let cumulative_variance: Array1<Float> = {
let mut cum = Array1::zeros(self.explained_variance_ratio.len());
let mut sum = 0.0;
for i in 0..self.explained_variance_ratio.len() {
sum += self.explained_variance_ratio[i];
cum[i] = sum;
}
cum
};
let total_variance_explained = cumulative_variance[cumulative_variance.len() - 1];
ComponentContribution {
individual_variance: self.explained_variance_ratio.clone(),
cumulative_variance,
total_variance_explained,
}
}
pub fn scree_plot_data(&self) -> ScreePlotData {
let component_numbers: Array1<Float> =
Array1::from_shape_fn(self.explained_variance.len(), |i| (i + 1) as Float);
ScreePlotData {
component_numbers,
eigenvalues: self.explained_variance.clone(),
explained_variance_ratios: self.explained_variance_ratio.clone(),
}
}
pub fn optimal_components(&self) -> OptimalComponentsAnalysis {
let n_components = self.explained_variance.len();
let mean_eigenvalue = self.explained_variance.mean().unwrap_or(0.0);
let kaiser_components = self
.explained_variance
.iter()
.position(|&x| x <= mean_eigenvalue)
.unwrap_or(n_components);
let elbow_components = self.find_elbow_point();
let variance_95_components = self
.explained_variance_ratio
.iter()
.scan(0.0, |acc, &x| {
*acc += x;
Some(*acc)
})
.position(|x| x >= 0.95)
.map(|x| x + 1)
.unwrap_or(n_components);
OptimalComponentsAnalysis {
kaiser_criterion: kaiser_components,
elbow_method: elbow_components,
variance_95_percent: variance_95_components,
total_components: n_components,
}
}
fn find_elbow_point(&self) -> usize {
let n = self.explained_variance.len();
if n < 3 {
return n;
}
let mut second_derivatives = Vec::new();
for i in 1..n - 1 {
let second_deriv = self.explained_variance[i - 1] - 2.0 * self.explained_variance[i]
+ self.explained_variance[i + 1];
second_derivatives.push(second_deriv.abs());
}
let elbow_idx = second_derivatives
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(idx, _)| idx + 2) .unwrap_or(n);
elbow_idx.min(n)
}
}
#[derive(Debug, Clone)]
pub struct LoadingPlotData {
pub x_loadings: Array1<Float>,
pub y_loadings: Array1<Float>,
pub component1: usize,
pub component2: usize,
pub explained_variance1: Float,
pub explained_variance2: Float,
pub feature_names: Option<Vec<String>>,
}
impl LoadingPlotData {
pub fn axis_labels(&self) -> (String, String) {
let x_label = format!(
"PC{} ({:.1}%)",
self.component1 + 1,
self.explained_variance1 * 100.0
);
let y_label = format!(
"PC{} ({:.1}%)",
self.component2 + 1,
self.explained_variance2 * 100.0
);
(x_label, y_label)
}
pub fn feature_coordinates(&self) -> Vec<(Float, Float, String)> {
let n_features = self.x_loadings.len();
let mut coordinates = Vec::with_capacity(n_features);
for i in 0..n_features {
let name = self
.feature_names
.as_ref()
.and_then(|names| names.get(i))
.cloned()
.unwrap_or_else(|| format!("feature_{i}"));
coordinates.push((self.x_loadings[i], self.y_loadings[i], name));
}
coordinates
}
}
#[derive(Debug, Clone)]
pub struct BiplotData {
pub x_scores: Array1<Float>,
pub y_scores: Array1<Float>,
pub loading_plot: LoadingPlotData,
}
impl BiplotData {
pub fn sample_coordinates(&self) -> Vec<(Float, Float)> {
self.x_scores
.iter()
.zip(self.y_scores.iter())
.map(|(&x, &y)| (x, y))
.collect()
}
pub fn scaled_loadings(&self, scale_factor: Float) -> Vec<(Float, Float, String)> {
self.loading_plot
.feature_coordinates()
.into_iter()
.map(|(x, y, name)| (x * scale_factor, y * scale_factor, name))
.collect()
}
}
#[derive(Debug, Clone)]
pub struct FeatureImportance {
pub feature_index: usize,
pub feature_name: String,
pub importance_score: Float,
pub loading_value: Float,
}
#[derive(Debug, Clone)]
pub struct ComponentContribution {
pub individual_variance: Array1<Float>,
pub cumulative_variance: Array1<Float>,
pub total_variance_explained: Float,
}
impl ComponentContribution {
pub fn components_for_variance(&self, threshold: Float) -> usize {
self.cumulative_variance
.iter()
.position(|&x| x >= threshold)
.map(|x| x + 1)
.unwrap_or(self.cumulative_variance.len())
}
pub fn variance_explained_by_n_components(&self, n: usize) -> Float {
if n == 0 {
0.0
} else if n >= self.cumulative_variance.len() {
self.total_variance_explained
} else {
self.cumulative_variance[n - 1]
}
}
}
#[derive(Debug, Clone)]
pub struct ScreePlotData {
pub component_numbers: Array1<Float>,
pub eigenvalues: Array1<Float>,
pub explained_variance_ratios: Array1<Float>,
}
#[derive(Debug, Clone)]
pub struct OptimalComponentsAnalysis {
pub kaiser_criterion: usize,
pub elbow_method: usize,
pub variance_95_percent: usize,
pub total_components: usize,
}
impl OptimalComponentsAnalysis {
pub fn recommendation(&self) -> ComponentRecommendation {
let criteria = vec![
self.kaiser_criterion,
self.elbow_method,
self.variance_95_percent,
];
let mut sorted_criteria = criteria.clone();
sorted_criteria.sort();
let median = sorted_criteria[sorted_criteria.len() / 2];
ComponentRecommendation {
recommended_components: median,
confidence: self.calculate_confidence(&criteria),
reasoning: self.generate_reasoning(&criteria),
}
}
fn calculate_confidence(&self, criteria: &[usize]) -> Float {
let max_val = *criteria.iter().max().unwrap_or(&1);
let min_val = *criteria.iter().min().unwrap_or(&1);
if max_val == min_val {
1.0 } else {
1.0 - (max_val - min_val) as Float / self.total_components as Float
}
}
fn generate_reasoning(&self, _criteria: &[usize]) -> String {
format!(
"Kaiser criterion suggests {} components, elbow method suggests {}, \
and 95% variance threshold suggests {} components.",
self.kaiser_criterion, self.elbow_method, self.variance_95_percent
)
}
}
#[derive(Debug, Clone)]
pub struct ComponentRecommendation {
pub recommended_components: usize,
pub confidence: Float,
pub reasoning: String,
}
pub struct DecompositionVisualizer;
impl DecompositionVisualizer {
pub fn pca_visualization(
components: &Array2<Float>,
transformed_data: &Array2<Float>,
explained_variance: &Array1<Float>,
) -> ComponentVisualization {
ComponentVisualization::new(
components.t().to_owned(), transformed_data.clone(),
explained_variance.clone(),
)
}
pub fn ica_visualization(
mixing_matrix: &Array2<Float>,
sources: &Array2<Float>,
) -> ComponentVisualization {
let n_components = mixing_matrix.ncols();
let explained_variance = Array1::ones(n_components);
ComponentVisualization::new(mixing_matrix.clone(), sources.clone(), explained_variance)
}
pub fn nmf_visualization(
components: &Array2<Float>,
transformed_data: &Array2<Float>,
) -> ComponentVisualization {
let explained_variance = components
.axis_iter(Axis(0))
.map(|comp| comp.dot(&comp))
.collect::<Array1<Float>>();
ComponentVisualization::new(
components.t().to_owned(),
transformed_data.clone(),
explained_variance,
)
}
pub fn factor_analysis_visualization(
loadings: &Array2<Float>,
factors: &Array2<Float>,
explained_variance: &Array1<Float>,
) -> ComponentVisualization {
ComponentVisualization::new(
loadings.clone(),
factors.clone(),
explained_variance.clone(),
)
}
}
#[allow(non_snake_case)]
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_abs_diff_eq;
#[test]
fn test_component_visualization_creation() {
let loadings = Array2::from_shape_vec((3, 2), vec![0.8, 0.2, 0.6, 0.5, 0.1, 0.9])
.expect("shape and data length should match");
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])
.expect("shape and data length should match");
let explained_variance = Array1::from_vec(vec![2.0, 1.0]);
let viz = ComponentVisualization::new(loadings, scores, explained_variance);
assert_eq!(viz.explained_variance_ratio.len(), 2);
assert_abs_diff_eq!(viz.explained_variance_ratio[0], 2.0 / 3.0, epsilon = 1e-10);
assert_abs_diff_eq!(viz.explained_variance_ratio[1], 1.0 / 3.0, epsilon = 1e-10);
}
#[test]
fn test_loading_plot_data() {
let loadings = Array2::from_shape_vec((3, 2), vec![0.8, 0.2, 0.6, 0.5, 0.1, 0.9])
.expect("shape and data length should match");
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])
.expect("shape and data length should match");
let explained_variance = Array1::from_vec(vec![2.0, 1.0]);
let viz = ComponentVisualization::new(loadings, scores, explained_variance);
let loading_plot = viz
.loading_plot_data(0, 1)
.expect("operation should succeed");
assert_eq!(loading_plot.x_loadings.len(), 3);
assert_eq!(loading_plot.y_loadings.len(), 3);
assert_eq!(loading_plot.component1, 0);
assert_eq!(loading_plot.component2, 1);
let (x_label, y_label) = loading_plot.axis_labels();
assert!(x_label.contains("PC1"));
assert!(y_label.contains("PC2"));
}
#[test]
fn test_feature_importance() {
let loadings = Array2::from_shape_vec((3, 2), vec![0.8, 0.2, 0.6, 0.5, 0.1, 0.9])
.expect("shape and data length should match");
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])
.expect("shape and data length should match");
let explained_variance = Array1::from_vec(vec![2.0, 1.0]);
let viz = ComponentVisualization::new(loadings, scores, explained_variance);
let importance = viz.feature_importance();
assert_eq!(importance.dim(), (3, 2));
for &val in importance.iter() {
assert!(val >= 0.0);
}
}
#[test]
fn test_top_features_per_component() {
let loadings = Array2::from_shape_vec((3, 2), vec![0.8, 0.2, 0.6, 0.5, 0.1, 0.9])
.expect("shape and data length should match");
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])
.expect("shape and data length should match");
let explained_variance = Array1::from_vec(vec![2.0, 1.0]);
let viz = ComponentVisualization::new(loadings, scores, explained_variance);
let top_features = viz.top_features_per_component(2);
assert_eq!(top_features.len(), 2); assert_eq!(top_features[0].len(), 2); assert_eq!(top_features[1].len(), 2); }
#[test]
fn test_component_contributions() {
let loadings = Array2::from_shape_vec((3, 2), vec![0.8, 0.2, 0.6, 0.5, 0.1, 0.9])
.expect("shape and data length should match");
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])
.expect("shape and data length should match");
let explained_variance = Array1::from_vec(vec![2.0, 1.0]);
let viz = ComponentVisualization::new(loadings, scores, explained_variance);
let contributions = viz.component_contributions();
assert_eq!(contributions.cumulative_variance.len(), 2);
assert!(contributions.cumulative_variance[0] <= contributions.cumulative_variance[1]);
assert_abs_diff_eq!(contributions.total_variance_explained, 1.0, epsilon = 1e-10);
}
#[test]
fn test_optimal_components_analysis() {
let loadings = Array2::from_shape_vec(
(5, 5),
vec![
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,
0.05, 0.1, 0.8, 0.4, 0.1, 0.02, 0.01, 0.1, 0.7, 0.6,
],
)
.expect("operation should succeed");
let scores = Array2::zeros((10, 5));
let explained_variance = Array1::from_vec(vec![3.0, 2.0, 1.5, 0.8, 0.2]);
let viz = ComponentVisualization::new(loadings, scores, explained_variance);
let analysis = viz.optimal_components();
assert!(analysis.kaiser_criterion <= 5);
assert!(analysis.elbow_method <= 5);
assert!(analysis.variance_95_percent <= 5);
assert_eq!(analysis.total_components, 5);
let recommendation = analysis.recommendation();
assert!(recommendation.recommended_components <= 5);
assert!(recommendation.confidence >= 0.0 && recommendation.confidence <= 1.0);
assert!(!recommendation.reasoning.is_empty());
}
#[test]
fn test_decomposition_visualizer() {
let components = Array2::from_shape_vec((2, 3), vec![0.8, 0.6, 0.1, 0.2, 0.5, 0.9])
.expect("shape and data length should match");
let transformed_data =
Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 0.5, 1.5, 2.5, 3.5])
.expect("shape and data length should match");
let explained_variance = Array1::from_vec(vec![2.0, 1.0]);
let viz = DecompositionVisualizer::pca_visualization(
&components,
&transformed_data,
&explained_variance,
);
assert_eq!(viz.loadings.dim(), (3, 2)); assert_eq!(viz.scores.dim(), (4, 2));
assert_eq!(viz.explained_variance.len(), 2);
}
#[test]
fn test_biplot_data() {
let loadings = Array2::from_shape_vec((3, 2), vec![0.8, 0.2, 0.6, 0.5, 0.1, 0.9])
.expect("shape and data length should match");
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])
.expect("shape and data length should match");
let explained_variance = Array1::from_vec(vec![2.0, 1.0]);
let viz = ComponentVisualization::new(loadings, scores, explained_variance);
let biplot = viz.biplot_data(0, 1).expect("operation should succeed");
assert_eq!(biplot.x_scores.len(), 4);
assert_eq!(biplot.y_scores.len(), 4);
let sample_coords = biplot.sample_coordinates();
assert_eq!(sample_coords.len(), 4);
let scaled_loadings = biplot.scaled_loadings(1.5);
assert_eq!(scaled_loadings.len(), 3);
}
}