1use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2, Axis};
8use sklears_core::{
9 error::{Result, SklearsError},
10 traits::{Estimator, Predict},
11 types::Float,
12};
13
14#[derive(Debug, Clone)]
52pub struct GraphSemiSupervisedSVM {
53 pub c: f64,
55 pub gamma_a: f64,
57 pub gamma_i: f64,
59 pub kernel: String,
61 pub graph_kernel: String,
63 pub n_neighbors: usize,
65 pub sigma: f64,
67 pub max_iter: usize,
69 pub tol: f64,
71 pub verbose: bool,
73}
74
75#[derive(Debug, Clone)]
77pub struct TrainedGraphSemiSupervisedSVM {
78 pub coef_: Array2<f64>,
80 pub intercept_: Array1<f64>,
82 pub classes_: Array1<i32>,
84 pub n_features_in_: usize,
86 pub training_data_: Array2<f64>,
88 pub graph_matrix_: Array2<f64>,
90 pub unlabeled_predictions_: Array1<f64>,
92 _params: GraphSemiSupervisedSVM,
94}
95
96#[derive(Debug)]
98pub struct GraphBuilder {
99 kernel_type: String,
100 n_neighbors: usize,
101 sigma: f64,
102}
103
104impl GraphBuilder {
105 pub fn new(kernel_type: &str, n_neighbors: usize, sigma: f64) -> Self {
106 Self {
107 kernel_type: kernel_type.to_string(),
108 n_neighbors,
109 sigma,
110 }
111 }
112
113 fn rbf_similarity(&self, x1: ArrayView1<f64>, x2: ArrayView1<f64>) -> f64 {
115 let dist_sq: f64 = x1.iter().zip(x2.iter()).map(|(a, b)| (a - b).powi(2)).sum();
116 (-dist_sq / (2.0 * self.sigma.powi(2))).exp()
117 }
118
119 fn build_knn_graph(&self, x: ArrayView2<f64>) -> Array2<f64> {
121 let n_samples = x.nrows();
122 let mut graph = Array2::zeros((n_samples, n_samples));
123
124 for i in 0..n_samples {
125 let xi = x.row(i);
126
127 let mut distances: Vec<(usize, f64)> = Vec::new();
129 for j in 0..n_samples {
130 if i != j {
131 let xj = x.row(j);
132 let dist: f64 = xi
133 .iter()
134 .zip(xj.iter())
135 .map(|(a, b)| (a - b).powi(2))
136 .sum::<f64>()
137 .sqrt();
138 distances.push((j, dist));
139 }
140 }
141
142 distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
144
145 let k = std::cmp::min(self.n_neighbors, distances.len());
146 for &(j, dist) in distances.iter().take(k) {
147 let weight = (-dist.powi(2) / (2.0 * self.sigma.powi(2))).exp();
149 graph[[i, j]] = weight;
150 graph[[j, i]] = weight; }
152 }
153
154 graph
155 }
156
157 fn build_rbf_graph(&self, x: ArrayView2<f64>) -> Array2<f64> {
159 let n_samples = x.nrows();
160 let mut graph = Array2::zeros((n_samples, n_samples));
161
162 for i in 0..n_samples {
163 for j in i + 1..n_samples {
164 let similarity = self.rbf_similarity(x.row(i), x.row(j));
165 graph[[i, j]] = similarity;
166 graph[[j, i]] = similarity;
167 }
168 }
169
170 graph
171 }
172
173 pub fn build_graph(&self, x: ArrayView2<f64>) -> Array2<f64> {
175 match self.kernel_type.as_str() {
176 "knn" => self.build_knn_graph(x),
177 "rbf" => self.build_rbf_graph(x),
178 _ => self.build_rbf_graph(x), }
180 }
181}
182
183impl Default for GraphSemiSupervisedSVM {
184 fn default() -> Self {
185 Self::new()
186 }
187}
188
189impl GraphSemiSupervisedSVM {
190 pub fn new() -> Self {
192 Self {
193 c: 1.0,
194 gamma_a: 0.1,
195 gamma_i: 0.01,
196 kernel: "rbf".to_string(),
197 graph_kernel: "rbf".to_string(),
198 n_neighbors: 10,
199 sigma: 1.0,
200 max_iter: 1000,
201 tol: 1e-4,
202 verbose: false,
203 }
204 }
205
206 pub fn with_c(mut self, c: f64) -> Self {
208 self.c = c;
209 self
210 }
211
212 pub fn with_gamma_a(mut self, gamma_a: f64) -> Self {
214 self.gamma_a = gamma_a;
215 self
216 }
217
218 pub fn with_gamma_i(mut self, gamma_i: f64) -> Self {
220 self.gamma_i = gamma_i;
221 self
222 }
223
224 pub fn with_kernel(mut self, kernel: &str) -> Self {
226 self.kernel = kernel.to_string();
227 self
228 }
229
230 pub fn with_graph_kernel(mut self, graph_kernel: &str) -> Self {
232 self.graph_kernel = graph_kernel.to_string();
233 self
234 }
235
236 pub fn with_n_neighbors(mut self, n_neighbors: usize) -> Self {
238 self.n_neighbors = n_neighbors;
239 self
240 }
241
242 pub fn with_sigma(mut self, sigma: f64) -> Self {
244 self.sigma = sigma;
245 self
246 }
247
248 pub fn with_max_iter(mut self, max_iter: usize) -> Self {
250 self.max_iter = max_iter;
251 self
252 }
253
254 pub fn with_tol(mut self, tol: f64) -> Self {
256 self.tol = tol;
257 self
258 }
259
260 pub fn with_verbose(mut self, verbose: bool) -> Self {
262 self.verbose = verbose;
263 self
264 }
265
266 fn compute_laplacian(&self, adjacency: &Array2<f64>) -> Array2<f64> {
268 let n = adjacency.nrows();
269 let mut laplacian = Array2::zeros((n, n));
270
271 let mut degrees = Array1::zeros(n);
273 for i in 0..n {
274 degrees[i] = adjacency.row(i).sum();
275 }
276
277 for i in 0..n {
279 for j in 0..n {
280 if i == j {
281 laplacian[[i, j]] = 1.0;
282 if degrees[i] > 0.0 {
283 laplacian[[i, j]] -= adjacency[[i, j]] / degrees[i];
284 }
285 } else if degrees[i] > 0.0 && degrees[j] > 0.0 {
286 laplacian[[i, j]] = -adjacency[[i, j]] / (degrees[i] * degrees[j]).sqrt();
287 }
288 }
289 }
290
291 laplacian
292 }
293
294 fn propagate_labels(
296 &self,
297 graph: &Array2<f64>,
298 labeled_indices: &[usize],
299 unlabeled_indices: &[usize],
300 y_labeled: &Array1<f64>,
301 ) -> Array1<f64> {
302 let n_total = graph.nrows();
303 let mut labels = Array1::zeros(n_total);
304
305 for (i, &idx) in labeled_indices.iter().enumerate() {
307 labels[idx] = y_labeled[i];
308 }
309
310 let _laplacian = self.compute_laplacian(graph);
311
312 for iteration in 0..self.max_iter {
314 let old_labels = labels.clone();
315
316 for &u_idx in unlabeled_indices {
318 let mut weighted_sum = 0.0;
319 let mut weight_sum = 0.0;
320
321 for j in 0..n_total {
322 if j != u_idx {
323 let weight = graph[[u_idx, j]];
324 weighted_sum += weight * labels[j];
325 weight_sum += weight;
326 }
327 }
328
329 if weight_sum > 0.0 {
330 labels[u_idx] = weighted_sum / weight_sum;
331 }
332 }
333
334 for (i, &idx) in labeled_indices.iter().enumerate() {
336 labels[idx] = y_labeled[i];
337 }
338
339 let change: f64 = labels
341 .iter()
342 .zip(old_labels.iter())
343 .map(|(new, old)| (new - old).powi(2))
344 .sum::<f64>()
345 .sqrt();
346
347 if change < self.tol {
348 if self.verbose {
349 println!("Label propagation converged at iteration {iteration}");
350 }
351 break;
352 }
353
354 if self.verbose && iteration % 100 == 0 {
355 println!(
356 "Label propagation iteration {}, change: {:.6}",
357 iteration, change
358 );
359 }
360 }
361
362 labels
363 }
364
365 pub fn fit_semi_supervised(
367 self,
368 x_labeled: &Array2<f64>,
369 y_labeled: &Array1<i32>,
370 x_unlabeled: &Array2<f64>,
371 ) -> Result<TrainedGraphSemiSupervisedSVM> {
372 let (n_labeled, n_features) = x_labeled.dim();
373 let (n_unlabeled, _) = x_unlabeled.dim();
374
375 if n_labeled == 0 || n_features == 0 {
376 return Err(SklearsError::InvalidInput(
377 "Labeled data cannot be empty".to_string(),
378 ));
379 }
380
381 if x_labeled.ncols() != x_unlabeled.ncols() {
382 return Err(SklearsError::InvalidInput(
383 "Labeled and unlabeled data must have same number of features".to_string(),
384 ));
385 }
386
387 let mut x_combined = Array2::zeros((n_labeled + n_unlabeled, n_features));
389 for i in 0..n_labeled {
390 x_combined.row_mut(i).assign(&x_labeled.row(i));
391 }
392 for i in 0..n_unlabeled {
393 x_combined
394 .row_mut(n_labeled + i)
395 .assign(&x_unlabeled.row(i));
396 }
397
398 let mut classes: Vec<i32> = y_labeled.to_vec();
400 classes.sort_unstable();
401 classes.dedup();
402 let classes = Array1::from(classes);
403
404 if classes.len() < 2 {
405 return Err(SklearsError::InvalidInput(
406 "Need at least 2 classes for classification".to_string(),
407 ));
408 }
409
410 let graph_builder = GraphBuilder::new(&self.graph_kernel, self.n_neighbors, self.sigma);
412 let graph = graph_builder.build_graph(x_combined.view());
413
414 let labeled_indices: Vec<usize> = (0..n_labeled).collect();
416 let unlabeled_indices: Vec<usize> = (n_labeled..n_labeled + n_unlabeled).collect();
417
418 let y_labeled_f64 = if classes.len() == 2 {
420 y_labeled.map(|&label| if label == classes[1] { 1.0 } else { -1.0 })
421 } else {
422 y_labeled.map(|&label| label as f64)
424 };
425
426 let propagated_labels =
428 self.propagate_labels(&graph, &labeled_indices, &unlabeled_indices, &y_labeled_f64);
429
430 let mut unlabeled_predictions_ = Array1::zeros(n_unlabeled);
432 for (i, &idx) in unlabeled_indices.iter().enumerate() {
433 unlabeled_predictions_[i] = propagated_labels[idx];
434 }
435
436 let y_combined = if classes.len() == 2 {
438 propagated_labels.map(|&f_label| {
439 if f_label >= 0.0 {
440 classes[1]
441 } else {
442 classes[0]
443 }
444 })
445 } else {
446 propagated_labels.map(|&f_label| f_label.round() as i32)
447 };
448
449 let mut coef = Array1::zeros(n_features);
451 let mut intercept = 0.0;
452
453 let learning_rate = 0.01;
455
456 for iteration in 0..self.max_iter {
457 let mut w_gradient: Array1<f64> = Array1::zeros(n_features);
458 let mut intercept_gradient = 0.0;
459
460 for i in 0..x_combined.nrows() {
461 let xi = x_combined.row(i);
462 let yi = if y_combined[i] == classes[1] {
463 1.0
464 } else {
465 -1.0
466 };
467
468 let mut prediction = intercept;
470 for j in 0..n_features {
471 prediction += coef[j] * xi[j];
472 }
473
474 let margin = yi * prediction;
475
476 if margin < 1.0 {
478 for j in 0..n_features {
479 w_gradient[j] += -yi * xi[j];
480 }
481 intercept_gradient += -yi;
482 }
483 }
484
485 for j in 0..n_features {
487 w_gradient[j] += coef[j] / self.c;
488 }
489
490 for j in 0..n_features {
492 coef[j] -= learning_rate * w_gradient[j];
493 }
494 intercept -= learning_rate * intercept_gradient;
495
496 if iteration % 100 == 0 {
498 let gradient_norm = w_gradient.iter().map(|&g| g * g).sum::<f64>().sqrt();
499 if gradient_norm < self.tol {
500 if self.verbose {
501 println!("Graph SVM converged at iteration {iteration}");
502 }
503 break;
504 }
505 }
506 }
507
508 let coef_matrix = coef.insert_axis(Axis(0));
509 let intercept_arr = Array1::from(vec![intercept]);
510
511 Ok(TrainedGraphSemiSupervisedSVM {
512 coef_: coef_matrix,
513 intercept_: intercept_arr,
514 classes_: classes,
515 n_features_in_: n_features,
516 training_data_: x_combined,
517 graph_matrix_: graph,
518 unlabeled_predictions_,
519 _params: self,
520 })
521 }
522}
523
524impl Estimator for GraphSemiSupervisedSVM {
525 type Config = Self;
526 type Error = SklearsError;
527 type Float = Float;
528
529 fn config(&self) -> &Self::Config {
530 self
531 }
532}
533
534impl Predict<Array2<f64>, Array1<i32>> for TrainedGraphSemiSupervisedSVM {
538 fn predict(&self, x: &Array2<f64>) -> Result<Array1<i32>> {
539 let decision_values = self.decision_function(x)?;
540
541 if self.classes_.len() == 2 {
542 let predictions = decision_values.map(|&score| {
544 if score >= 0.0 {
545 self.classes_[1]
546 } else {
547 self.classes_[0]
548 }
549 });
550 Ok(predictions.remove_axis(Axis(1)))
551 } else {
552 let mut predictions = Array1::zeros(x.len_of(Axis(0)));
554 for (i, row) in decision_values.axis_iter(Axis(0)).enumerate() {
555 let best_class_idx = row
556 .iter()
557 .enumerate()
558 .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
559 .map(|(idx, _)| idx)
560 .expect("value should be present");
561 predictions[i] = self.classes_[best_class_idx];
562 }
563 Ok(predictions)
564 }
565 }
566}
567
568impl TrainedGraphSemiSupervisedSVM {
569 pub fn decision_function(&self, x: &Array2<f64>) -> Result<Array2<f64>> {
571 let (n_samples, n_features) = x.dim();
572
573 if n_features != self.n_features_in_ {
574 return Err(SklearsError::FeatureMismatch {
575 expected: self.n_features_in_,
576 actual: n_features,
577 });
578 }
579
580 if self.classes_.len() == 2 {
581 let mut scores = Array1::zeros(n_samples);
583 let w = self.coef_.row(0);
584 let intercept = self.intercept_[0];
585
586 for (i, x_row) in x.axis_iter(Axis(0)).enumerate() {
587 let mut score = intercept;
588 for (j, &x_val) in x_row.iter().enumerate() {
589 score += w[j] * x_val;
590 }
591 scores[i] = score;
592 }
593
594 Ok(scores.insert_axis(Axis(1)))
595 } else {
596 let mut scores = Array2::zeros((n_samples, self.classes_.len()));
598
599 for (class_idx, coef_row) in self.coef_.axis_iter(Axis(0)).enumerate() {
600 let intercept = self.intercept_[class_idx];
601
602 for (i, x_row) in x.axis_iter(Axis(0)).enumerate() {
603 let mut score = intercept;
604 for (j, &x_val) in x_row.iter().enumerate() {
605 score += coef_row[j] * x_val;
606 }
607 scores[[i, class_idx]] = score;
608 }
609 }
610
611 Ok(scores)
612 }
613 }
614
615 pub fn coef(&self) -> &Array2<f64> {
617 &self.coef_
618 }
619
620 pub fn intercept(&self) -> &Array1<f64> {
622 &self.intercept_
623 }
624
625 pub fn classes(&self) -> &Array1<i32> {
627 &self.classes_
628 }
629
630 pub fn graph_matrix(&self) -> &Array2<f64> {
632 &self.graph_matrix_
633 }
634
635 pub fn unlabeled_predictions(&self) -> &Array1<f64> {
637 &self.unlabeled_predictions_
638 }
639
640 pub fn training_data(&self) -> &Array2<f64> {
642 &self.training_data_
643 }
644}
645
646#[allow(non_snake_case)]
647#[cfg(test)]
648mod tests {
649 use super::*;
650 use approx::assert_abs_diff_eq;
651 use scirs2_core::ndarray::array;
652
653 #[test]
654 fn test_graph_semi_supervised_svm() {
655 let X_labeled_var = array![[1.0, 2.0], [2.0, 3.0]];
656 let y_labeled = array![0, 1];
657 let X_unlabeled_var = array![[1.5, 2.5], [2.5, 3.5]];
658
659 let model = GraphSemiSupervisedSVM::new()
660 .with_c(1.0)
661 .with_gamma_a(0.1)
662 .with_verbose(true);
663
664 let trained_model = model
665 .fit_semi_supervised(&X_labeled_var, &y_labeled, &X_unlabeled_var)
666 .expect("operation should succeed");
667
668 let predictions = trained_model
670 .predict(&X_labeled_var)
671 .expect("prediction should succeed");
672 assert_eq!(predictions.len(), 2);
673
674 let scores = trained_model
676 .decision_function(&X_labeled_var)
677 .expect("decision function should succeed");
678 assert_eq!(scores.dim(), (2, 1));
679
680 assert_eq!(trained_model.unlabeled_predictions().len(), 2);
682 }
683
684 #[test]
685 fn test_graph_builder_rbf() {
686 let X_var = array![[1.0, 2.0], [2.0, 3.0], [3.0, 4.0]];
687
688 let builder = GraphBuilder::new("rbf", 2, 1.0);
689 let graph = builder.build_graph(X_var.view());
690
691 assert_eq!(graph.dim(), (3, 3));
692
693 for i in 0..3 {
695 for j in 0..3 {
696 assert_abs_diff_eq!(graph[[i, j]], graph[[j, i]], epsilon = 1e-10);
697 }
698 }
699
700 for i in 0..3 {
702 assert_abs_diff_eq!(graph[[i, i]], 0.0, epsilon = 1e-10);
703 }
704 }
705
706 #[test]
707 fn test_graph_builder_knn() {
708 let X_var = array![[1.0, 2.0], [2.0, 3.0], [3.0, 4.0], [10.0, 10.0]];
709
710 let builder = GraphBuilder::new("knn", 2, 1.0);
711 let graph = builder.build_graph(X_var.view());
712
713 assert_eq!(graph.dim(), (4, 4));
714
715 for i in 0..4 {
717 let non_zero_count = graph
718 .row(i)
719 .iter()
720 .enumerate()
721 .filter(|(j, &val)| *j != i && val > 1e-10)
722 .count();
723 assert!(
724 non_zero_count <= 2,
725 "Row {} has {} non-zero entries",
726 i,
727 non_zero_count
728 );
729 }
730 }
731
732 #[test]
733 fn test_graph_semi_supervised_parameters() {
734 let model = GraphSemiSupervisedSVM::new()
735 .with_c(0.5)
736 .with_gamma_a(0.2)
737 .with_gamma_i(0.05)
738 .with_kernel("linear")
739 .with_graph_kernel("knn")
740 .with_n_neighbors(5)
741 .with_sigma(2.0)
742 .with_max_iter(500)
743 .with_tol(1e-5);
744
745 assert_eq!(model.c, 0.5);
746 assert_abs_diff_eq!(model.gamma_a, 0.2);
747 assert_abs_diff_eq!(model.gamma_i, 0.05);
748 assert_eq!(model.kernel, "linear");
749 assert_eq!(model.graph_kernel, "knn");
750 assert_eq!(model.n_neighbors, 5);
751 assert_abs_diff_eq!(model.sigma, 2.0);
752 assert_eq!(model.max_iter, 500);
753 assert_abs_diff_eq!(model.tol, 1e-5);
754 }
755
756 #[test]
757 fn test_label_propagation_convergence() {
758 let X_labeled_var = array![[0.0, 0.0], [1.0, 1.0], [2.0, 2.0]];
759 let y_labeled = array![0, 1, 1];
760 let X_unlabeled_var = array![[0.5, 0.5], [1.5, 1.5]];
761
762 let model = GraphSemiSupervisedSVM::new()
763 .with_gamma_a(0.5)
764 .with_max_iter(100)
765 .with_tol(1e-6)
766 .with_verbose(true);
767
768 let result = model.fit_semi_supervised(&X_labeled_var, &y_labeled, &X_unlabeled_var);
769 assert!(result.is_ok());
770
771 let trained_model = result.expect("operation should succeed");
772
773 let unlabeled_preds = trained_model.unlabeled_predictions();
775 println!("Unlabeled predictions: {:?}", unlabeled_preds);
776
777 assert!(unlabeled_preds[1] > unlabeled_preds[0]);
780 }
781
782 #[test]
783 fn test_dimension_mismatch() {
784 let X_labeled_var = array![[1.0, 2.0], [2.0, 3.0]];
785 let y_labeled = array![0, 1];
786 let X_unlabeled_var = array![[1.5, 2.5, 3.5]]; let model = GraphSemiSupervisedSVM::new();
789 let result = model.fit_semi_supervised(&X_labeled_var, &y_labeled, &X_unlabeled_var);
790 assert!(result.is_err());
791 }
792
793 #[test]
794 fn test_empty_labeled_data() {
795 let X_labeled_var = Array2::zeros((0, 2));
796 let y_labeled = Array1::zeros(0);
797 let X_unlabeled_var = array![[1.5, 2.5]];
798
799 let model = GraphSemiSupervisedSVM::new();
800 let result = model.fit_semi_supervised(&X_labeled_var, &y_labeled, &X_unlabeled_var);
801 assert!(result.is_err());
802 }
803}