use std::borrow::Borrow;
use nalgebra::{DMatrix, DVector, SVD};
use serde::{Deserialize, Serialize};
use super::{as_refs, on_grid, r_factor, spectrum_label, AnalysisInput, AnalysisSpace};
use crate::xafs::errors::AnalysisError;
use crate::xafs::xasspectrum::XASSpectrum;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PcaConfig {
pub space: AnalysisSpace,
pub range: Option<(f64, f64)>,
pub center: bool,
}
impl Default for PcaConfig {
fn default() -> Self {
Self {
space: AnalysisSpace::Norm,
range: None,
center: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PcaModel {
pub space: AnalysisSpace,
pub centered: bool,
pub x: DVector<f64>,
pub labels: Vec<String>,
pub data: DMatrix<f64>,
pub mean: DVector<f64>,
pub components: DMatrix<f64>,
pub eigenvalues: Vec<f64>,
pub variance_explained: Vec<f64>,
pub cumulative_variance: Vec<f64>,
pub ind: Vec<f64>,
pub scores: DMatrix<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PcaCountBasis {
NumericalRank,
IndicatorMinimum,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PcaCountSuggestion {
pub count: usize,
pub basis: PcaCountBasis,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct PcaReconstructionError {
pub components: usize,
pub sse: f64,
pub relative_error: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PcaFit {
pub n_components: usize,
pub x: DVector<f64>,
pub data: DVector<f64>,
pub fit: DVector<f64>,
pub residual: DVector<f64>,
pub weights: Vec<f64>,
pub chi_square: f64,
pub reduced_chi_square: f64,
pub r_factor: f64,
}
pub fn pca_train<S: Borrow<XASSpectrum>>(
spectra: &[S],
cfg: &PcaConfig,
) -> Result<PcaModel, AnalysisError> {
let refs = as_refs(spectra);
let n = refs.len();
if n < 2 {
return Err(AnalysisError::InsufficientSpectra { min: 2, actual: n });
}
let first = AnalysisInput::with_label(refs[0], cfg.space, "spectrum0".into())?;
let bounds = first.bounds(cfg.space, cfg.range)?;
let (x, y0) = first.select(bounds, 2)?;
let p = x.len();
let mut data = DMatrix::zeros(n, p);
data.set_row(0, &y0.transpose());
for (i, s) in refs.iter().enumerate().skip(1) {
let y = AnalysisInput::with_label(s, cfg.space, format!("spectrum{i}"))?
.interpolate(&x, bounds)?;
data.set_row(i, &y.transpose());
}
let labels = refs
.iter()
.enumerate()
.map(|(i, s)| spectrum_label(s, format!("spectrum{i}")))
.collect();
let mean = if cfg.center {
DVector::from_fn(p, |j, _| data.column(j).sum() / n as f64)
} else {
DVector::zeros(p)
};
let mut centered = data.clone();
if cfg.center {
for i in 0..n {
let row = centered.row(i) - mean.transpose();
centered.set_row(i, &row);
}
}
let svd = SVD::new(centered.clone(), true, true);
let (u, v_t) = match (svd.u, svd.v_t) {
(Some(u), Some(v_t)) => (u, v_t),
_ => {
return Err(AnalysisError::LinearAlgebra {
reason: "SVD did not return singular vectors".to_string(),
})
}
};
let sigma = svd.singular_values;
let n_comp = sigma.len();
let sigma_sq: Vec<f64> = sigma.iter().map(|s| s * s).collect();
let total: f64 = sigma_sq.iter().sum();
let eigenvalues: Vec<f64> = sigma_sq.iter().map(|s| s / n as f64).collect();
let variance_explained: Vec<f64> = sigma_sq
.iter()
.map(|s| if total > 0.0 { s / total } else { 0.0 })
.collect();
let cumulative_variance: Vec<f64> = variance_explained
.iter()
.scan(0.0, |acc, v| {
*acc += v;
Some(*acc)
})
.collect();
let mut scores = u.columns(0, n_comp).into_owned();
for j in 0..n_comp {
let mut col = scores.column_mut(j);
col *= sigma[j];
}
let ind = malinowski_ind(&eigenvalues, n, p);
Ok(PcaModel {
space: cfg.space,
centered: cfg.center,
x,
labels,
data,
mean,
components: v_t.rows(0, n_comp).into_owned(),
eigenvalues,
variance_explained,
cumulative_variance,
ind,
scores,
})
}
fn malinowski_ind(eigenvalues: &[f64], n_spectra: usize, n_points: usize) -> Vec<f64> {
let c = n_spectra;
let r = n_points as f64;
(0..c)
.map(|k| {
let tail: f64 = eigenvalues.iter().skip(k).sum();
let remaining = (c - k) as f64;
(tail / (r * remaining)).sqrt() / (remaining * remaining)
})
.collect()
}
impl PcaModel {
pub fn n_spectra(&self) -> usize {
self.data.nrows()
}
pub fn n_components(&self) -> usize {
self.components.nrows()
}
pub fn numerical_rank(&self) -> usize {
let tolerance =
f64::EPSILON * self.data.nrows().max(self.data.ncols()) as f64 * self.data.norm();
self.eigenvalues
.iter()
.filter(|&&v| {
v.is_finite() && (v.max(0.0) * self.n_spectra() as f64).sqrt() > tolerance
})
.count()
}
pub fn component_count_suggestion(&self) -> Option<PcaCountSuggestion> {
let rank = self.numerical_rank();
let possible = self
.n_components()
.min(self.n_spectra().saturating_sub(usize::from(self.centered)));
if rank == 0 {
return None;
}
if rank < possible {
return Some(PcaCountSuggestion {
count: rank,
basis: PcaCountBasis::NumericalRank,
});
}
let limit = possible.min(self.ind.len().saturating_sub(1));
let best = (1..limit)
.filter(|&k| self.ind[k].is_finite() && self.ind[k] > 0.0)
.min_by(|&a, &b| self.ind[a].total_cmp(&self.ind[b]))?;
self.ind
.get(limit)
.is_some_and(|&last| self.ind[best] < last && self.ind[best] < self.ind[0])
.then_some(PcaCountSuggestion {
count: best,
basis: PcaCountBasis::IndicatorMinimum,
})
}
pub fn reconstruction_errors(&self) -> Vec<PcaReconstructionError> {
let norm = self.data.norm_squared();
(0..=self.n_components())
.map(|components| {
let sse =
self.eigenvalues.iter().skip(components).sum::<f64>() * self.n_spectra() as f64;
PcaReconstructionError {
components,
sse,
relative_error: if norm > 0.0 { sse / norm } else { f64::NAN },
}
})
.collect()
}
pub fn suggested_components_ind(&self) -> usize {
let mut best = 1;
let mut best_val = f64::INFINITY;
for (k, &v) in self.ind.iter().enumerate().skip(1) {
if v.is_finite() && v < best_val {
best_val = v;
best = k;
}
}
best.min(self.n_components().max(1))
}
pub fn suggested_components_variance(&self, threshold: f64) -> usize {
self.cumulative_variance
.iter()
.position(|&v| v >= threshold)
.map(|i| i + 1)
.unwrap_or(self.n_components())
}
pub fn reconstruct(
&self,
y: &DVector<f64>,
n_components: usize,
) -> Result<PcaFit, AnalysisError> {
if n_components > self.n_components() {
return Err(AnalysisError::TooManyComponents {
requested: n_components,
available: self.n_components(),
});
}
if y.len() != self.x.len() {
return Err(AnalysisError::LinearAlgebra {
reason: format!(
"spectrum has {} points, model grid has {}",
y.len(),
self.x.len()
),
});
}
let comps = self.components.rows(0, n_components);
let centered = y - &self.mean;
let w = comps * ¢ered;
let fit = &self.mean + comps.transpose() * &w;
let residual = y - &fit;
let chi_square: f64 = residual.iter().map(|r| r * r).sum();
let dof = y.len().saturating_sub(n_components).max(1) as f64;
Ok(PcaFit {
n_components,
x: self.x.clone(),
r_factor: r_factor(y, &fit),
data: y.clone(),
fit,
residual,
weights: w.iter().copied().collect(),
chi_square,
reduced_chi_square: chi_square / dof,
})
}
pub fn reconstruct_training(
&self,
index: usize,
n_components: usize,
) -> Result<PcaFit, AnalysisError> {
if index >= self.n_spectra() {
return Err(crate::xafs::errors::DataError::IndexOutOfRange {
index,
length: self.n_spectra(),
}
.into());
}
let y = self.data.row(index).transpose();
self.reconstruct(&y, n_components)
}
pub fn target_transform(
&self,
spectrum: &XASSpectrum,
n_components: usize,
) -> Result<PcaFit, AnalysisError> {
let y = on_grid(spectrum, self.space, &self.x)?;
self.reconstruct(&y, n_components)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ind_minimum_at_true_rank() {
let eig = [10.0, 1.0, 1e-4, 1e-4, 1e-4];
let ind = malinowski_ind(&eig, 5, 200);
let argmin = ind
.iter()
.enumerate()
.skip(1)
.min_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.unwrap()
.0;
assert_eq!(argmin, 2);
}
}