Skip to main content

sklears_decomposition/
pls.rs

1//! Partial Least Squares (PLS) Decomposition
2//!
3//! This module provides Partial Least Squares decomposition methods for regression
4//! and dimensionality reduction. PLS finds a linear regression model by projecting
5//! the input variables and response variables to a new space that maximizes the
6//! covariance between the projected variables.
7
8use 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
17/// Type alias for NIPALS step result to reduce type complexity
18type NipalsStepResult = (
19    Array1<f64>,
20    Array1<f64>,
21    Array1<f64>,
22    Array1<f64>,
23    Array1<f64>,
24    Array1<f64>,
25);
26
27/// PLS algorithm variants
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
30pub enum PLSAlgorithm {
31    /// PLS1 - For single response variable
32    #[default]
33    PLS1,
34    /// PLS2 - For multiple response variables using NIPALS algorithm
35    PLS2,
36    /// Canonical PLS - Maximizes correlation between X and Y scores
37    Canonical,
38}
39
40/// Partial Least Squares Decomposition
41///
42/// PLS finds latent variables that explain the maximum covariance between
43/// predictor variables X and response variables Y. It's particularly useful
44/// when the number of predictors is large relative to the number of observations,
45/// or when predictors are highly correlated.
46///
47/// # Mathematical Background
48///
49/// PLS seeks to find weight vectors w and c such that:
50/// - t = X * w (X scores)
51/// - u = Y * c (Y scores)
52/// - cov(t, u) is maximized
53///
54/// The algorithm iteratively deflates X and Y by removing the variance
55/// explained by each component.
56///
57/// # Applications
58/// - Regression with high-dimensional predictors
59/// - Spectroscopy and chemometrics
60/// - Bioinformatics and genomics
61/// - Quality control and process monitoring
62#[derive(Debug, Clone)]
63#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
64pub struct PartialLeastSquares {
65    /// Number of components to extract
66    pub n_components: usize,
67    /// PLS algorithm to use
68    pub algorithm: PLSAlgorithm,
69    /// Maximum number of iterations for NIPALS
70    pub max_iter: usize,
71    /// Convergence tolerance
72    pub tol: f64,
73    /// Whether to center the data
74    pub center: bool,
75    /// Whether to scale the data to unit variance
76    pub scale: bool,
77    /// Whether to copy the input data
78    pub copy: bool,
79}
80
81/// Fitted PLS model
82#[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    /// Create a new PLS instance
113    ///
114    /// # Parameters
115    /// - `n_components`: Number of components to extract
116    ///
117    /// # Examples
118    /// ```
119    /// use sklears_decomposition::PartialLeastSquares;
120    ///
121    /// let pls = PartialLeastSquares::new(3);
122    /// assert_eq!(pls.n_components, 3);
123    /// ```
124    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    /// Set the PLS algorithm
137    pub fn algorithm(mut self, algorithm: PLSAlgorithm) -> Self {
138        self.algorithm = algorithm;
139        self
140    }
141
142    /// Set maximum number of iterations
143    pub fn max_iter(mut self, max_iter: usize) -> Self {
144        self.max_iter = max_iter;
145        self
146    }
147
148    /// Set convergence tolerance
149    pub fn tolerance(mut self, tol: f64) -> Self {
150        self.tol = tol;
151        self
152    }
153
154    /// Set whether to center the data
155    pub fn center(mut self, center: bool) -> Self {
156        self.center = center;
157        self
158    }
159
160    /// Set whether to scale the data
161    pub fn scale(mut self, scale: bool) -> Self {
162        self.scale = scale;
163        self
164    }
165
166    /// Set whether to copy input data
167    pub fn copy(mut self, copy: bool) -> Self {
168        self.copy = copy;
169        self
170    }
171
172    /// Center and scale data
173    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            &centered / &scale.clone().insert_axis(Axis(0))
200        } else {
201            centered
202        };
203
204        Ok((processed, mean, scale))
205    }
206
207    /// NIPALS algorithm for PLS
208    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        // Initialize u with first column of Y, or with higher variance column
213        let mut u = if n_features_y == 1 {
214            y.column(0).to_owned()
215        } else {
216            // Find column with highest variance
217            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        // Ensure u is not zero
231        let u_norm = u.dot(&u).sqrt();
232        if u_norm < 1e-12 {
233            // Initialize with random values if Y column is zero
234            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            // X weights: w = X^T * u / ||u||^2
242            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            // Normalize w to unit length
251            let w_norm = w.dot(&w).sqrt();
252            if w_norm < 1e-12 {
253                // If w becomes zero, try a different approach
254                if iter == 0 {
255                    // Initialize w as the first principal component direction (unit e1)
256                    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            // X scores: t = X * w
269            let t = x.dot(&w);
270
271            // Y weights: c = Y^T * t / ||t||^2
272            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            // Y scores: u_new = Y * c
281            let u_new = y.dot(&c);
282
283            // Check convergence
284            let diff = (&w - &w_old).dot(&(&w - &w_old)).sqrt();
285            if diff < self.tol || iter >= self.max_iter {
286                // X loadings: p = X^T * t / ||t||^2
287                let p = x.t().dot(&t) / t_norm_sq;
288
289                // Y loadings: q = Y^T * u / ||u||^2
290                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                    // If u is zero, use t instead
295                    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    /// Deflate matrices X and Y
308    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    /// Compute explained variance ratio
326    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) // Clamp to [0, 1] range
333        } 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        // Preprocess data
363        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        // Initialize storage
367        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        // Extract components
378        for k in 0..n_components {
379            // NIPALS step
380            let (w, c, p, q, t, u) = self.nipals_step(&x_work, &y_work)?;
381
382            // Store results
383            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            // Compute explained variance for this component
391            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            // Update working matrices
396            x_work = x_deflated;
397            y_work = y_deflated;
398        }
399
400        // Compute rotation matrices (for transformation)
401        // R = W * (P^T * W)^(-1) where W is weights and P is loadings
402        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            // Fallback: use weights directly
412            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            // Fallback: use weights directly
422            y_weights.clone()
423        };
424
425        // Compute regression coefficients
426        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        // Preprocess X the same way as training data
462        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        // Transform using rotation matrix
466        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        // Preprocess X
482        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        // Predict using regression coefficients
486        let y_pred_scaled = x_scaled.dot(&self.coef);
487
488        // Transform back to original scale
489        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    /// Transform Y data to get Y scores
498    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        // Preprocess Y
508        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        // Transform using Y rotation matrix
512        let y_scores = y_scaled.dot(&self.y_rotations);
513        Ok(y_scores)
514    }
515
516    /// Get the X explained variance ratio
517    pub fn x_explained_variance_ratio(&self) -> &Array1<f64> {
518        &self.x_explained_variance_ratio
519    }
520
521    /// Get the Y explained variance ratio
522    pub fn y_explained_variance_ratio(&self) -> &Array1<f64> {
523        &self.y_explained_variance_ratio
524    }
525
526    /// Get the regression coefficients
527    pub fn coefficients(&self) -> &Array2<f64> {
528        &self.coef
529    }
530}
531
532/// Compute outer product of two vectors
533fn 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
543/// Invert a square matrix using scirs2-linalg
544fn 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        // Use less perfectly correlated data
591        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        // Test transformation
614        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        // Test prediction
621        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]]; // Different number of samples
629
630        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        // Test with wrong number of features
655        let x_wrong = array![[1.0, 2.0]]; // Should have 3 features
656        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]]; // Should have 2 features
663        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        // Test PLS1
674        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        // Test PLS2
681        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}