use anofox_ml_core::{Fit, Predict, Result, RustMlError};
use ndarray::{Array1, Array2};
#[derive(Debug, Clone)]
pub struct PlsRegression {
pub n_components: usize,
pub max_iter: usize,
pub tol: f64,
}
impl PlsRegression {
pub fn new(n_components: usize) -> Self {
Self {
n_components,
max_iter: 500,
tol: 1e-6,
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct FittedPlsRegression {
pub x_mean: Array1<f64>,
pub y_mean: f64,
pub x_std: Array1<f64>,
pub y_std: f64,
pub coef: Array1<f64>,
n_features: usize,
}
impl Fit<f64> for PlsRegression {
type Fitted = FittedPlsRegression;
fn fit(&self, x: &Array2<f64>, y: &Array1<f64>) -> Result<Self::Fitted> {
if x.nrows() != y.len() {
return Err(RustMlError::ShapeMismatch(format!(
"X has {} rows but y has {}",
x.nrows(),
y.len()
)));
}
let n = x.nrows();
let d = x.ncols();
if self.n_components == 0 || self.n_components > d.min(n) {
return Err(RustMlError::InvalidParameter(format!(
"n_components must be in 1..={}",
d.min(n)
)));
}
let n_f = n as f64;
let mut x_mean = Array1::<f64>::zeros(d);
for j in 0..d {
x_mean[j] = x.column(j).sum() / n_f;
}
let y_mean = y.sum() / n_f;
let mut x_std = Array1::<f64>::ones(d);
for j in 0..d {
let mut v = 0.0;
for i in 0..n {
let dv = x[[i, j]] - x_mean[j];
v += dv * dv;
}
x_std[j] = (v / n_f).sqrt().max(1e-12);
}
let mut yv = 0.0;
for i in 0..n {
let dv = y[i] - y_mean;
yv += dv * dv;
}
let y_std = (yv / n_f).sqrt().max(1e-12);
let mut xs = Array2::<f64>::zeros((n, d));
let mut ys = Array1::<f64>::zeros(n);
for i in 0..n {
for j in 0..d {
xs[[i, j]] = (x[[i, j]] - x_mean[j]) / x_std[j];
}
ys[i] = (y[i] - y_mean) / y_std;
}
let k = self.n_components;
let mut p_mat = Array2::<f64>::zeros((d, k));
let mut w_mat = Array2::<f64>::zeros((d, k));
let mut q_vec = Array1::<f64>::zeros(k);
let mut x_def = xs.clone();
let mut y_def = ys.clone();
for comp in 0..k {
let mut w = Array1::<f64>::zeros(d);
for j in 0..d {
let mut s = 0.0;
for i in 0..n {
s += x_def[[i, j]] * y_def[i];
}
w[j] = s;
}
let nw = w.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-12);
for j in 0..d {
w[j] /= nw;
}
let mut t = Array1::<f64>::zeros(n);
for i in 0..n {
let mut s = 0.0;
for j in 0..d {
s += x_def[[i, j]] * w[j];
}
t[i] = s;
}
let tt: f64 = t.iter().map(|v| v * v).sum::<f64>().max(1e-12);
let mut p = Array1::<f64>::zeros(d);
for j in 0..d {
let mut s = 0.0;
for i in 0..n {
s += x_def[[i, j]] * t[i];
}
p[j] = s / tt;
}
let mut q = 0.0;
for i in 0..n {
q += y_def[i] * t[i];
}
q /= tt;
for i in 0..n {
for j in 0..d {
x_def[[i, j]] -= t[i] * p[j];
}
y_def[i] -= t[i] * q;
}
for j in 0..d {
p_mat[[j, comp]] = p[j];
w_mat[[j, comp]] = w[j];
}
q_vec[comp] = q;
}
let mut pt_w = Array2::<f64>::zeros((k, k));
for a in 0..k {
for b in 0..k {
let mut s = 0.0;
for j in 0..d {
s += p_mat[[j, a]] * w_mat[[j, b]];
}
pt_w[[a, b]] = s;
}
}
let mut z = Array1::<f64>::zeros(k);
let mut m = pt_w.clone();
let mut rhs = q_vec.clone();
for col in 0..k {
let mut piv = col;
for r in (col + 1)..k {
if m[[r, col]].abs() > m[[piv, col]].abs() {
piv = r;
}
}
if piv != col {
for c in 0..k {
let tmp = m[[col, c]];
m[[col, c]] = m[[piv, c]];
m[[piv, c]] = tmp;
}
let tmp = rhs[col];
rhs[col] = rhs[piv];
rhs[piv] = tmp;
}
let pv = m[[col, col]];
if pv.abs() < 1e-14 {
continue;
}
for r in (col + 1)..k {
let f = m[[r, col]] / pv;
for c in col..k {
m[[r, c]] -= f * m[[col, c]];
}
rhs[r] -= f * rhs[col];
}
}
for r in (0..k).rev() {
let mut s = rhs[r];
for c in (r + 1)..k {
s -= m[[r, c]] * z[c];
}
let pv = m[[r, r]];
if pv.abs() > 1e-14 {
z[r] = s / pv;
}
}
let mut coef = Array1::<f64>::zeros(d);
for j in 0..d {
let mut s = 0.0;
for c in 0..k {
s += w_mat[[j, c]] * z[c];
}
coef[j] = s;
}
Ok(FittedPlsRegression {
x_mean,
y_mean,
x_std,
y_std,
coef,
n_features: d,
})
}
}
impl Predict<f64> for FittedPlsRegression {
fn predict(&self, x: &Array2<f64>) -> Result<Array1<f64>> {
if x.ncols() != self.n_features {
return Err(RustMlError::ShapeMismatch(format!(
"expected {} features, got {}",
self.n_features,
x.ncols()
)));
}
let n = x.nrows();
let mut out = Array1::<f64>::zeros(n);
for i in 0..n {
let mut s = 0.0;
for j in 0..self.n_features {
let xs = (x[[i, j]] - self.x_mean[j]) / self.x_std[j];
s += self.coef[j] * xs;
}
out[i] = s * self.y_std + self.y_mean;
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::array;
#[test]
fn test_pls1_recovers_linear() {
let rng_x: Vec<f64> = (0..40)
.flat_map(|i| {
let i = i as f64;
vec![i, 0.5 * i, -0.3 * i + 1.0]
})
.collect();
let x = Array2::from_shape_vec((40, 3), rng_x).unwrap();
let y: Array1<f64> = x.column(0).mapv(|v| 2.0 * v) + x.column(1).mapv(|v| 1.5 * v);
let fitted = PlsRegression::new(2).fit(&x, &y).unwrap();
let preds = fitted.predict(&x).unwrap();
let rss: f64 = preds
.iter()
.zip(y.iter())
.map(|(p, t)| (t - p).powi(2))
.sum();
let mean = y.iter().sum::<f64>() / y.len() as f64;
let tss: f64 = y.iter().map(|t| (t - mean).powi(2)).sum();
let r2 = 1.0 - rss / tss;
assert!(r2 > 0.99, "R² too low: {r2}");
let _ = array![1.0_f64];
}
}