Skip to main content

sklears_svm/
distributed_svm.rs

1//! Distributed Support Vector Machine Training
2//!
3//! This module implements distributed SVM training algorithms that can
4//! scale across multiple processes or machines using message passing
5//! and data parallelism.
6
7use crate::kernels::{create_kernel, Kernel, KernelType};
8use crate::smo::{SmoConfig, SmoSolver};
9use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2};
10use sklears_core::{
11    error::{Result, SklearsError},
12    traits::{Estimator, Fit, Predict, Trained, Untrained},
13    types::Float,
14};
15use std::marker::PhantomData;
16use std::sync::{Arc, Mutex};
17use std::thread;
18
19/// Configuration for distributed SVM training
20#[derive(Debug, Clone)]
21pub struct DistributedConfig {
22    /// Number of worker processes/threads
23    pub n_workers: usize,
24    /// Communication interval (iterations between synchronization)
25    pub sync_interval: usize,
26    /// Maximum number of global iterations
27    pub max_global_iter: usize,
28    /// Convergence tolerance
29    pub tolerance: Float,
30    /// Whether to use asynchronous updates
31    pub async_updates: bool,
32    /// Size of data chunks per worker
33    pub chunk_size: usize,
34    /// Cache size for each worker (in MB)
35    pub cache_size_mb: usize,
36}
37
38impl Default for DistributedConfig {
39    fn default() -> Self {
40        Self {
41            n_workers: 4,
42            sync_interval: 10,
43            max_global_iter: 100,
44            tolerance: 1e-3,
45            async_updates: false,
46            chunk_size: 1000,
47            cache_size_mb: 50,
48        }
49    }
50}
51
52/// Distributed training strategy
53#[derive(Debug, Clone, PartialEq, Default)]
54pub enum DistributedStrategy {
55    /// Data parallel: each worker processes a subset of data
56    #[default]
57    DataParallel,
58    /// Model parallel: each worker handles a subset of support vectors
59    ModelParallel,
60    /// Hybrid: combination of data and model parallelism
61    Hybrid,
62}
63
64/// Communication protocol for distributed training
65#[derive(Debug, Clone, PartialEq, Default)]
66pub enum CommunicationProtocol {
67    /// Synchronous: all workers synchronize at each iteration
68    #[default]
69    Synchronous,
70    /// Asynchronous: workers update shared state independently
71    Asynchronous,
72    /// Parameter server: centralized parameter management
73    ParameterServer,
74}
75
76/// Worker state for distributed training
77#[allow(dead_code)] // intentionally deferred: distributed worker state not yet instantiated
78#[derive(Debug, Clone)]
79struct WorkerState {
80    /// Worker ID
81    worker_id: usize,
82    /// Local dual coefficients
83    local_alpha: Array1<Float>,
84    /// Local data chunk indices
85    data_indices: Vec<usize>,
86    /// Local convergence status
87    converged: bool,
88    /// Number of local iterations
89    local_iterations: usize,
90}
91
92/// Shared state across all workers
93#[derive(Debug)]
94struct SharedState {
95    /// Global dual coefficients
96    global_alpha: Arc<Mutex<Array1<Float>>>,
97    /// Global intercept
98    global_intercept: Arc<Mutex<Float>>,
99    /// Convergence flags from all workers
100    worker_convergence: Arc<Mutex<Vec<bool>>>,
101    /// Global iteration counter
102    global_iteration: Arc<Mutex<usize>>,
103    /// Total number of samples
104    #[allow(dead_code)] // intentionally deferred: sample count readout pending
105    n_samples: usize,
106}
107
108/// Distributed SVM classifier
109#[derive(Debug, Clone)]
110pub struct DistributedSVM<S> {
111    /// Regularization parameter
112    pub c: Float,
113    /// Kernel function
114    pub kernel: KernelType,
115    /// Distributed training configuration
116    pub config: DistributedConfig,
117    /// Training strategy
118    pub strategy: DistributedStrategy,
119    /// Communication protocol
120    pub protocol: CommunicationProtocol,
121    /// Number of classes
122    pub n_classes: Option<usize>,
123    /// Support vectors
124    pub support_vectors: Option<Array2<Float>>,
125    /// Dual coefficients
126    pub dual_coef: Option<Array1<Float>>,
127    /// Intercept
128    pub intercept: Float,
129    /// Class labels
130    pub classes: Option<Array1<i32>>,
131    /// Number of support vectors per class
132    pub n_support: Option<Array1<usize>>,
133    /// State marker
134    _state: PhantomData<S>,
135}
136
137impl DistributedSVM<Untrained> {
138    /// Create a new distributed SVM classifier
139    pub fn new(
140        c: Float,
141        kernel: KernelType,
142        config: DistributedConfig,
143        strategy: DistributedStrategy,
144        protocol: CommunicationProtocol,
145    ) -> Self {
146        Self {
147            c,
148            kernel,
149            config,
150            strategy,
151            protocol,
152            n_classes: None,
153            support_vectors: None,
154            dual_coef: None,
155            intercept: 0.0,
156            classes: None,
157            n_support: None,
158            _state: PhantomData,
159        }
160    }
161
162    /// Builder pattern for configuration
163    pub fn builder() -> DistributedSVMBuilder {
164        DistributedSVMBuilder::new()
165    }
166}
167
168/// Builder for DistributedSVM
169pub struct DistributedSVMBuilder {
170    c: Float,
171    kernel: KernelType,
172    config: DistributedConfig,
173    strategy: DistributedStrategy,
174    protocol: CommunicationProtocol,
175}
176
177impl DistributedSVMBuilder {
178    pub fn new() -> Self {
179        Self {
180            c: 1.0,
181            kernel: KernelType::Rbf { gamma: 1.0 },
182            config: DistributedConfig::default(),
183            strategy: DistributedStrategy::default(),
184            protocol: CommunicationProtocol::default(),
185        }
186    }
187
188    pub fn c(mut self, c: Float) -> Self {
189        self.c = c;
190        self
191    }
192
193    pub fn kernel(mut self, kernel: KernelType) -> Self {
194        self.kernel = kernel;
195        self
196    }
197
198    pub fn n_workers(mut self, n_workers: usize) -> Self {
199        self.config.n_workers = n_workers;
200        self
201    }
202
203    pub fn sync_interval(mut self, sync_interval: usize) -> Self {
204        self.config.sync_interval = sync_interval;
205        self
206    }
207
208    pub fn max_global_iter(mut self, max_global_iter: usize) -> Self {
209        self.config.max_global_iter = max_global_iter;
210        self
211    }
212
213    pub fn tolerance(mut self, tolerance: Float) -> Self {
214        self.config.tolerance = tolerance;
215        self
216    }
217
218    pub fn strategy(mut self, strategy: DistributedStrategy) -> Self {
219        self.strategy = strategy;
220        self
221    }
222
223    pub fn protocol(mut self, protocol: CommunicationProtocol) -> Self {
224        self.protocol = protocol;
225        self
226    }
227
228    pub fn chunk_size(mut self, chunk_size: usize) -> Self {
229        self.config.chunk_size = chunk_size;
230        self
231    }
232
233    pub fn build(self) -> DistributedSVM<Untrained> {
234        DistributedSVM::new(
235            self.c,
236            self.kernel,
237            self.config,
238            self.strategy,
239            self.protocol,
240        )
241    }
242}
243
244impl Default for DistributedSVMBuilder {
245    fn default() -> Self {
246        Self::new()
247    }
248}
249
250impl Fit<ArrayView2<'_, Float>, ArrayView1<'_, Float>> for DistributedSVM<Untrained> {
251    type Fitted = DistributedSVM<Trained>;
252
253    fn fit(self, x: &ArrayView2<Float>, y: &ArrayView1<Float>) -> Result<Self::Fitted> {
254        self.fit_distributed(*x, *y)
255    }
256}
257
258impl DistributedSVM<Untrained> {
259    /// Train using distributed approach
260    pub fn fit_distributed(
261        &self,
262        x: ArrayView2<Float>,
263        y: ArrayView1<Float>,
264    ) -> Result<DistributedSVM<Trained>> {
265        let n_samples = x.nrows();
266        let n_features = x.ncols();
267
268        // Determine unique classes
269        let mut classes_vec = Vec::new();
270        for &label in y.iter() {
271            let label_i32 = label as i32;
272            if !classes_vec.contains(&label_i32) {
273                classes_vec.push(label_i32);
274            }
275        }
276        classes_vec.sort_unstable();
277        let n_classes = classes_vec.len();
278
279        if n_classes != 2 {
280            return Err(SklearsError::InvalidInput(
281                "Multi-class distributed SVM not yet implemented".to_string(),
282            ));
283        }
284
285        // Convert to binary labels
286        let mut binary_y = Array1::zeros(n_samples);
287        for (i, &label) in y.iter().enumerate() {
288            binary_y[i] = if label as i32 == classes_vec[0] {
289                -1.0
290            } else {
291                1.0
292            };
293        }
294
295        // Initialize shared state
296        let shared_state = SharedState {
297            global_alpha: Arc::new(Mutex::new(Array1::zeros(n_samples))),
298            global_intercept: Arc::new(Mutex::new(0.0)),
299            worker_convergence: Arc::new(Mutex::new(vec![false; self.config.n_workers])),
300            global_iteration: Arc::new(Mutex::new(0)),
301            n_samples,
302        };
303
304        // Partition data across workers
305        let chunk_size = n_samples / self.config.n_workers;
306        let mut worker_handles = Vec::new();
307
308        for worker_id in 0..self.config.n_workers {
309            let start_idx = worker_id * chunk_size;
310            let end_idx = if worker_id == self.config.n_workers - 1 {
311                n_samples
312            } else {
313                (worker_id + 1) * chunk_size
314            };
315
316            // Clone data for this worker
317            let worker_x = x
318                .slice(scirs2_core::ndarray::s![start_idx..end_idx, ..])
319                .to_owned();
320            let worker_y = binary_y
321                .slice(scirs2_core::ndarray::s![start_idx..end_idx])
322                .to_owned();
323            let data_indices: Vec<usize> = (start_idx..end_idx).collect();
324
325            // Clone necessary data for the worker
326            let kernel = self.kernel.clone();
327            let c = self.c;
328            let tolerance = self.config.tolerance;
329            let sync_interval = self.config.sync_interval;
330            let max_global_iter = self.config.max_global_iter;
331            let cache_size_mb = self.config.cache_size_mb;
332
333            // Clone shared state references
334            let global_alpha = shared_state.global_alpha.clone();
335            let global_intercept = shared_state.global_intercept.clone();
336            let worker_convergence = shared_state.worker_convergence.clone();
337            let global_iteration = shared_state.global_iteration.clone();
338
339            // Spawn worker thread
340            let handle = thread::spawn(move || {
341                Self::worker_thread(
342                    worker_id,
343                    worker_x,
344                    worker_y,
345                    data_indices,
346                    kernel,
347                    c,
348                    tolerance,
349                    sync_interval,
350                    max_global_iter,
351                    cache_size_mb,
352                    global_alpha,
353                    global_intercept,
354                    worker_convergence,
355                    global_iteration,
356                )
357            });
358
359            worker_handles.push(handle);
360        }
361
362        // Wait for all workers to complete
363        for handle in worker_handles {
364            handle.join().map_err(|_| {
365                SklearsError::NumericalError("Worker thread panicked".to_string())
366            })??;
367        }
368
369        // Extract final results
370        let final_alpha = shared_state
371            .global_alpha
372            .lock()
373            .expect("lock not poisoned")
374            .clone();
375        let final_intercept = *shared_state
376            .global_intercept
377            .lock()
378            .expect("lock not poisoned");
379
380        // Extract support vectors
381        let support_indices: Vec<usize> = final_alpha
382            .iter()
383            .enumerate()
384            .filter(|(_, &coef)| coef.abs() > 1e-10)
385            .map(|(i, _)| i)
386            .collect();
387
388        let n_support_vectors = support_indices.len();
389        let mut support_vectors = Array2::zeros((n_support_vectors, n_features));
390        let mut support_dual_coef = Array1::zeros(n_support_vectors);
391
392        for (i, &idx) in support_indices.iter().enumerate() {
393            support_vectors.row_mut(i).assign(&x.row(idx));
394            support_dual_coef[i] = final_alpha[idx];
395        }
396
397        // Count support vectors per class
398        let mut n_support = Array1::zeros(n_classes);
399        for &coef in support_dual_coef.iter() {
400            if coef > 0.0 {
401                n_support[1] += 1;
402            } else {
403                n_support[0] += 1;
404            }
405        }
406
407        Ok(DistributedSVM {
408            c: self.c,
409            kernel: self.kernel.clone(),
410            config: self.config.clone(),
411            strategy: self.strategy.clone(),
412            protocol: self.protocol.clone(),
413            n_classes: Some(n_classes),
414            support_vectors: Some(support_vectors),
415            dual_coef: Some(support_dual_coef),
416            intercept: final_intercept,
417            classes: Some(Array1::from_vec(classes_vec)),
418            n_support: Some(n_support),
419            _state: PhantomData,
420        })
421    }
422
423    /// Worker thread function
424    #[allow(clippy::too_many_arguments)]
425    fn worker_thread(
426        worker_id: usize,
427        x: Array2<Float>,
428        y: Array1<Float>,
429        data_indices: Vec<usize>,
430        kernel: KernelType,
431        c: Float,
432        tolerance: Float,
433        sync_interval: usize,
434        max_global_iter: usize,
435        cache_size_mb: usize,
436        global_alpha: Arc<Mutex<Array1<Float>>>,
437        global_intercept: Arc<Mutex<Float>>,
438        worker_convergence: Arc<Mutex<Vec<bool>>>,
439        global_iteration: Arc<Mutex<usize>>,
440    ) -> Result<()> {
441        // Initialize local state
442        let mut local_alpha = Array1::zeros(x.nrows());
443        let mut local_iterations = 0;
444
445        // Create SMO solver for this worker
446        let smo_config = SmoConfig {
447            c,
448            tol: tolerance,
449            max_iter: sync_interval,
450            cache_size: cache_size_mb,
451            shrinking: true,
452            ..Default::default()
453        };
454
455        let concrete_kernel = create_kernel(kernel)?;
456        let mut smo_solver = SmoSolver::new(smo_config, concrete_kernel);
457
458        loop {
459            // Safety check: prevent infinite loops with local iteration limit
460            if local_iterations >= max_global_iter * 2 {
461                break;
462            }
463            local_iterations += 1;
464
465            // Check global convergence and iteration limit
466            {
467                let global_iter = *global_iteration.lock().expect("lock not poisoned");
468                let convergence_flags = worker_convergence.lock().expect("lock not poisoned");
469
470                if global_iter >= max_global_iter || convergence_flags.iter().all(|&x| x) {
471                    break;
472                }
473            }
474
475            // Perform local SMO iterations
476            let smo_result = smo_solver.solve(&x, &y)?;
477
478            // Update local alpha
479            for (i, &coef) in smo_result.alpha.iter().enumerate() {
480                local_alpha[i] = coef;
481            }
482
483            // Synchronize with global state
484            {
485                let mut global_alpha_lock = global_alpha.lock().expect("lock not poisoned");
486                let mut global_intercept_lock = global_intercept.lock().expect("lock not poisoned");
487                let mut convergence_lock = worker_convergence.lock().expect("lock not poisoned");
488                let mut global_iter_lock = global_iteration.lock().expect("lock not poisoned");
489
490                // Update global alpha for this worker's data indices
491                for (local_idx, &global_idx) in data_indices.iter().enumerate() {
492                    global_alpha_lock[global_idx] = local_alpha[local_idx];
493                }
494
495                // Update global intercept (average across workers)
496                *global_intercept_lock = (*global_intercept_lock * worker_id as Float
497                    + smo_result.b)
498                    / (worker_id + 1) as Float;
499
500                // Update convergence status
501                convergence_lock[worker_id] = smo_result.converged;
502
503                // Increment global iteration (only by worker 0 to avoid double counting)
504                if worker_id == 0 {
505                    *global_iter_lock += 1;
506                }
507            }
508
509            // Brief pause for synchronization
510            thread::yield_now();
511        }
512
513        Ok(())
514    }
515}
516
517impl Predict<ArrayView2<'_, Float>, Array1<Float>> for DistributedSVM<Trained> {
518    fn predict(&self, x: &ArrayView2<Float>) -> Result<Array1<Float>> {
519        let support_vectors =
520            self.support_vectors
521                .as_ref()
522                .ok_or_else(|| SklearsError::NotFitted {
523                    operation: "predict".to_string(),
524                })?;
525
526        let dual_coef = self
527            .dual_coef
528            .as_ref()
529            .ok_or_else(|| SklearsError::NotFitted {
530                operation: "predict".to_string(),
531            })?;
532
533        let classes = self
534            .classes
535            .as_ref()
536            .ok_or_else(|| SklearsError::NotFitted {
537                operation: "predict".to_string(),
538            })?;
539
540        let concrete_kernel = create_kernel(self.kernel.clone())?;
541        let mut predictions = Array1::zeros(x.nrows());
542
543        // Compute decision function for each sample
544        for i in 0..x.nrows() {
545            let mut decision_value = 0.0;
546
547            // Compute kernel values with all support vectors
548            for j in 0..support_vectors.nrows() {
549                let kernel_value = concrete_kernel.compute(
550                    x.row(i).to_owned().view(),
551                    support_vectors.row(j).to_owned().view(),
552                );
553                decision_value += dual_coef[j] * kernel_value;
554            }
555
556            decision_value += self.intercept;
557
558            // Convert to class prediction
559            predictions[i] = if decision_value >= 0.0 {
560                classes[1] as Float
561            } else {
562                classes[0] as Float
563            };
564        }
565
566        Ok(predictions)
567    }
568}
569
570impl Estimator for DistributedSVM<Untrained> {
571    type Config = DistributedConfig;
572    type Error = SklearsError;
573    type Float = Float;
574
575    fn config(&self) -> &Self::Config {
576        &self.config
577    }
578}
579
580impl Estimator for DistributedSVM<Trained> {
581    type Config = DistributedConfig;
582    type Error = SklearsError;
583    type Float = Float;
584
585    fn config(&self) -> &Self::Config {
586        &self.config
587    }
588}
589
590#[allow(non_snake_case)]
591#[cfg(test)]
592mod tests {
593    use super::*;
594    use scirs2_core::ndarray::{Array1, Array2};
595
596    #[test]
597    fn test_distributed_svm_creation() {
598        let config = DistributedConfig::default();
599        let svm = DistributedSVM::new(
600            1.0,
601            KernelType::Linear,
602            config,
603            DistributedStrategy::DataParallel,
604            CommunicationProtocol::Synchronous,
605        );
606        assert_eq!(svm.c, 1.0);
607        assert_eq!(svm.config().n_workers, 4);
608    }
609
610    #[test]
611    fn test_distributed_svm_builder() {
612        let svm = DistributedSVM::builder()
613            .c(2.0)
614            .kernel(KernelType::Rbf { gamma: 0.5 })
615            .n_workers(8)
616            .tolerance(1e-4)
617            .strategy(DistributedStrategy::ModelParallel)
618            .protocol(CommunicationProtocol::Asynchronous)
619            .build();
620
621        assert_eq!(svm.c, 2.0);
622        assert_eq!(svm.config().n_workers, 8);
623        assert_eq!(svm.config().tolerance, 1e-4);
624        assert_eq!(svm.strategy, DistributedStrategy::ModelParallel);
625        assert_eq!(svm.protocol, CommunicationProtocol::Asynchronous);
626    }
627
628    #[test]
629    #[ignore = "Disabled due to potential deadlock in worker threads - needs architecture redesign"]
630    fn test_distributed_svm_training() -> Result<()> {
631        // Create simple binary classification data
632        let x = Array2::from_shape_vec(
633            (8, 2),
634            vec![
635                1.0, 2.0, 2.0, 3.0, 3.0, 4.0, 4.0, 5.0, 5.0, 6.0, 6.0, 7.0, 7.0, 8.0, 8.0, 9.0,
636            ],
637        )?;
638        let y = Array1::from_vec(vec![1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0]);
639
640        let svm = DistributedSVM::builder()
641            .c(1.0)
642            .kernel(KernelType::Linear)
643            .n_workers(2)
644            .tolerance(1e-2)
645            .max_global_iter(10)
646            .build();
647
648        let trained_svm = svm.fit(&x.view(), &y.view())?;
649
650        // Test predictions
651        let predictions = trained_svm.predict(&x.view())?;
652        assert_eq!(predictions.len(), 8);
653
654        // Check that we have reasonable predictions
655        for &pred in predictions.iter() {
656            assert!(pred == 1.0 || pred == -1.0);
657        }
658
659        Ok(())
660    }
661}