1use scirs2_core::ndarray::{Array1, Array2, Axis};
9use scirs2_linalg::compat::ArrayLinalgExt;
10#[cfg(feature = "serde")]
11use serde::{Deserialize, Serialize};
12use sklears_core::{
13 error::{Result, SklearsError},
14 traits::{Fit, Predict, Transform},
15};
16
17type NipalsStepResult = (
19 Array1<f64>,
20 Array1<f64>,
21 Array1<f64>,
22 Array1<f64>,
23 Array1<f64>,
24 Array1<f64>,
25);
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
30pub enum PLSAlgorithm {
31 #[default]
33 PLS1,
34 PLS2,
36 Canonical,
38}
39
40#[derive(Debug, Clone)]
63#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
64pub struct PartialLeastSquares {
65 pub n_components: usize,
67 pub algorithm: PLSAlgorithm,
69 pub max_iter: usize,
71 pub tol: f64,
73 pub center: bool,
75 pub scale: bool,
77 pub copy: bool,
79}
80
81#[derive(Debug, Clone)]
83#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
84pub struct FittedPLS {
85 pub x_weights: Array2<f64>,
86 pub y_weights: Array2<f64>,
87 pub x_loadings: Array2<f64>,
88 pub y_loadings: Array2<f64>,
89 pub x_scores: Array2<f64>,
90 pub y_scores: Array2<f64>,
91 pub x_rotations: Array2<f64>,
92 pub y_rotations: Array2<f64>,
93 pub coef: Array2<f64>,
94 pub x_mean: Array1<f64>,
95 pub y_mean: Array1<f64>,
96 pub x_scale: Array1<f64>,
97 pub y_scale: Array1<f64>,
98 pub n_features_x: usize,
99 pub n_features_y: usize,
100 pub n_components: usize,
101 pub x_explained_variance_ratio: Array1<f64>,
102 pub y_explained_variance_ratio: Array1<f64>,
103}
104
105impl Default for PartialLeastSquares {
106 fn default() -> Self {
107 Self::new(2)
108 }
109}
110
111impl PartialLeastSquares {
112 pub fn new(n_components: usize) -> Self {
125 Self {
126 n_components,
127 algorithm: PLSAlgorithm::default(),
128 max_iter: 500,
129 tol: 1e-6,
130 center: true,
131 scale: true,
132 copy: true,
133 }
134 }
135
136 pub fn algorithm(mut self, algorithm: PLSAlgorithm) -> Self {
138 self.algorithm = algorithm;
139 self
140 }
141
142 pub fn max_iter(mut self, max_iter: usize) -> Self {
144 self.max_iter = max_iter;
145 self
146 }
147
148 pub fn tolerance(mut self, tol: f64) -> Self {
150 self.tol = tol;
151 self
152 }
153
154 pub fn center(mut self, center: bool) -> Self {
156 self.center = center;
157 self
158 }
159
160 pub fn scale(mut self, scale: bool) -> Self {
162 self.scale = scale;
163 self
164 }
165
166 pub fn copy(mut self, copy: bool) -> Self {
168 self.copy = copy;
169 self
170 }
171
172 fn preprocess_data(
174 &self,
175 data: &Array2<f64>,
176 ) -> Result<(Array2<f64>, Array1<f64>, Array1<f64>)> {
177 let mean = if self.center {
178 data.mean_axis(Axis(0)).ok_or_else(|| {
179 SklearsError::NumericalError("cannot compute mean of empty array".to_string())
180 })?
181 } else {
182 Array1::zeros(data.ncols())
183 };
184
185 let centered = if self.center {
186 data - &mean.clone().insert_axis(Axis(0))
187 } else {
188 data.clone()
189 };
190
191 let scale = if self.scale {
192 let var = centered.var_axis(Axis(0), 0.0);
193 var.mapv(|v| if v > 1e-12 { v.sqrt() } else { 1.0 })
194 } else {
195 Array1::ones(data.ncols())
196 };
197
198 let processed = if self.scale {
199 ¢ered / &scale.clone().insert_axis(Axis(0))
200 } else {
201 centered
202 };
203
204 Ok((processed, mean, scale))
205 }
206
207 fn nipals_step(&self, x: &Array2<f64>, y: &Array2<f64>) -> Result<NipalsStepResult> {
209 let n_features_x = x.ncols();
210 let n_features_y = y.ncols();
211
212 let mut u = if n_features_y == 1 {
214 y.column(0).to_owned()
215 } else {
216 let mut max_var = 0.0;
218 let mut best_col = 0;
219 for i in 0..n_features_y {
220 let col = y.column(i);
221 let var = col.var(0.0);
222 if var > max_var {
223 max_var = var;
224 best_col = i;
225 }
226 }
227 y.column(best_col).to_owned()
228 };
229
230 let u_norm = u.dot(&u).sqrt();
232 if u_norm < 1e-12 {
233 u = Array1::from_shape_fn(u.len(), |_| 1.0 / (u.len() as f64).sqrt());
235 }
236
237 let mut w_old = Array1::zeros(n_features_x);
238 let mut iter = 0;
239
240 loop {
241 let u_norm_sq = u.dot(&u);
243 if u_norm_sq < 1e-12 {
244 return Err(SklearsError::NumericalError(
245 "u vector became zero in NIPALS".to_string(),
246 ));
247 }
248 let mut w = x.t().dot(&u) / u_norm_sq;
249
250 let w_norm = w.dot(&w).sqrt();
252 if w_norm < 1e-12 {
253 if iter == 0 {
255 w = Array1::from_shape_fn(n_features_x, |i| if i == 0 { 1.0 } else { 0.0 });
257 let w_norm = w.dot(&w).sqrt();
258 w = &w / w_norm;
259 } else {
260 return Err(SklearsError::NumericalError(
261 "X weights became zero in NIPALS".to_string(),
262 ));
263 }
264 } else {
265 w = &w / w_norm;
266 }
267
268 let t = x.dot(&w);
270
271 let t_norm_sq = t.dot(&t);
273 if t_norm_sq < 1e-12 {
274 return Err(SklearsError::NumericalError(
275 "X scores became zero in NIPALS".to_string(),
276 ));
277 }
278 let c = y.t().dot(&t) / t_norm_sq;
279
280 let u_new = y.dot(&c);
282
283 let diff = (&w - &w_old).dot(&(&w - &w_old)).sqrt();
285 if diff < self.tol || iter >= self.max_iter {
286 let p = x.t().dot(&t) / t_norm_sq;
288
289 let u_norm_sq = u_new.dot(&u_new);
291 let q = if u_norm_sq > 1e-12 {
292 y.t().dot(&u_new) / u_norm_sq
293 } else {
294 y.t().dot(&t) / t_norm_sq
296 };
297
298 return Ok((w, c, p, q, t, u_new));
299 }
300
301 w_old = w.clone();
302 u = u_new;
303 iter += 1;
304 }
305 }
306
307 fn deflate_matrices(
309 &self,
310 x: &Array2<f64>,
311 y: &Array2<f64>,
312 t: &Array1<f64>,
313 p: &Array1<f64>,
314 q: &Array1<f64>,
315 ) -> (Array2<f64>, Array2<f64>) {
316 let t_outer_p = outer_product(t, p);
317 let t_outer_q = outer_product(t, q);
318
319 let x_deflated = x - &t_outer_p;
320 let y_deflated = y - &t_outer_q;
321
322 (x_deflated, y_deflated)
323 }
324
325 fn compute_explained_variance(&self, original: &Array2<f64>, deflated: &Array2<f64>) -> f64 {
327 let total_var = original.mapv(|x| x * x).sum();
328 let remaining_var = deflated.mapv(|x| x * x).sum();
329
330 if total_var > 1e-12 {
331 let ratio = (total_var - remaining_var) / total_var;
332 ratio.clamp(0.0, 1.0) } else {
334 0.0
335 }
336 }
337}
338
339impl Fit<(Array2<f64>, Array2<f64>), ()> for PartialLeastSquares {
340 type Fitted = FittedPLS;
341
342 fn fit(self, data: &(Array2<f64>, Array2<f64>), _target: &()) -> Result<Self::Fitted> {
343 let (x, y) = data;
344
345 if x.nrows() != y.nrows() {
346 return Err(SklearsError::InvalidInput(
347 "X and Y must have the same number of samples".to_string(),
348 ));
349 }
350
351 if x.nrows() < 2 {
352 return Err(SklearsError::InvalidInput(
353 "Need at least 2 samples for PLS".to_string(),
354 ));
355 }
356
357 let n_samples = x.nrows();
358 let n_features_x = x.ncols();
359 let n_features_y = y.ncols();
360 let n_components = self.n_components.min(n_features_x.min(n_features_y));
361
362 let (mut x_work, x_mean, x_scale) = self.preprocess_data(x)?;
364 let (mut y_work, y_mean, y_scale) = self.preprocess_data(y)?;
365
366 let mut x_weights = Array2::zeros((n_features_x, n_components));
368 let mut y_weights = Array2::zeros((n_features_y, n_components));
369 let mut x_loadings = Array2::zeros((n_features_x, n_components));
370 let mut y_loadings = Array2::zeros((n_features_y, n_components));
371 let mut x_scores = Array2::zeros((n_samples, n_components));
372 let mut y_scores = Array2::zeros((n_samples, n_components));
373
374 let mut x_explained_var_ratio = Array1::zeros(n_components);
375 let mut y_explained_var_ratio = Array1::zeros(n_components);
376
377 for k in 0..n_components {
379 let (w, c, p, q, t, u) = self.nipals_step(&x_work, &y_work)?;
381
382 x_weights.column_mut(k).assign(&w);
384 y_weights.column_mut(k).assign(&c);
385 x_loadings.column_mut(k).assign(&p);
386 y_loadings.column_mut(k).assign(&q);
387 x_scores.column_mut(k).assign(&t);
388 y_scores.column_mut(k).assign(&u);
389
390 let (x_deflated, y_deflated) = self.deflate_matrices(&x_work, &y_work, &t, &p, &q);
392 x_explained_var_ratio[k] = self.compute_explained_variance(&x_work, &x_deflated);
393 y_explained_var_ratio[k] = self.compute_explained_variance(&y_work, &y_deflated);
394
395 x_work = x_deflated;
397 y_work = y_deflated;
398 }
399
400 let ptw_x = x_loadings.t().dot(&x_weights);
403 let ptw_y = y_loadings.t().dot(&y_weights);
404
405 let x_rotations = if ptw_x.nrows() == ptw_x.ncols() && ptw_x.nrows() > 0 {
406 let ptw_x_inv = invert_matrix(&ptw_x).map_err(|_| {
407 SklearsError::NumericalError("Failed to invert X P^T*W matrix".to_string())
408 })?;
409 x_weights.dot(&ptw_x_inv)
410 } else {
411 x_weights.clone()
413 };
414
415 let y_rotations = if ptw_y.nrows() == ptw_y.ncols() && ptw_y.nrows() > 0 {
416 let ptw_y_inv = invert_matrix(&ptw_y).map_err(|_| {
417 SklearsError::NumericalError("Failed to invert Y P^T*W matrix".to_string())
418 })?;
419 y_weights.dot(&ptw_y_inv)
420 } else {
421 y_weights.clone()
423 };
424
425 let coef = x_rotations.dot(&y_loadings.t());
427
428 Ok(FittedPLS {
429 x_weights,
430 y_weights,
431 x_loadings,
432 y_loadings,
433 x_scores,
434 y_scores,
435 x_rotations,
436 y_rotations,
437 coef,
438 x_mean,
439 y_mean,
440 x_scale,
441 y_scale,
442 n_features_x,
443 n_features_y,
444 n_components,
445 x_explained_variance_ratio: x_explained_var_ratio,
446 y_explained_variance_ratio: y_explained_var_ratio,
447 })
448 }
449}
450
451impl Transform<Array2<f64>, Array2<f64>> for FittedPLS {
452 fn transform(&self, x: &Array2<f64>) -> Result<Array2<f64>> {
453 if x.ncols() != self.n_features_x {
454 let expected = self.n_features_x;
455 let got = x.ncols();
456 return Err(SklearsError::InvalidInput(format!(
457 "Expected {expected} features, got {got}"
458 )));
459 }
460
461 let x_centered = x - &self.x_mean.clone().insert_axis(Axis(0));
463 let x_scaled = &x_centered / &self.x_scale.clone().insert_axis(Axis(0));
464
465 let x_scores = x_scaled.dot(&self.x_rotations);
467 Ok(x_scores)
468 }
469}
470
471impl Predict<Array2<f64>, Array2<f64>> for FittedPLS {
472 fn predict(&self, x: &Array2<f64>) -> Result<Array2<f64>> {
473 if x.ncols() != self.n_features_x {
474 let expected = self.n_features_x;
475 let got = x.ncols();
476 return Err(SklearsError::InvalidInput(format!(
477 "Expected {expected} features, got {got}"
478 )));
479 }
480
481 let x_centered = x - &self.x_mean.clone().insert_axis(Axis(0));
483 let x_scaled = &x_centered / &self.x_scale.clone().insert_axis(Axis(0));
484
485 let y_pred_scaled = x_scaled.dot(&self.coef);
487
488 let y_pred_centered = &y_pred_scaled * &self.y_scale.clone().insert_axis(Axis(0));
490 let y_pred = &y_pred_centered + &self.y_mean.clone().insert_axis(Axis(0));
491
492 Ok(y_pred)
493 }
494}
495
496impl FittedPLS {
497 pub fn transform_y(&self, y: &Array2<f64>) -> Result<Array2<f64>> {
499 if y.ncols() != self.n_features_y {
500 let expected = self.n_features_y;
501 let got = y.ncols();
502 return Err(SklearsError::InvalidInput(format!(
503 "Expected {expected} features for Y, got {got}"
504 )));
505 }
506
507 let y_centered = y - &self.y_mean.clone().insert_axis(Axis(0));
509 let y_scaled = &y_centered / &self.y_scale.clone().insert_axis(Axis(0));
510
511 let y_scores = y_scaled.dot(&self.y_rotations);
513 Ok(y_scores)
514 }
515
516 pub fn x_explained_variance_ratio(&self) -> &Array1<f64> {
518 &self.x_explained_variance_ratio
519 }
520
521 pub fn y_explained_variance_ratio(&self) -> &Array1<f64> {
523 &self.y_explained_variance_ratio
524 }
525
526 pub fn coefficients(&self) -> &Array2<f64> {
528 &self.coef
529 }
530}
531
532fn outer_product(a: &Array1<f64>, b: &Array1<f64>) -> Array2<f64> {
534 let mut result = Array2::zeros((a.len(), b.len()));
535 for i in 0..a.len() {
536 for j in 0..b.len() {
537 result[[i, j]] = a[i] * b[j];
538 }
539 }
540 result
541}
542
543fn invert_matrix(m: &Array2<f64>) -> Result<Array2<f64>> {
545 if m.nrows() != m.ncols() {
546 return Err(SklearsError::NumericalError(
547 "Matrix must be square for inversion".to_string(),
548 ));
549 }
550 ArrayLinalgExt::inv(m)
551 .map_err(|e| SklearsError::NumericalError(format!("Matrix inversion failed: {e}")))
552}
553
554#[allow(non_snake_case)]
555#[cfg(test)]
556mod tests {
557 use super::*;
558 use scirs2_core::ndarray::array;
559
560 #[test]
561 fn test_pls_creation() {
562 let pls = PartialLeastSquares::new(3);
563 assert_eq!(pls.n_components, 3);
564 assert_eq!(pls.algorithm, PLSAlgorithm::PLS1);
565 assert_eq!(pls.max_iter, 500);
566 assert_eq!(pls.tol, 1e-6);
567 assert!(pls.center);
568 assert!(pls.scale);
569 }
570
571 #[test]
572 fn test_pls_builder_pattern() {
573 let pls = PartialLeastSquares::new(2)
574 .algorithm(PLSAlgorithm::PLS2)
575 .max_iter(1000)
576 .tolerance(1e-8)
577 .center(false)
578 .scale(false);
579
580 assert_eq!(pls.n_components, 2);
581 assert_eq!(pls.algorithm, PLSAlgorithm::PLS2);
582 assert_eq!(pls.max_iter, 1000);
583 assert_eq!(pls.tol, 1e-8);
584 assert!(!pls.center);
585 assert!(!pls.scale);
586 }
587
588 #[test]
589 fn test_pls_fit_transform() {
590 let x = array![
592 [1.0, 2.1, 3.2],
593 [4.1, 5.0, 6.1],
594 [7.2, 8.1, 9.0],
595 [10.1, 11.0, 12.1],
596 [2.5, 3.5, 4.5],
597 ];
598
599 let y = array![[2.1, 3.2], [5.1, 6.0], [8.0, 9.1], [11.1, 12.0], [3.5, 4.6],];
600
601 let pls = PartialLeastSquares::new(2);
602 let fitted = pls
603 .fit(&(x.clone(), y.clone()), &())
604 .expect("model fitting should succeed");
605
606 assert_eq!(fitted.n_features_x, 3);
607 assert_eq!(fitted.n_features_y, 2);
608 assert_eq!(fitted.n_components, 2);
609 assert_eq!(fitted.x_weights.dim(), (3, 2));
610 assert_eq!(fitted.y_weights.dim(), (2, 2));
611 assert_eq!(fitted.coef.dim(), (3, 2));
612
613 let x_scores = fitted.transform(&x).expect("transformation should succeed");
615 let y_scores = fitted.transform_y(&y).expect("operation should succeed");
616
617 assert_eq!(x_scores.dim(), (5, 2));
618 assert_eq!(y_scores.dim(), (5, 2));
619
620 let y_pred = fitted.predict(&x).expect("prediction should succeed");
622 assert_eq!(y_pred.dim(), (5, 2));
623 }
624
625 #[test]
626 fn test_pls_mismatched_samples() {
627 let x = array![[1.0, 2.0], [3.0, 4.0]];
628 let y = array![[1.0], [2.0], [3.0]]; let pls = PartialLeastSquares::new(1);
631 let result = pls.fit(&(x, y), &());
632 assert!(result.is_err());
633 }
634
635 #[test]
636 fn test_pls_insufficient_samples() {
637 let x = array![[1.0, 2.0]];
638 let y = array![[1.0]];
639
640 let pls = PartialLeastSquares::new(1);
641 let result = pls.fit(&(x, y), &());
642 assert!(result.is_err());
643 }
644
645 #[test]
646 fn test_pls_feature_mismatch() {
647 let x = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0],];
648
649 let y = array![[2.0, 3.0], [5.0, 6.0],];
650
651 let pls = PartialLeastSquares::new(1);
652 let fitted = pls.fit(&(x, y), &()).expect("model fitting should succeed");
653
654 let x_wrong = array![[1.0, 2.0]]; let result = fitted.transform(&x_wrong);
657 assert!(result.is_err());
658
659 let result = fitted.predict(&x_wrong);
660 assert!(result.is_err());
661
662 let y_wrong = array![[1.0]]; let result = fitted.transform_y(&y_wrong);
664 assert!(result.is_err());
665 }
666
667 #[test]
668 fn test_pls_algorithms() {
669 let x = array![[1.0, 0.0], [0.0, 1.0], [-1.0, 0.0], [0.0, -1.0],];
670
671 let y = array![[1.0], [1.0], [-1.0], [-1.0],];
672
673 let pls1 = PartialLeastSquares::new(1).algorithm(PLSAlgorithm::PLS1);
675 let fitted1 = pls1
676 .fit(&(x.clone(), y.clone()), &())
677 .expect("model fitting should succeed");
678 assert_eq!(fitted1.n_components, 1);
679
680 let pls2 = PartialLeastSquares::new(1).algorithm(PLSAlgorithm::PLS2);
682 let fitted2 = pls2
683 .fit(&(x.clone(), y.clone()), &())
684 .expect("model fitting should succeed");
685 assert_eq!(fitted2.n_components, 1);
686 }
687
688 #[test]
689 fn test_pls_explained_variance() {
690 let x = array![[1.0, 2.1], [3.1, 4.0], [5.0, 6.1], [7.1, 8.0], [2.5, 3.5],];
691
692 let y = array![[2.1], [4.0], [6.1], [8.0], [3.5],];
693
694 let pls = PartialLeastSquares::new(1);
695 let fitted = pls.fit(&(x, y), &()).expect("model fitting should succeed");
696
697 let x_var = fitted.x_explained_variance_ratio();
698 let y_var = fitted.y_explained_variance_ratio();
699
700 assert_eq!(x_var.len(), 1);
701 assert_eq!(y_var.len(), 1);
702 assert!(x_var[0] >= 0.0 && x_var[0] <= 1.0);
703 assert!(y_var[0] >= 0.0 && y_var[0] <= 1.0);
704 }
705
706 #[test]
707 fn test_outer_product() {
708 let a = array![1.0, 2.0];
709 let b = array![3.0, 4.0];
710 let result = outer_product(&a, &b);
711
712 let expected = array![[3.0, 4.0], [6.0, 8.0]];
713 assert_eq!(result, expected);
714 }
715}