use crate::algorithms::decomposition::{
at, count_to_f64, jacobi_eigen, mean_center, reconstruction_error, symmetric_inverse,
};
const SMALL: f64 = 1e-12;
#[derive(Debug, Clone)]
pub struct FactorResult {
pub loadings: Vec<Vec<f64>>,
pub noise_variance: Vec<f64>,
pub reconstruction_error: f64,
}
#[must_use]
pub fn factor_analysis(
data: &[Vec<f64>],
n_components: usize,
max_iter: usize,
tol: f64,
) -> FactorResult {
let dim = data.first().map_or(0, Vec::len);
let k = n_components.min(dim);
let n = data.len();
if dim == 0 || k == 0 || n == 0 {
return FactorResult {
loadings: Vec::new(),
noise_variance: Vec::new(),
reconstruction_error: 0.0,
};
}
let (centered, means) = mean_center(data, dim);
let var = column_variance(¢ered, dim);
let nsqrt = count_to_f64(n).sqrt();
let llconst = count_to_f64(dim).mul_add((2.0 * std::f64::consts::PI).ln(), count_to_f64(k));
let mut psi = vec![1.0_f64; dim];
let mut loadings = vec![vec![0.0_f64; dim]; k];
let mut old_ll = f64::NEG_INFINITY;
for _ in 0..max_iter {
let sqrt_psi: Vec<f64> = psi.iter().map(|&p| p.sqrt() + SMALL).collect();
let scaled = scale_columns(¢ered, &sqrt_psi, nsqrt);
let (sq_singular, right_vectors, unexplained) = top_k_svd(&scaled, dim, k);
loadings = build_loadings(&sq_singular, &right_vectors, &sqrt_psi, dim, k);
let ll = log_likelihood(llconst, &sq_singular, &psi, unexplained, n);
if (ll - old_ll) < tol {
break;
}
old_ll = ll;
update_noise(&mut psi, &var, &loadings, dim);
}
let reconstructed = reconstruct(¢ered, &loadings, &psi, &means, dim, k);
let error = reconstruction_error(data, &reconstructed);
FactorResult {
loadings,
noise_variance: psi,
reconstruction_error: error,
}
}
fn column_variance(centered: &[Vec<f64>], dim: usize) -> Vec<f64> {
let mut var = vec![0.0_f64; dim];
for row in centered {
for (v, &x) in var.iter_mut().zip(row) {
*v = x.mul_add(x, *v);
}
}
let n = count_to_f64(centered.len());
if n > 0.0 {
for v in &mut var {
*v /= n;
}
}
var
}
fn scale_columns(centered: &[Vec<f64>], sqrt_psi: &[f64], nsqrt: f64) -> Vec<Vec<f64>> {
centered
.iter()
.map(|row| {
row.iter()
.zip(sqrt_psi)
.map(|(&x, &sp)| x / (sp * nsqrt))
.collect()
})
.collect()
}
fn top_k_svd(scaled: &[Vec<f64>], dim: usize, k: usize) -> (Vec<f64>, Vec<Vec<f64>>, f64) {
let mut gram = vec![0.0_f64; dim * dim];
for row in scaled {
for i in 0..dim {
let ri = row.get(i).copied().unwrap_or(0.0);
for j in 0..dim {
let rj = row.get(j).copied().unwrap_or(0.0);
if let Some(slot) = gram.get_mut(i * dim + j) {
*slot = ri.mul_add(rj, *slot);
}
}
}
}
let (values, vectors) = jacobi_eigen(&gram, dim);
let mut order: Vec<usize> = (0..dim).collect();
order.sort_by(|&a, &b| {
let va = values.get(a).copied().unwrap_or(f64::NEG_INFINITY);
let vb = values.get(b).copied().unwrap_or(f64::NEG_INFINITY);
vb.partial_cmp(&va).unwrap_or(std::cmp::Ordering::Equal)
});
let mut sq_singular = Vec::with_capacity(k);
let mut right_vectors = Vec::with_capacity(k);
for &col in order.iter().take(k) {
sq_singular.push(values.get(col).copied().unwrap_or(0.0).max(0.0));
right_vectors.push((0..dim).map(|row| at(&vectors, dim, row, col)).collect());
}
let unexplained: f64 = order
.iter()
.skip(k)
.map(|&col| values.get(col).copied().unwrap_or(0.0).max(0.0))
.sum();
(sq_singular, right_vectors, unexplained)
}
fn build_loadings(
sq_singular: &[f64],
right_vectors: &[Vec<f64>],
sqrt_psi: &[f64],
dim: usize,
k: usize,
) -> Vec<Vec<f64>> {
(0..k)
.map(|c| {
let scale = (sq_singular.get(c).copied().unwrap_or(0.0) - 1.0)
.max(0.0)
.sqrt();
let vector = right_vectors.get(c);
(0..dim)
.map(|j| {
let v = vector.and_then(|row| row.get(j)).copied().unwrap_or(0.0);
scale * v * sqrt_psi.get(j).copied().unwrap_or(0.0)
})
.collect()
})
.collect()
}
fn log_likelihood(
llconst: f64,
sq_singular: &[f64],
psi: &[f64],
unexplained: f64,
n: usize,
) -> f64 {
let log_s: f64 = sq_singular.iter().map(|&s| s.max(SMALL).ln()).sum();
let log_psi: f64 = psi.iter().map(|&p| p.ln()).sum();
let ll = llconst + log_s + unexplained + log_psi;
ll * (-count_to_f64(n) / 2.0)
}
fn update_noise(psi: &mut [f64], var: &[f64], loadings: &[Vec<f64>], dim: usize) {
for j in 0..dim {
let explained: f64 = loadings
.iter()
.map(|row| {
let w = row.get(j).copied().unwrap_or(0.0);
w * w
})
.sum();
if let Some(slot) = psi.get_mut(j) {
*slot = (var.get(j).copied().unwrap_or(0.0) - explained).max(SMALL);
}
}
}
fn reconstruct(
centered: &[Vec<f64>],
loadings: &[Vec<f64>],
psi: &[f64],
means: &[f64],
dim: usize,
k: usize,
) -> Vec<Vec<f64>> {
let wpsi: Vec<Vec<f64>> = loadings
.iter()
.map(|row| {
row.iter()
.zip(psi)
.map(|(&w, &p)| w / p)
.collect::<Vec<f64>>()
})
.collect();
let mut posterior = vec![0.0_f64; k * k];
for a in 0..k {
for b in 0..k {
let dot: f64 = wpsi.get(a).zip(loadings.get(b)).map_or(0.0, |(wa, lb)| {
wa.iter().zip(lb).map(|(&x, &y)| x * y).sum()
});
let eye = if a == b { 1.0 } else { 0.0 };
if let Some(slot) = posterior.get_mut(a * k + b) {
*slot = dot + eye;
}
}
}
let cov_z = symmetric_inverse(&posterior, k);
centered
.iter()
.map(|row| {
let tmp: Vec<f64> = (0..k)
.map(|c| {
wpsi.get(c)
.map_or(0.0, |w| row.iter().zip(w).map(|(&x, &y)| x * y).sum())
})
.collect();
let z: Vec<f64> = (0..k)
.map(|c| {
(0..k)
.map(|a| tmp.get(a).copied().unwrap_or(0.0) * at(&cov_z, k, a, c))
.sum()
})
.collect();
(0..dim)
.map(|j| {
let acc: f64 = (0..k)
.map(|c| {
z.get(c).copied().unwrap_or(0.0)
* loadings
.get(c)
.and_then(|w| w.get(j))
.copied()
.unwrap_or(0.0)
})
.sum();
acc + means.get(j).copied().unwrap_or(0.0)
})
.collect()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_factor_captures_shared_trend() {
let data = vec![
vec![1.0, 1.1],
vec![2.0, 2.2],
vec![3.0, 2.9],
vec![4.0, 4.1],
];
let r = factor_analysis(&data, 1, 1000, 1e-2);
assert!(
r.reconstruction_error < 0.1,
"error was {}",
r.reconstruction_error
);
}
#[test]
fn empty_input_is_empty() {
let r = factor_analysis(&[], 2, 100, 1e-2);
assert!(r.loadings.is_empty(), "loadings not empty");
}
#[test]
fn k_is_clamped_to_dimension() {
let data = vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![5.0, 6.0]];
let r = factor_analysis(&data, 9, 100, 1e-2);
assert!(r.loadings.len() <= 2, "factor count exceeded dim");
}
}