use crate::error::Result;
use crate::primitives::{Matrix, Vector};
#[derive(Debug, Clone)]
pub struct QuadraticDiscriminantAnalysis {
classes: Option<Vec<usize>>,
means: Option<Vec<Vec<f32>>>,
log_priors: Option<Vec<f32>>,
chol: Option<Vec<Vec<f32>>>,
log_det: Option<Vec<f32>>,
reg_param: f32,
}
impl QuadraticDiscriminantAnalysis {
#[must_use]
pub fn new() -> Self {
Self {
classes: None,
means: None,
log_priors: None,
chol: None,
log_det: None,
reg_param: 0.0,
}
}
#[must_use]
pub fn with_reg_param(mut self, reg_param: f32) -> Self {
self.reg_param = reg_param;
self
}
#[must_use]
pub fn classes(&self) -> Option<&[usize]> {
self.classes.as_deref()
}
pub fn fit(&mut self, x: &Matrix<f32>, y: &[usize]) -> Result<()> {
let (n_samples, n_features) = x.shape();
if n_samples == 0 {
return Err("Cannot fit with empty data".into());
}
if y.len() != n_samples {
return Err("Number of samples in X and y must match".into());
}
let classes = unique_sorted(y);
if classes.len() < 2 {
return Err("Need at least 2 classes".into());
}
let mut means = Vec::with_capacity(classes.len());
let mut log_priors = Vec::with_capacity(classes.len());
let mut chol = Vec::with_capacity(classes.len());
let mut log_det = Vec::with_capacity(classes.len());
for &class_label in &classes {
let rows: Vec<usize> = (0..n_samples).filter(|&i| y[i] == class_label).collect();
let n_c = rows.len();
if n_c < 2 {
return Err("Each class needs at least 2 samples for a covariance".into());
}
let mean = class_mean(x, &rows, n_features);
let cov = class_covariance(x, &rows, &mean, n_features, (n_c - 1) as f32);
let l = self.cholesky_with_ridge(&cov, n_features)?;
let ld = log_det_from_cholesky(&l, n_features);
means.push(mean);
log_priors.push((n_c as f32 / n_samples as f32).ln());
chol.push(l);
log_det.push(ld);
}
self.classes = Some(classes);
self.means = Some(means);
self.log_priors = Some(log_priors);
self.chol = Some(chol);
self.log_det = Some(log_det);
Ok(())
}
fn cholesky_with_ridge(&self, cov: &[f32], d: usize) -> Result<Vec<f32>> {
if let Some(l) = cholesky_lower(cov, d) {
return Ok(l);
}
let mean_diag = (0..d).map(|i| cov[i * d + i]).sum::<f32>() / d as f32;
let ridge = if self.reg_param > 0.0 {
self.reg_param * mean_diag
} else {
1e-6 * mean_diag.max(1e-12)
};
let mut reg = cov.to_vec();
for i in 0..d {
reg[i * d + i] += ridge;
}
cholesky_lower(®, d).ok_or_else(|| {
"Class covariance is not positive-definite even after regularization".into()
})
}
fn log_posteriors_row(&self, x: &Matrix<f32>, row: usize) -> Vec<f32> {
let means = self.means.as_ref().expect("fitted");
let chol = self.chol.as_ref().expect("fitted");
let log_det = self.log_det.as_ref().expect("fitted");
let log_priors = self.log_priors.as_ref().expect("fitted");
let d = means[0].len();
let two_pi_term = d as f32 * (2.0 * std::f32::consts::PI).ln();
let mut out = Vec::with_capacity(means.len());
for c in 0..means.len() {
let mut diff = vec![0.0f32; d];
for (j, dj) in diff.iter_mut().enumerate() {
*dj = x.get(row, j) - means[c][j];
}
let z = forward_substitute(&chol[c], &diff, d);
let mahal: f32 = z.iter().map(|&v| v * v).sum();
let lp = -0.5 * (two_pi_term + log_det[c] + mahal) + log_priors[c];
out.push(lp);
}
out
}
pub fn predict(&self, x: &Matrix<f32>) -> Result<Vec<usize>> {
let classes = self.classes.as_ref().ok_or("Model not fitted")?;
let means = self.means.as_ref().ok_or("Model not fitted")?;
let (n_samples, n_features) = x.shape();
if n_features != means[0].len() {
return Err("Feature dimension mismatch".into());
}
let mut preds = Vec::with_capacity(n_samples);
for row in 0..n_samples {
let lp = self.log_posteriors_row(x, row);
preds.push(classes[argmax(&lp)]);
}
Ok(preds)
}
pub fn predict_proba(&self, x: &Matrix<f32>) -> Result<Vec<Vec<f32>>> {
let means = self.means.as_ref().ok_or("Model not fitted")?;
let (n_samples, n_features) = x.shape();
if n_features != means[0].len() {
return Err("Feature dimension mismatch".into());
}
let mut out = Vec::with_capacity(n_samples);
for row in 0..n_samples {
out.push(softmax(&self.log_posteriors_row(x, row)));
}
Ok(out)
}
}
impl Default for QuadraticDiscriminantAnalysis {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct LinearDiscriminantAnalysis {
classes: Option<Vec<usize>>,
coef: Option<Vec<Vec<f32>>>,
intercept: Option<Vec<f32>>,
}
impl LinearDiscriminantAnalysis {
#[must_use]
pub fn new() -> Self {
Self {
classes: None,
coef: None,
intercept: None,
}
}
#[must_use]
pub fn classes(&self) -> Option<&[usize]> {
self.classes.as_deref()
}
#[must_use]
pub fn coef(&self) -> Option<&Vec<Vec<f32>>> {
self.coef.as_ref()
}
#[must_use]
pub fn intercept(&self) -> Option<&[f32]> {
self.intercept.as_deref()
}
pub fn fit(&mut self, x: &Matrix<f32>, y: &[usize]) -> Result<()> {
let (n_samples, n_features) = x.shape();
if n_samples == 0 {
return Err("Cannot fit with empty data".into());
}
if y.len() != n_samples {
return Err("Number of samples in X and y must match".into());
}
let classes = unique_sorted(y);
if classes.len() < 2 {
return Err("Need at least 2 classes".into());
}
let mut means = Vec::with_capacity(classes.len());
let mut log_priors = Vec::with_capacity(classes.len());
let mut scatter = vec![0.0f32; n_features * n_features];
for &class_label in &classes {
let rows: Vec<usize> = (0..n_samples).filter(|&i| y[i] == class_label).collect();
let n_c = rows.len();
let mean = class_mean(x, &rows, n_features);
for &i in &rows {
for a in 0..n_features {
let da = x.get(i, a) - mean[a];
for b in 0..n_features {
let db = x.get(i, b) - mean[b];
scatter[a * n_features + b] += da * db;
}
}
}
log_priors.push((n_c as f32 / n_samples as f32).ln());
means.push(mean);
}
let inv_n = 1.0 / n_samples as f32;
let cov_data: Vec<f32> = scatter.iter().map(|&v| v * inv_n).collect();
let cov = Matrix::from_vec(n_features, n_features, cov_data)
.map_err(Into::<crate::error::AprenderError>::into)?;
let mut coef = Vec::with_capacity(classes.len());
let mut intercept = Vec::with_capacity(classes.len());
for c in 0..classes.len() {
let mu = Vector::from_vec(means[c].clone());
let w = cov
.cholesky_solve(&mu)
.map_err(Into::<crate::error::AprenderError>::into)?;
let w_vec: Vec<f32> = (0..n_features).map(|j| w[j]).collect();
let dot: f32 = (0..n_features).map(|j| w_vec[j] * means[c][j]).sum();
intercept.push(-0.5 * dot + log_priors[c]);
coef.push(w_vec);
}
self.classes = Some(classes);
self.coef = Some(coef);
self.intercept = Some(intercept);
Ok(())
}
fn decision_row(&self, x: &Matrix<f32>, row: usize) -> Vec<f32> {
let coef = self.coef.as_ref().expect("fitted");
let intercept = self.intercept.as_ref().expect("fitted");
let d = coef[0].len();
(0..coef.len())
.map(|c| {
let mut acc = intercept[c];
for j in 0..d {
acc += coef[c][j] * x.get(row, j);
}
acc
})
.collect()
}
pub fn decision_function(&self, x: &Matrix<f32>) -> Result<Vec<Vec<f32>>> {
let coef = self.coef.as_ref().ok_or("Model not fitted")?;
let (n_samples, n_features) = x.shape();
if n_features != coef[0].len() {
return Err("Feature dimension mismatch".into());
}
Ok((0..n_samples)
.map(|row| self.decision_row(x, row))
.collect())
}
pub fn predict(&self, x: &Matrix<f32>) -> Result<Vec<usize>> {
let classes = self.classes.as_ref().ok_or("Model not fitted")?;
let coef = self.coef.as_ref().ok_or("Model not fitted")?;
let (n_samples, n_features) = x.shape();
if n_features != coef[0].len() {
return Err("Feature dimension mismatch".into());
}
let mut preds = Vec::with_capacity(n_samples);
for row in 0..n_samples {
let dec = self.decision_row(x, row);
preds.push(classes[argmax(&dec)]);
}
Ok(preds)
}
pub fn predict_proba(&self, x: &Matrix<f32>) -> Result<Vec<Vec<f32>>> {
let coef = self.coef.as_ref().ok_or("Model not fitted")?;
let (n_samples, n_features) = x.shape();
if n_features != coef[0].len() {
return Err("Feature dimension mismatch".into());
}
let mut out = Vec::with_capacity(n_samples);
for row in 0..n_samples {
out.push(softmax(&self.decision_row(x, row)));
}
Ok(out)
}
}
impl Default for LinearDiscriminantAnalysis {
fn default() -> Self {
Self::new()
}
}
fn unique_sorted(y: &[usize]) -> Vec<usize> {
let mut c = y.to_vec();
c.sort_unstable();
c.dedup();
c
}
fn class_mean(x: &Matrix<f32>, rows: &[usize], n_features: usize) -> Vec<f32> {
let mut mean = vec![0.0f32; n_features];
for &i in rows {
for (j, mj) in mean.iter_mut().enumerate() {
*mj += x.get(i, j);
}
}
let inv = 1.0 / rows.len() as f32;
for m in &mut mean {
*m *= inv;
}
mean
}
fn class_covariance(
x: &Matrix<f32>,
rows: &[usize],
mean: &[f32],
n_features: usize,
denom: f32,
) -> Vec<f32> {
let mut cov = vec![0.0f32; n_features * n_features];
for &i in rows {
for a in 0..n_features {
let da = x.get(i, a) - mean[a];
for b in 0..n_features {
let db = x.get(i, b) - mean[b];
cov[a * n_features + b] += da * db;
}
}
}
let inv = 1.0 / denom;
for v in &mut cov {
*v *= inv;
}
cov
}
fn cholesky_lower(a: &[f32], n: usize) -> Option<Vec<f32>> {
let mut l = vec![0.0f32; n * n];
for i in 0..n {
for j in 0..=i {
let mut sum = 0.0;
if i == j {
for k in 0..j {
sum += l[j * n + k] * l[j * n + k];
}
let diag = a[j * n + j] - sum;
if diag <= 0.0 {
return None;
}
l[j * n + j] = diag.sqrt();
} else {
for k in 0..j {
sum += l[i * n + k] * l[j * n + k];
}
l[i * n + j] = (a[i * n + j] - sum) / l[j * n + j];
}
}
}
Some(l)
}
fn forward_substitute(l: &[f32], b: &[f32], n: usize) -> Vec<f32> {
let mut z = vec![0.0f32; n];
for i in 0..n {
let mut sum = 0.0;
for j in 0..i {
sum += l[i * n + j] * z[j];
}
z[i] = (b[i] - sum) / l[i * n + i];
}
z
}
fn log_det_from_cholesky(l: &[f32], n: usize) -> f32 {
2.0 * (0..n).map(|i| l[i * n + i].ln()).sum::<f32>()
}
fn argmax(v: &[f32]) -> usize {
let mut best = 0;
let mut best_v = f32::NEG_INFINITY;
for (i, &x) in v.iter().enumerate() {
if x > best_v {
best_v = x;
best = i;
}
}
best
}
fn softmax(logits: &[f32]) -> Vec<f32> {
let m = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let mut exps: Vec<f32> = logits.iter().map(|&v| (v - m).exp()).collect();
let sum: f32 = exps.iter().sum();
for e in &mut exps {
*e /= sum;
}
exps
}
#[cfg(test)]
#[path = "tests_discriminant_analysis.rs"]
mod tests_discriminant_analysis;