Skip to main content

sklears_svm/
graph_semi_supervised.rs

1//! Graph-based Semi-supervised Support Vector Machines
2//!
3//! This module implements graph-based semi-supervised SVMs that leverage
4//! graph structure to propagate label information from labeled to unlabeled
5//! examples, particularly useful when labeled data is scarce.
6
7use 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/// Graph-based Semi-supervised Support Vector Machine
15///
16/// This implementation combines SVM with graph-based label propagation,
17/// using the graph structure to regularize the learning process and
18/// propagate information from labeled to unlabeled examples.
19///
20/// # Parameters
21/// * `C` - Regularization parameter for SVM (default: 1.0)
22/// * `gamma_a` - Graph regularization parameter (default: 0.1)
23/// * `gamma_i` - Intrinsic regularization parameter (default: 0.01)
24/// * `kernel` - Kernel function for SVM ('rbf', 'linear', default: 'rbf')
25/// * `graph_kernel` - Kernel for graph construction ('rbf', 'knn', default: 'rbf')
26/// * `n_neighbors` - Number of neighbors for k-NN graph (default: 10)
27/// * `sigma` - Bandwidth for RBF kernel (default: 1.0)
28/// * `max_iter` - Maximum number of iterations (default: 1000)
29/// * `tol` - Tolerance for convergence (default: 1e-4)
30/// * `verbose` - Enable verbose output (default: false)
31///
32/// # Example
33/// ```rust
34/// use sklears_svm::GraphSemiSupervisedSVM;
35/// use sklears_core::traits::{Predict};
36/// use scirs2_core::ndarray::array;
37///
38/// let X_labeled = array![[1.0, 2.0], [2.0, 3.0]];
39/// let y_labeled = array![0, 1];
40/// let X_unlabeled = array![[1.5, 2.5], [2.5, 3.5]];
41///
42/// let model = GraphSemiSupervisedSVM::new()
43///     .with_c(1.0)
44///     .with_gamma_a(0.1);
45///
46/// let trained_model = model
47///     .fit_semi_supervised(&X_labeled, &y_labeled, &X_unlabeled)
48///     .expect("GraphSemiSupervisedSVM fit_semi_supervised should succeed on valid input");
49/// let predictions = trained_model.predict(&X_labeled).expect("GraphSemiSupervisedSVM predict should succeed on valid input");
50/// ```
51#[derive(Debug, Clone)]
52pub struct GraphSemiSupervisedSVM {
53    /// SVM regularization parameter
54    pub c: f64,
55    /// Graph regularization parameter
56    pub gamma_a: f64,
57    /// Intrinsic regularization parameter
58    pub gamma_i: f64,
59    /// Kernel function for SVM
60    pub kernel: String,
61    /// Kernel function for graph construction
62    pub graph_kernel: String,
63    /// Number of neighbors for k-NN graph
64    pub n_neighbors: usize,
65    /// Bandwidth for RBF kernel
66    pub sigma: f64,
67    /// Maximum number of iterations
68    pub max_iter: usize,
69    /// Tolerance for convergence
70    pub tol: f64,
71    /// Verbose output
72    pub verbose: bool,
73}
74
75/// Trained Graph-based Semi-supervised SVM model
76#[derive(Debug, Clone)]
77pub struct TrainedGraphSemiSupervisedSVM {
78    /// Model weights (coefficients)
79    pub coef_: Array2<f64>,
80    /// Intercept terms
81    pub intercept_: Array1<f64>,
82    /// Unique class labels
83    pub classes_: Array1<i32>,
84    /// Number of features
85    pub n_features_in_: usize,
86    /// Training data (labeled + unlabeled)
87    pub training_data_: Array2<f64>,
88    /// Graph adjacency matrix
89    pub graph_matrix_: Array2<f64>,
90    /// Predicted labels for unlabeled data
91    pub unlabeled_predictions_: Array1<f64>,
92    /// Training parameters
93    _params: GraphSemiSupervisedSVM,
94}
95
96/// Graph construction utilities
97#[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    /// Compute RBF similarity between two points
114    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    /// Build k-NN graph
120    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            // Compute distances to all other points
128            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            // Sort by distance and take k nearest neighbors
143            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                // Use RBF similarity as edge weight
148                let weight = (-dist.powi(2) / (2.0 * self.sigma.powi(2))).exp();
149                graph[[i, j]] = weight;
150                graph[[j, i]] = weight; // Symmetric graph
151            }
152        }
153
154        graph
155    }
156
157    /// Build RBF similarity graph
158    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    /// Build graph based on specified kernel type
174    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), // Default to RBF
179        }
180    }
181}
182
183impl Default for GraphSemiSupervisedSVM {
184    fn default() -> Self {
185        Self::new()
186    }
187}
188
189impl GraphSemiSupervisedSVM {
190    /// Create a new GraphSemiSupervisedSVM with default parameters
191    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    /// Set the SVM regularization parameter C
207    pub fn with_c(mut self, c: f64) -> Self {
208        self.c = c;
209        self
210    }
211
212    /// Set the graph regularization parameter
213    pub fn with_gamma_a(mut self, gamma_a: f64) -> Self {
214        self.gamma_a = gamma_a;
215        self
216    }
217
218    /// Set the intrinsic regularization parameter
219    pub fn with_gamma_i(mut self, gamma_i: f64) -> Self {
220        self.gamma_i = gamma_i;
221        self
222    }
223
224    /// Set the SVM kernel function
225    pub fn with_kernel(mut self, kernel: &str) -> Self {
226        self.kernel = kernel.to_string();
227        self
228    }
229
230    /// Set the graph construction kernel
231    pub fn with_graph_kernel(mut self, graph_kernel: &str) -> Self {
232        self.graph_kernel = graph_kernel.to_string();
233        self
234    }
235
236    /// Set the number of neighbors for k-NN graph
237    pub fn with_n_neighbors(mut self, n_neighbors: usize) -> Self {
238        self.n_neighbors = n_neighbors;
239        self
240    }
241
242    /// Set the RBF kernel bandwidth
243    pub fn with_sigma(mut self, sigma: f64) -> Self {
244        self.sigma = sigma;
245        self
246    }
247
248    /// Set the maximum number of iterations
249    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
250        self.max_iter = max_iter;
251        self
252    }
253
254    /// Set the tolerance for convergence
255    pub fn with_tol(mut self, tol: f64) -> Self {
256        self.tol = tol;
257        self
258    }
259
260    /// Set verbose output
261    pub fn with_verbose(mut self, verbose: bool) -> Self {
262        self.verbose = verbose;
263        self
264    }
265
266    /// Compute graph Laplacian matrix
267    fn compute_laplacian(&self, adjacency: &Array2<f64>) -> Array2<f64> {
268        let n = adjacency.nrows();
269        let mut laplacian = Array2::zeros((n, n));
270
271        // Compute degree matrix
272        let mut degrees = Array1::zeros(n);
273        for i in 0..n {
274            degrees[i] = adjacency.row(i).sum();
275        }
276
277        // Compute normalized Laplacian: I - D^(-1/2) * A * D^(-1/2)
278        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    /// Label propagation using graph structure
295    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        // Initialize labeled points
306        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        // Iterative label propagation
313        for iteration in 0..self.max_iter {
314            let old_labels = labels.clone();
315
316            // Update unlabeled points
317            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            // Reset labeled points (clamping)
335            for (i, &idx) in labeled_indices.iter().enumerate() {
336                labels[idx] = y_labeled[i];
337            }
338
339            // Check convergence
340            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    /// Train graph-based semi-supervised SVM
366    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        // Combine labeled and unlabeled data
388        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        // Get unique classes
399        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        // Build graph
411        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        // Prepare indices
415        let labeled_indices: Vec<usize> = (0..n_labeled).collect();
416        let unlabeled_indices: Vec<usize> = (n_labeled..n_labeled + n_unlabeled).collect();
417
418        // Convert labels to f64 for propagation (-1, 1 for binary)
419        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            // For multi-class, use one-vs-rest approach
423            y_labeled.map(|&label| label as f64)
424        };
425
426        // Perform label propagation
427        let propagated_labels =
428            self.propagate_labels(&graph, &labeled_indices, &unlabeled_indices, &y_labeled_f64);
429
430        // Extract predictions for unlabeled data
431        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        // Train SVM on combined data with propagated labels
437        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        // Simple linear SVM training (simplified for demonstration)
450        let mut coef = Array1::zeros(n_features);
451        let mut intercept = 0.0;
452
453        // Use gradient descent for training
454        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                // Compute prediction
469                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                // Hinge loss gradient
477                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            // Add regularization
486            for j in 0..n_features {
487                w_gradient[j] += coef[j] / self.c;
488            }
489
490            // Update parameters
491            for j in 0..n_features {
492                coef[j] -= learning_rate * w_gradient[j];
493            }
494            intercept -= learning_rate * intercept_gradient;
495
496            // Check convergence (simplified)
497            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
534// Note: We use a custom fit method instead of the standard Fit trait
535// because semi-supervised learning requires both labeled and unlabeled data
536
537impl 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            // Binary classification
543            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            // Multi-class: predict class with highest score
553            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    /// Compute decision function values
570    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            // Binary classification: single decision function
582            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            // Multi-class: one score per class
597            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    /// Get the model coefficients
616    pub fn coef(&self) -> &Array2<f64> {
617        &self.coef_
618    }
619
620    /// Get the intercept terms
621    pub fn intercept(&self) -> &Array1<f64> {
622        &self.intercept_
623    }
624
625    /// Get the class labels
626    pub fn classes(&self) -> &Array1<i32> {
627        &self.classes_
628    }
629
630    /// Get the graph adjacency matrix
631    pub fn graph_matrix(&self) -> &Array2<f64> {
632        &self.graph_matrix_
633    }
634
635    /// Get the predictions for unlabeled data from training
636    pub fn unlabeled_predictions(&self) -> &Array1<f64> {
637        &self.unlabeled_predictions_
638    }
639
640    /// Get the combined training data (labeled + unlabeled)
641    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        // Test predictions on labeled data
669        let predictions = trained_model
670            .predict(&X_labeled_var)
671            .expect("prediction should succeed");
672        assert_eq!(predictions.len(), 2);
673
674        // Test decision function
675        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        // Check that unlabeled predictions were generated
681        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        // Graph should be symmetric
694        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        // Diagonal should be zero
701        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        // Check that each row has at most k non-zero entries (excluding diagonal)
716        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        // Check that unlabeled predictions are reasonable
774        let unlabeled_preds = trained_model.unlabeled_predictions();
775        println!("Unlabeled predictions: {:?}", unlabeled_preds);
776
777        // First unlabeled point (0.5, 0.5) should be closer to class 0
778        // Second unlabeled point (1.5, 1.5) should be closer to class 1
779        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]]; // Wrong dimension
787
788        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}