Skip to main content

scirs2_transform/reduction/
laplacian_eigenmaps.rs

1//! Laplacian Eigenmaps for Nonlinear Dimensionality Reduction
2//!
3//! Laplacian Eigenmaps (Belkin & Niyogi, 2003) embeds data into a low-dimensional
4//! space by preserving local neighborhood structure via the graph Laplacian.
5//!
6//! ## Algorithm
7//!
8//! 1. Construct a weighted graph (k-NN or heat kernel)
9//! 2. Compute the graph Laplacian (normalized or unnormalized)
10//! 3. Solve the generalized eigenvalue problem: L f = lambda D f
11//! 4. Embed using eigenvectors corresponding to smallest non-zero eigenvalues
12//!
13//! ## Features
14//!
15//! - Heat kernel and k-NN graph construction
16//! - Normalized and unnormalized Laplacian variants
17//! - Out-of-sample extension via Nystrom approximation
18//! - Automatic bandwidth selection for the heat kernel
19
20use scirs2_core::ndarray::{Array1, Array2, ArrayBase, Data, Ix2};
21use scirs2_core::numeric::{Float, NumCast};
22use scirs2_core::validation::{check_positive, checkshape};
23use scirs2_linalg::eigh;
24
25use crate::error::{Result, TransformError};
26
27/// Graph construction method for Laplacian Eigenmaps
28#[derive(Debug, Clone, PartialEq)]
29pub enum GraphMethod {
30    /// k-nearest neighbors graph with optional heat kernel weights
31    KNN {
32        /// Number of neighbors
33        k: usize,
34        /// If true, use heat kernel weights; otherwise binary weights
35        heat_kernel: bool,
36    },
37    /// Epsilon-ball neighborhood graph
38    EpsilonBall {
39        /// Neighborhood radius
40        epsilon: f64,
41    },
42    /// Full heat kernel (connect all points with Gaussian weights)
43    FullHeatKernel,
44}
45
46/// Laplacian type
47#[derive(Debug, Clone, PartialEq)]
48pub enum LaplacianType {
49    /// Unnormalized Laplacian: L = D - W
50    Unnormalized,
51    /// Normalized Laplacian (symmetric): L_sym = D^{-1/2} L D^{-1/2} = I - D^{-1/2} W D^{-1/2}
52    NormalizedSymmetric,
53    /// Normalized Laplacian (random walk): L_rw = D^{-1} L = I - D^{-1} W
54    NormalizedRandomWalk,
55}
56
57/// Laplacian Eigenmaps for nonlinear dimensionality reduction
58///
59/// # Example
60///
61/// ```rust,no_run
62/// use scirs2_transform::reduction::laplacian_eigenmaps::{LaplacianEigenmaps, GraphMethod};
63/// use scirs2_core::ndarray::Array2;
64///
65/// let data = Array2::<f64>::zeros((50, 10));
66/// let mut le = LaplacianEigenmaps::new(2, GraphMethod::KNN { k: 10, heat_kernel: true });
67/// let embedding = le.fit_transform(&data).expect("should succeed");
68/// assert_eq!(embedding.shape(), &[50, 2]);
69/// ```
70#[derive(Debug, Clone)]
71pub struct LaplacianEigenmaps {
72    /// Number of components in the embedding
73    n_components: usize,
74    /// Method for constructing the graph
75    graph_method: GraphMethod,
76    /// Type of Laplacian to use
77    laplacian_type: LaplacianType,
78    /// Heat kernel bandwidth parameter (sigma). Auto-selected if None.
79    sigma: Option<f64>,
80    /// The embedding vectors
81    embedding: Option<Array2<f64>>,
82    /// Training data (for out-of-sample extension)
83    training_data: Option<Array2<f64>>,
84    /// Affinity (weight) matrix
85    affinity_matrix: Option<Array2<f64>>,
86    /// Eigenvalues from the decomposition
87    eigenvalues: Option<Array1<f64>>,
88    /// Eigenvectors from the decomposition
89    eigenvectors: Option<Array2<f64>>,
90    /// Degree vector
91    degrees: Option<Array1<f64>>,
92}
93
94impl LaplacianEigenmaps {
95    /// Create a new LaplacianEigenmaps instance
96    ///
97    /// # Arguments
98    /// * `n_components` - Number of dimensions in the embedding
99    /// * `graph_method` - Method for constructing the neighborhood graph
100    pub fn new(n_components: usize, graph_method: GraphMethod) -> Self {
101        LaplacianEigenmaps {
102            n_components,
103            graph_method,
104            laplacian_type: LaplacianType::NormalizedSymmetric,
105            sigma: None,
106            embedding: None,
107            training_data: None,
108            affinity_matrix: None,
109            eigenvalues: None,
110            eigenvectors: None,
111            degrees: None,
112        }
113    }
114
115    /// Set the Laplacian type
116    pub fn with_laplacian_type(mut self, laplacian_type: LaplacianType) -> Self {
117        self.laplacian_type = laplacian_type;
118        self
119    }
120
121    /// Set the heat kernel bandwidth (sigma)
122    pub fn with_sigma(mut self, sigma: f64) -> Self {
123        self.sigma = Some(sigma);
124        self
125    }
126
127    /// Get the embedding
128    pub fn embedding(&self) -> Option<&Array2<f64>> {
129        self.embedding.as_ref()
130    }
131
132    /// Get the affinity matrix
133    pub fn affinity_matrix(&self) -> Option<&Array2<f64>> {
134        self.affinity_matrix.as_ref()
135    }
136
137    /// Get the eigenvalues
138    pub fn eigenvalues(&self) -> Option<&Array1<f64>> {
139        self.eigenvalues.as_ref()
140    }
141
142    /// Compute pairwise squared Euclidean distances
143    fn compute_sq_distances(x: &Array2<f64>) -> Array2<f64> {
144        let n = x.nrows();
145        let d = x.ncols();
146        let mut dist_sq = Array2::zeros((n, n));
147
148        for i in 0..n {
149            for j in (i + 1)..n {
150                let mut sq = 0.0;
151                for k in 0..d {
152                    let diff = x[[i, k]] - x[[j, k]];
153                    sq += diff * diff;
154                }
155                dist_sq[[i, j]] = sq;
156                dist_sq[[j, i]] = sq;
157            }
158        }
159
160        dist_sq
161    }
162
163    /// Estimate sigma using the median heuristic on neighbor distances
164    fn estimate_sigma(dist_sq: &Array2<f64>, k: usize) -> f64 {
165        let n = dist_sq.nrows();
166        let mut kth_distances = Vec::with_capacity(n);
167
168        for i in 0..n {
169            let mut row_dists: Vec<f64> = (0..n)
170                .filter(|&j| j != i)
171                .map(|j| dist_sq[[i, j]])
172                .collect();
173            row_dists.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
174            let k_idx = (k - 1).min(row_dists.len().saturating_sub(1));
175            kth_distances.push(row_dists[k_idx]);
176        }
177
178        kth_distances.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
179        let median_sq = kth_distances[kth_distances.len() / 2];
180
181        // sigma = sqrt(median_kth_distance_sq)
182        let sigma = median_sq.sqrt();
183        if sigma < 1e-15 {
184            1.0
185        } else {
186            sigma
187        }
188    }
189
190    /// Construct the affinity (weight) matrix
191    fn construct_affinity(&self, dist_sq: &Array2<f64>, sigma: f64) -> Result<Array2<f64>> {
192        let n = dist_sq.nrows();
193        let sigma_sq = sigma * sigma;
194        let mut w: Array2<f64> = Array2::zeros((n, n));
195
196        match &self.graph_method {
197            GraphMethod::KNN { k, heat_kernel } => {
198                let k_val = *k;
199                if k_val >= n {
200                    return Err(TransformError::InvalidInput(format!(
201                        "k={} must be < n_samples={}",
202                        k_val, n
203                    )));
204                }
205
206                for i in 0..n {
207                    // Find k-nearest neighbors
208                    let mut neighbors: Vec<(f64, usize)> = (0..n)
209                        .filter(|&j| j != i)
210                        .map(|j| (dist_sq[[i, j]], j))
211                        .collect();
212                    neighbors
213                        .sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
214
215                    for idx in 0..k_val.min(neighbors.len()) {
216                        let (d_sq, j) = neighbors[idx];
217                        let weight = if *heat_kernel {
218                            (-d_sq / (2.0 * sigma_sq)).exp()
219                        } else {
220                            1.0
221                        };
222                        // Make symmetric
223                        w[[i, j]] = w[[i, j]].max(weight);
224                        w[[j, i]] = w[[j, i]].max(weight);
225                    }
226                }
227            }
228            GraphMethod::EpsilonBall { epsilon } => {
229                let eps_sq = epsilon * epsilon;
230                for i in 0..n {
231                    for j in (i + 1)..n {
232                        if dist_sq[[i, j]] <= eps_sq {
233                            let weight = (-dist_sq[[i, j]] / (2.0 * sigma_sq)).exp();
234                            w[[i, j]] = weight;
235                            w[[j, i]] = weight;
236                        }
237                    }
238                }
239            }
240            GraphMethod::FullHeatKernel => {
241                for i in 0..n {
242                    for j in (i + 1)..n {
243                        let weight = (-dist_sq[[i, j]] / (2.0 * sigma_sq)).exp();
244                        w[[i, j]] = weight;
245                        w[[j, i]] = weight;
246                    }
247                }
248            }
249        }
250
251        Ok(w)
252    }
253
254    /// Compute the degree vector
255    fn compute_degrees(w: &Array2<f64>) -> Array1<f64> {
256        let n = w.nrows();
257        let mut d = Array1::zeros(n);
258        for i in 0..n {
259            d[i] = w.row(i).sum();
260        }
261        d
262    }
263
264    /// Compute the graph Laplacian and solve the eigenvalue problem
265    fn compute_embedding(
266        &self,
267        w: &Array2<f64>,
268        degrees: &Array1<f64>,
269    ) -> Result<(Array1<f64>, Array2<f64>)> {
270        let n = w.nrows();
271
272        // Check for isolated nodes
273        for i in 0..n {
274            if degrees[i] < 1e-15 {
275                return Err(TransformError::ComputationError(format!(
276                    "Node {} is isolated (zero degree). Increase k or epsilon.",
277                    i
278                )));
279            }
280        }
281
282        match &self.laplacian_type {
283            LaplacianType::Unnormalized => {
284                // L = D - W
285                // Solve L f = lambda D f  =>  D^{-1} L f = lambda f
286                // Equivalently, solve D^{-1/2} L D^{-1/2} g = lambda g, then f = D^{-1/2} g
287                let mut l_sym = Array2::zeros((n, n));
288                for i in 0..n {
289                    let d_i_inv_sqrt = 1.0 / degrees[i].sqrt();
290                    for j in 0..n {
291                        let d_j_inv_sqrt = 1.0 / degrees[j].sqrt();
292                        if i == j {
293                            l_sym[[i, j]] = 1.0 - w[[i, j]] / degrees[i];
294                        } else {
295                            l_sym[[i, j]] = -w[[i, j]] * d_i_inv_sqrt * d_j_inv_sqrt;
296                        }
297                    }
298                }
299
300                let (eigenvalues, eigenvectors) =
301                    eigh(&l_sym.view(), None).map_err(TransformError::LinalgError)?;
302
303                // Transform back: f = D^{-1/2} g
304                let mut f_vecs = eigenvectors.clone();
305                for i in 0..n {
306                    let d_inv_sqrt = 1.0 / degrees[i].sqrt();
307                    for j in 0..n {
308                        f_vecs[[i, j]] *= d_inv_sqrt;
309                    }
310                }
311
312                Ok((eigenvalues, f_vecs))
313            }
314            LaplacianType::NormalizedSymmetric => {
315                // L_sym = I - D^{-1/2} W D^{-1/2}
316                let mut l_sym = Array2::zeros((n, n));
317                for i in 0..n {
318                    let d_i_inv_sqrt = 1.0 / degrees[i].sqrt();
319                    for j in 0..n {
320                        let d_j_inv_sqrt = 1.0 / degrees[j].sqrt();
321                        if i == j {
322                            l_sym[[i, j]] = 1.0 - w[[i, j]] * d_i_inv_sqrt * d_j_inv_sqrt;
323                        } else {
324                            l_sym[[i, j]] = -w[[i, j]] * d_i_inv_sqrt * d_j_inv_sqrt;
325                        }
326                    }
327                }
328
329                eigh(&l_sym.view(), None).map_err(TransformError::LinalgError)
330            }
331            LaplacianType::NormalizedRandomWalk => {
332                // L_rw = I - D^{-1} W
333                // This is not symmetric, so we solve via the symmetric form
334                // L_sym = D^{1/2} L_rw D^{-1/2}
335                // The eigenvectors of L_rw are D^{-1/2} * eigenvectors of L_sym
336                let mut l_sym = Array2::zeros((n, n));
337                for i in 0..n {
338                    let d_i_inv_sqrt = 1.0 / degrees[i].sqrt();
339                    for j in 0..n {
340                        let d_j_inv_sqrt = 1.0 / degrees[j].sqrt();
341                        if i == j {
342                            l_sym[[i, j]] = 1.0 - w[[i, j]] * d_i_inv_sqrt * d_j_inv_sqrt;
343                        } else {
344                            l_sym[[i, j]] = -w[[i, j]] * d_i_inv_sqrt * d_j_inv_sqrt;
345                        }
346                    }
347                }
348
349                let (eigenvalues, eigenvectors) =
350                    eigh(&l_sym.view(), None).map_err(TransformError::LinalgError)?;
351
352                // Transform: f = D^{-1/2} * eigvecs
353                let mut f_vecs = eigenvectors.clone();
354                for i in 0..n {
355                    let d_inv_sqrt = 1.0 / degrees[i].sqrt();
356                    for j in 0..n {
357                        f_vecs[[i, j]] *= d_inv_sqrt;
358                    }
359                }
360
361                Ok((eigenvalues, f_vecs))
362            }
363        }
364    }
365
366    /// Fit the Laplacian Eigenmaps model
367    ///
368    /// # Arguments
369    /// * `x` - Input data, shape (n_samples, n_features)
370    pub fn fit<S>(&mut self, x: &ArrayBase<S, Ix2>) -> Result<()>
371    where
372        S: Data,
373        S::Elem: Float + NumCast,
374    {
375        let (n_samples, n_features) = x.dim();
376
377        check_positive(self.n_components, "n_components")?;
378        checkshape(x, &[n_samples, n_features], "x")?;
379
380        if self.n_components >= n_samples {
381            return Err(TransformError::InvalidInput(format!(
382                "n_components={} must be < n_samples={}",
383                self.n_components, n_samples
384            )));
385        }
386
387        let x_f64 = x.mapv(|v| NumCast::from(v).unwrap_or(0.0));
388
389        // Compute pairwise distances
390        let dist_sq = Self::compute_sq_distances(&x_f64);
391
392        // Determine sigma
393        let sigma = self.sigma.unwrap_or_else(|| {
394            let k = match &self.graph_method {
395                GraphMethod::KNN { k, .. } => *k,
396                _ => (n_samples as f64).sqrt().ceil() as usize,
397            };
398            Self::estimate_sigma(&dist_sq, k.min(n_samples - 1).max(1))
399        });
400
401        // Construct affinity matrix
402        let w = self.construct_affinity(&dist_sq, sigma)?;
403
404        // Compute degrees
405        let degrees = Self::compute_degrees(&w);
406
407        // Compute embedding
408        let (eigenvalues, eigenvectors) = self.compute_embedding(&w, &degrees)?;
409
410        // Sort by eigenvalue (ascending) and select the n_components smallest non-zero
411        let mut indices: Vec<usize> = (0..n_samples).collect();
412        indices.sort_by(|&i, &j| {
413            eigenvalues[i]
414                .partial_cmp(&eigenvalues[j])
415                .unwrap_or(std::cmp::Ordering::Equal)
416        });
417
418        // Skip the first eigenvector (constant, eigenvalue ~ 0)
419        let mut embedding = Array2::zeros((n_samples, self.n_components));
420        let mut selected_eigenvalues = Array1::zeros(self.n_components);
421
422        for j in 0..self.n_components {
423            let idx = indices[j + 1]; // Skip first (trivial) eigenvector
424            selected_eigenvalues[j] = eigenvalues[idx];
425            for i in 0..n_samples {
426                embedding[[i, j]] = eigenvectors[[i, idx]];
427            }
428        }
429
430        self.embedding = Some(embedding);
431        self.training_data = Some(x_f64);
432        self.affinity_matrix = Some(w);
433        self.eigenvalues = Some(selected_eigenvalues);
434        self.eigenvectors = Some(eigenvectors);
435        self.degrees = Some(degrees);
436
437        Ok(())
438    }
439
440    /// Transform data using the fitted model
441    ///
442    /// For the training data, returns the stored embedding.
443    /// For new data, uses Nystrom approximation.
444    pub fn transform<S>(&self, x: &ArrayBase<S, Ix2>) -> Result<Array2<f64>>
445    where
446        S: Data,
447        S::Elem: Float + NumCast,
448    {
449        let training_data = self
450            .training_data
451            .as_ref()
452            .ok_or_else(|| TransformError::NotFitted("Model not fitted".to_string()))?;
453
454        let x_f64 = x.mapv(|v| NumCast::from(v).unwrap_or(0.0));
455
456        if self.is_same_data(&x_f64, training_data) {
457            return self
458                .embedding
459                .as_ref()
460                .cloned()
461                .ok_or_else(|| TransformError::NotFitted("Embedding not available".to_string()));
462        }
463
464        self.nystrom_extension(&x_f64)
465    }
466
467    /// Fit and transform in one step
468    pub fn fit_transform<S>(&mut self, x: &ArrayBase<S, Ix2>) -> Result<Array2<f64>>
469    where
470        S: Data,
471        S::Elem: Float + NumCast,
472    {
473        self.fit(x)?;
474        self.transform(x)
475    }
476
477    /// Nystrom out-of-sample extension
478    ///
479    /// Approximates the embedding of new points using the Nystrom method:
480    /// For each new point, compute its affinity to the training points and
481    /// project using the learned eigenvectors.
482    fn nystrom_extension(&self, x_new: &Array2<f64>) -> Result<Array2<f64>> {
483        let training_data = self
484            .training_data
485            .as_ref()
486            .ok_or_else(|| TransformError::NotFitted("Training data not available".to_string()))?;
487        let training_embedding = self
488            .embedding
489            .as_ref()
490            .ok_or_else(|| TransformError::NotFitted("Embedding not available".to_string()))?;
491        let eigenvalues = self
492            .eigenvalues
493            .as_ref()
494            .ok_or_else(|| TransformError::NotFitted("Eigenvalues not available".to_string()))?;
495        let degrees = self
496            .degrees
497            .as_ref()
498            .ok_or_else(|| TransformError::NotFitted("Degrees not available".to_string()))?;
499
500        let n_new = x_new.nrows();
501        let n_train = training_data.nrows();
502        let n_features = training_data.ncols();
503
504        if x_new.ncols() != n_features {
505            return Err(TransformError::InvalidInput(format!(
506                "Input features {} must match training features {}",
507                x_new.ncols(),
508                n_features
509            )));
510        }
511
512        // Determine sigma for affinity computation
513        let sigma = self.sigma.unwrap_or(1.0);
514        let sigma_sq = sigma * sigma;
515
516        // Compute affinity between new points and training points
517        let mut w_new = Array2::zeros((n_new, n_train));
518        for i in 0..n_new {
519            for j in 0..n_train {
520                let mut dist_sq = 0.0;
521                for k in 0..n_features {
522                    let diff = x_new[[i, k]] - training_data[[j, k]];
523                    dist_sq += diff * diff;
524                }
525                w_new[[i, j]] = (-dist_sq / (2.0 * sigma_sq)).exp();
526            }
527        }
528
529        // Nystrom extension: f_new = D_new^{-1} W_new * embedding / eigenvalue
530        let mut new_embedding = Array2::zeros((n_new, self.n_components));
531
532        for i in 0..n_new {
533            let d_new_i: f64 = w_new.row(i).sum();
534            if d_new_i < 1e-15 {
535                continue;
536            }
537
538            for j in 0..self.n_components {
539                let eig_val = eigenvalues[j];
540                if eig_val.abs() < 1e-15 {
541                    continue;
542                }
543
544                let mut sum = 0.0;
545                for k in 0..n_train {
546                    // Normalized weight
547                    let w_norm = w_new[[i, k]] / (d_new_i.sqrt() * degrees[k].sqrt());
548                    sum += w_norm * training_embedding[[k, j]];
549                }
550                new_embedding[[i, j]] = sum / eig_val;
551            }
552        }
553
554        Ok(new_embedding)
555    }
556
557    /// Check if two data matrices are the same
558    fn is_same_data(&self, x: &Array2<f64>, training_data: &Array2<f64>) -> bool {
559        if x.dim() != training_data.dim() {
560            return false;
561        }
562        let (n, m) = x.dim();
563        for i in 0..n {
564            for j in 0..m {
565                if (x[[i, j]] - training_data[[i, j]]).abs() > 1e-10 {
566                    return false;
567                }
568            }
569        }
570        true
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use scirs2_core::ndarray::Array;
578
579    fn make_swiss_roll(n: usize) -> Array2<f64> {
580        let mut data = Vec::with_capacity(n * 3);
581        for i in 0..n {
582            let t = 1.5 * std::f64::consts::PI * (1.0 + 2.0 * i as f64 / n as f64);
583            let x = t * t.cos();
584            let y = 10.0 * i as f64 / n as f64;
585            let z = t * t.sin();
586            data.extend_from_slice(&[x, y, z]);
587        }
588        Array::from_shape_vec((n, 3), data).expect("Failed to create swiss roll")
589    }
590
591    #[test]
592    fn test_laplacian_eigenmaps_knn() {
593        let data = make_swiss_roll(30);
594        let mut le = LaplacianEigenmaps::new(
595            2,
596            GraphMethod::KNN {
597                k: 7,
598                heat_kernel: true,
599            },
600        );
601        let embedding = le.fit_transform(&data).expect("LE fit_transform failed");
602
603        assert_eq!(embedding.shape(), &[30, 2]);
604        for val in embedding.iter() {
605            assert!(val.is_finite());
606        }
607    }
608
609    #[test]
610    fn test_laplacian_eigenmaps_knn_binary() {
611        let data = make_swiss_roll(25);
612        let mut le = LaplacianEigenmaps::new(
613            2,
614            GraphMethod::KNN {
615                k: 5,
616                heat_kernel: false,
617            },
618        );
619        let embedding = le.fit_transform(&data).expect("LE fit_transform failed");
620
621        assert_eq!(embedding.shape(), &[25, 2]);
622        for val in embedding.iter() {
623            assert!(val.is_finite());
624        }
625    }
626
627    #[test]
628    fn test_laplacian_eigenmaps_full_heat() {
629        let data = make_swiss_roll(20);
630        let mut le = LaplacianEigenmaps::new(2, GraphMethod::FullHeatKernel).with_sigma(5.0);
631        let embedding = le.fit_transform(&data).expect("LE fit_transform failed");
632
633        assert_eq!(embedding.shape(), &[20, 2]);
634        for val in embedding.iter() {
635            assert!(val.is_finite());
636        }
637    }
638
639    #[test]
640    fn test_laplacian_eigenmaps_epsilon_ball() {
641        // Create data where points are close enough for epsilon-ball
642        let mut data_vec = Vec::new();
643        for i in 0..20 {
644            let t = i as f64 / 20.0;
645            data_vec.extend_from_slice(&[t, t * 2.0, t * 3.0]);
646        }
647        let data = Array::from_shape_vec((20, 3), data_vec).expect("Failed");
648
649        let mut le = LaplacianEigenmaps::new(2, GraphMethod::EpsilonBall { epsilon: 0.5 });
650        let embedding = le.fit_transform(&data).expect("LE fit_transform failed");
651
652        assert_eq!(embedding.shape(), &[20, 2]);
653        for val in embedding.iter() {
654            assert!(val.is_finite());
655        }
656    }
657
658    #[test]
659    fn test_laplacian_eigenmaps_unnormalized() {
660        let data = make_swiss_roll(25);
661        let mut le = LaplacianEigenmaps::new(
662            2,
663            GraphMethod::KNN {
664                k: 7,
665                heat_kernel: true,
666            },
667        )
668        .with_laplacian_type(LaplacianType::Unnormalized);
669        let embedding = le.fit_transform(&data).expect("LE fit_transform failed");
670
671        assert_eq!(embedding.shape(), &[25, 2]);
672        for val in embedding.iter() {
673            assert!(val.is_finite());
674        }
675    }
676
677    #[test]
678    fn test_laplacian_eigenmaps_random_walk() {
679        let data = make_swiss_roll(25);
680        let mut le = LaplacianEigenmaps::new(
681            2,
682            GraphMethod::KNN {
683                k: 7,
684                heat_kernel: true,
685            },
686        )
687        .with_laplacian_type(LaplacianType::NormalizedRandomWalk);
688        let embedding = le.fit_transform(&data).expect("LE fit_transform failed");
689
690        assert_eq!(embedding.shape(), &[25, 2]);
691        for val in embedding.iter() {
692            assert!(val.is_finite());
693        }
694    }
695
696    #[test]
697    fn test_laplacian_eigenmaps_eigenvalues() {
698        let data = make_swiss_roll(20);
699        let mut le = LaplacianEigenmaps::new(
700            3,
701            GraphMethod::KNN {
702                k: 5,
703                heat_kernel: true,
704            },
705        );
706        le.fit(&data).expect("LE fit failed");
707
708        let eigenvalues = le.eigenvalues().expect("Eigenvalues should exist");
709        assert_eq!(eigenvalues.len(), 3);
710
711        // Eigenvalues should be non-negative (from Laplacian)
712        for &ev in eigenvalues.iter() {
713            assert!(ev >= -1e-10, "Eigenvalue should be >= 0, got {}", ev);
714        }
715    }
716
717    #[test]
718    fn test_laplacian_eigenmaps_out_of_sample() {
719        let data = make_swiss_roll(30);
720        let mut le = LaplacianEigenmaps::new(
721            2,
722            GraphMethod::KNN {
723                k: 7,
724                heat_kernel: true,
725            },
726        );
727        le.fit(&data).expect("LE fit failed");
728
729        let new_data =
730            Array::from_shape_vec((3, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0])
731                .expect("Failed");
732
733        let new_embedding = le.transform(&new_data).expect("LE transform failed");
734        assert_eq!(new_embedding.shape(), &[3, 2]);
735        for val in new_embedding.iter() {
736            assert!(val.is_finite());
737        }
738    }
739
740    #[test]
741    fn test_laplacian_eigenmaps_custom_sigma() {
742        let data = make_swiss_roll(20);
743        let mut le = LaplacianEigenmaps::new(
744            2,
745            GraphMethod::KNN {
746                k: 5,
747                heat_kernel: true,
748            },
749        )
750        .with_sigma(2.0);
751        let embedding = le.fit_transform(&data).expect("LE fit_transform failed");
752
753        assert_eq!(embedding.shape(), &[20, 2]);
754    }
755
756    #[test]
757    fn test_laplacian_eigenmaps_invalid_params() {
758        let data = make_swiss_roll(5);
759
760        // n_components >= n_samples
761        let mut le = LaplacianEigenmaps::new(
762            10,
763            GraphMethod::KNN {
764                k: 3,
765                heat_kernel: true,
766            },
767        );
768        assert!(le.fit(&data).is_err());
769    }
770
771    #[test]
772    fn test_laplacian_eigenmaps_not_fitted() {
773        let le = LaplacianEigenmaps::new(
774            2,
775            GraphMethod::KNN {
776                k: 5,
777                heat_kernel: true,
778            },
779        );
780        let data = make_swiss_roll(10);
781        assert!(le.transform(&data).is_err());
782    }
783
784    #[test]
785    fn test_laplacian_eigenmaps_affinity_matrix() {
786        let data = make_swiss_roll(15);
787        let mut le = LaplacianEigenmaps::new(
788            2,
789            GraphMethod::KNN {
790                k: 5,
791                heat_kernel: true,
792            },
793        );
794        le.fit(&data).expect("LE fit failed");
795
796        let w = le.affinity_matrix().expect("Affinity should exist");
797        assert_eq!(w.shape(), &[15, 15]);
798
799        // Affinity should be symmetric
800        for i in 0..15 {
801            for j in 0..15 {
802                assert!(
803                    (w[[i, j]] - w[[j, i]]).abs() < 1e-10,
804                    "Affinity not symmetric at ({}, {})",
805                    i,
806                    j
807                );
808            }
809        }
810
811        // Diagonal should be zero (no self-loops)
812        for i in 0..15 {
813            assert!(w[[i, i]].abs() < 1e-10, "Diagonal should be zero");
814        }
815    }
816}