1use 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#[derive(Debug, Clone, PartialEq)]
29pub enum GraphMethod {
30 KNN {
32 k: usize,
34 heat_kernel: bool,
36 },
37 EpsilonBall {
39 epsilon: f64,
41 },
42 FullHeatKernel,
44}
45
46#[derive(Debug, Clone, PartialEq)]
48pub enum LaplacianType {
49 Unnormalized,
51 NormalizedSymmetric,
53 NormalizedRandomWalk,
55}
56
57#[derive(Debug, Clone)]
71pub struct LaplacianEigenmaps {
72 n_components: usize,
74 graph_method: GraphMethod,
76 laplacian_type: LaplacianType,
78 sigma: Option<f64>,
80 embedding: Option<Array2<f64>>,
82 training_data: Option<Array2<f64>>,
84 affinity_matrix: Option<Array2<f64>>,
86 eigenvalues: Option<Array1<f64>>,
88 eigenvectors: Option<Array2<f64>>,
90 degrees: Option<Array1<f64>>,
92}
93
94impl LaplacianEigenmaps {
95 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 pub fn with_laplacian_type(mut self, laplacian_type: LaplacianType) -> Self {
117 self.laplacian_type = laplacian_type;
118 self
119 }
120
121 pub fn with_sigma(mut self, sigma: f64) -> Self {
123 self.sigma = Some(sigma);
124 self
125 }
126
127 pub fn embedding(&self) -> Option<&Array2<f64>> {
129 self.embedding.as_ref()
130 }
131
132 pub fn affinity_matrix(&self) -> Option<&Array2<f64>> {
134 self.affinity_matrix.as_ref()
135 }
136
137 pub fn eigenvalues(&self) -> Option<&Array1<f64>> {
139 self.eigenvalues.as_ref()
140 }
141
142 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 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 let sigma = median_sq.sqrt();
183 if sigma < 1e-15 {
184 1.0
185 } else {
186 sigma
187 }
188 }
189
190 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 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 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 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 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 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 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 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 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 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 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 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 let dist_sq = Self::compute_sq_distances(&x_f64);
391
392 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 let w = self.construct_affinity(&dist_sq, sigma)?;
403
404 let degrees = Self::compute_degrees(&w);
406
407 let (eigenvalues, eigenvectors) = self.compute_embedding(&w, °rees)?;
409
410 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 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]; 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 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 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 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 let sigma = self.sigma.unwrap_or(1.0);
514 let sigma_sq = sigma * sigma;
515
516 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 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 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 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 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 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 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 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 for i in 0..15 {
813 assert!(w[[i, i]].abs() < 1e-10, "Diagonal should be zero");
814 }
815 }
816}