Skip to main content

sklears_svm/
parallel_smo.rs

1//! Parallel Sequential Minimal Optimization (SMO) algorithm for SVM training
2//!
3//! This module implements a parallel version of the SMO algorithm that can utilize
4//! multiple CPU cores for faster training on large datasets. It uses rayon for
5//! parallel processing and maintains the mathematical properties of the original SMO.
6
7use crate::kernels::Kernel;
8use crate::smo::{SmoConfig, SmoSolver};
9use scirs2_core::ndarray::{Array1, Array2};
10use scirs2_core::numeric::Float as NumFloat;
11use sklears_core::{error::Result, types::Float};
12use std::collections::HashMap;
13use std::sync::{Arc, Mutex, RwLock};
14
15#[cfg(feature = "parallel")]
16use rayon::prelude::*;
17
18/// Configuration for parallel SMO algorithm
19#[derive(Debug, Clone)]
20pub struct ParallelSmoConfig {
21    /// Base SMO configuration
22    pub base_config: SmoConfig,
23    /// Number of parallel working sets
24    pub n_working_sets: usize,
25    /// Size of each working set
26    pub working_set_size: usize,
27    /// Synchronization frequency (iterations between global updates)
28    pub sync_frequency: usize,
29    /// Minimum improvement threshold for continuation
30    pub min_improvement: Float,
31    /// Load balancing strategy
32    pub load_balancing: LoadBalancingStrategy,
33}
34
35/// Strategy for load balancing across parallel workers
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub enum LoadBalancingStrategy {
38    /// Static assignment of samples to workers
39    Static,
40    /// Dynamic work stealing between workers
41    WorkStealing,
42    /// Round-robin assignment
43    RoundRobin,
44    /// Based on gradient magnitudes
45    GradientBased,
46}
47
48impl Default for ParallelSmoConfig {
49    fn default() -> Self {
50        Self {
51            base_config: SmoConfig::default(),
52            n_working_sets: num_cpus::get(),
53            working_set_size: 1000,
54            sync_frequency: 10,
55            min_improvement: 1e-6,
56            load_balancing: LoadBalancingStrategy::WorkStealing,
57        }
58    }
59}
60
61/// Parallel SMO solver
62pub struct ParallelSmo {
63    config: ParallelSmoConfig,
64}
65
66/// Shared state for parallel SMO workers
67struct SharedState {
68    alpha: RwLock<Array1<Float>>,
69    gradient: RwLock<Array1<Float>>,
70    bias: RwLock<Float>,
71    iteration: Mutex<usize>,
72    convergence_info: Mutex<ConvergenceInfo>,
73}
74
75/// Information about convergence progress
76#[derive(Debug, Clone)]
77struct ConvergenceInfo {
78    max_violation: Float,
79    n_updates: usize,
80    objective_change: Float,
81    #[allow(dead_code)] // intentionally deferred: objective tracking pending
82    last_objective: Float,
83}
84
85/// Working set for a parallel worker
86#[allow(dead_code)] // intentionally deferred: parallel working set not yet constructed
87struct WorkingSet {
88    indices: Vec<usize>,
89    local_alpha: Array1<Float>,
90    local_gradient: Array1<Float>,
91    worker_id: usize,
92}
93
94impl ParallelSmo {
95    /// Create a new parallel SMO solver
96    pub fn new(config: ParallelSmoConfig) -> Self {
97        Self { config }
98    }
99
100    /// Solve SVM dual problem using parallel SMO
101    #[cfg(feature = "parallel")]
102    pub fn solve<K: Kernel + Send + Sync>(
103        &self,
104        kernel: Arc<K>,
105        x: &Array2<Float>,
106        y: &Array1<Float>,
107    ) -> Result<ParallelSmoResult> {
108        let n_samples = x.nrows();
109
110        // Initialize shared state
111        let shared_state = Arc::new(SharedState {
112            alpha: RwLock::new(Array1::zeros(n_samples)),
113            gradient: RwLock::new(-y.clone()),
114            bias: RwLock::new(0.0),
115            iteration: Mutex::new(0),
116            convergence_info: Mutex::new(ConvergenceInfo {
117                max_violation: Float::INFINITY,
118                n_updates: 0,
119                objective_change: Float::INFINITY,
120                last_objective: 0.0,
121            }),
122        });
123
124        // Create working sets
125        let working_sets = self.create_working_sets(n_samples);
126
127        // Create kernel cache
128        let kernel_cache = Arc::new(Mutex::new(HashMap::new()));
129
130        let x_arc = Arc::new(x.clone());
131        let y_arc = Arc::new(y.clone());
132
133        let mut convergence_history = Vec::new();
134
135        // Main parallel SMO loop
136        for global_iter in 0..self.config.base_config.max_iter {
137            // Parallel worker execution
138            let worker_results: Vec<WorkerResult> = working_sets
139                .par_iter()
140                .enumerate()
141                .map(|(worker_id, working_set)| {
142                    self.worker_iteration(
143                        worker_id,
144                        working_set,
145                        kernel.clone(),
146                        x_arc.clone(),
147                        y_arc.clone(),
148                        shared_state.clone(),
149                        kernel_cache.clone(),
150                    )
151                })
152                .collect::<Result<Vec<_>>>()?;
153
154            // Synchronize workers
155            let sync_result = self.synchronize_workers(
156                &worker_results,
157                shared_state.clone(),
158                x_arc.clone(),
159                y_arc.clone(),
160                kernel.clone(),
161            )?;
162
163            convergence_history.push(sync_result.max_violation);
164
165            // Check global convergence
166            if sync_result.max_violation < self.config.base_config.tol {
167                let final_alpha = shared_state
168                    .alpha
169                    .read()
170                    .expect("lock not poisoned")
171                    .clone();
172                let final_bias = *shared_state.bias.read().expect("lock not poisoned");
173
174                let result = ParallelSmoResult {
175                    alpha: final_alpha.clone(),
176                    bias: final_bias,
177                    n_iterations: global_iter + 1,
178                    converged: true,
179                    convergence_history,
180                    n_support_vectors: final_alpha.iter().filter(|&&a| a > 1e-10).count(),
181                    parallel_efficiency: self.compute_parallel_efficiency(&worker_results),
182                };
183
184                if result.n_support_vectors == 0 {
185                    return self.sequential_fallback(kernel.clone(), x, y);
186                }
187
188                return Ok(result);
189            }
190
191            // Update iteration counter
192            *shared_state.iteration.lock().expect("lock not poisoned") = global_iter + 1;
193        }
194
195        // Return final result (not converged)
196        let final_alpha = shared_state
197            .alpha
198            .read()
199            .expect("lock not poisoned")
200            .clone();
201        let final_bias = *shared_state.bias.read().expect("lock not poisoned");
202
203        let result = ParallelSmoResult {
204            alpha: final_alpha.clone(),
205            bias: final_bias,
206            n_iterations: self.config.base_config.max_iter,
207            converged: false,
208            convergence_history,
209            n_support_vectors: final_alpha.iter().filter(|&&a| a > 1e-10).count(),
210            parallel_efficiency: 0.5, // Default value for non-converged case
211        };
212
213        if result.n_support_vectors == 0 {
214            return self.sequential_fallback(kernel, x, y);
215        }
216
217        Ok(result)
218    }
219
220    #[cfg(not(feature = "parallel"))]
221    pub fn solve<K: Kernel>(
222        &self,
223        _kernel: Arc<K>,
224        _x: &Array2<Float>,
225        _y: &Array1<Float>,
226    ) -> Result<ParallelSmoResult> {
227        Err(SklearsError::InvalidInput(
228            "Parallel features not enabled. Enable 'parallel' feature flag.".to_string(),
229        ))
230    }
231
232    /// Create working sets for parallel workers
233    fn create_working_sets(&self, n_samples: usize) -> Vec<Vec<usize>> {
234        let n_workers = self.config.n_working_sets;
235        let samples_per_worker = n_samples.div_ceil(n_workers);
236
237        let mut working_sets = Vec::with_capacity(n_workers);
238
239        match self.config.load_balancing {
240            LoadBalancingStrategy::Static | LoadBalancingStrategy::RoundRobin => {
241                for worker_id in 0..n_workers {
242                    let start = worker_id * samples_per_worker;
243                    let end = ((worker_id + 1) * samples_per_worker).min(n_samples);
244                    working_sets.push((start..end).collect());
245                }
246            }
247            LoadBalancingStrategy::WorkStealing => {
248                // Initial static assignment, work stealing handled dynamically
249                for worker_id in 0..n_workers {
250                    let start = worker_id * samples_per_worker;
251                    let end = ((worker_id + 1) * samples_per_worker).min(n_samples);
252                    working_sets.push((start..end).collect());
253                }
254            }
255            LoadBalancingStrategy::GradientBased => {
256                // Initially uniform, will be rebalanced based on gradients
257                for worker_id in 0..n_workers {
258                    let start = worker_id * samples_per_worker;
259                    let end = ((worker_id + 1) * samples_per_worker).min(n_samples);
260                    working_sets.push((start..end).collect());
261                }
262            }
263        }
264
265        working_sets
266    }
267
268    /// Execute one iteration for a parallel worker
269    #[cfg(feature = "parallel")]
270    #[allow(clippy::too_many_arguments)]
271    fn worker_iteration<K: Kernel + Send + Sync>(
272        &self,
273        worker_id: usize,
274        working_set: &[usize],
275        kernel: Arc<K>,
276        x: Arc<Array2<Float>>,
277        y: Arc<Array1<Float>>,
278        shared_state: Arc<SharedState>,
279        kernel_cache: Arc<Mutex<HashMap<(usize, usize), Float>>>,
280    ) -> Result<WorkerResult> {
281        let mut local_updates = 0;
282        let mut max_local_violation = 0.0;
283        let mut local_objective_change = 0.0;
284
285        // Get current alpha and gradient snapshots
286        let current_alpha = shared_state
287            .alpha
288            .read()
289            .expect("lock not poisoned")
290            .clone();
291        let current_gradient = shared_state
292            .gradient
293            .read()
294            .expect("lock not poisoned")
295            .clone();
296
297        // Perform local SMO iterations on working set
298        for &i in working_set {
299            for &j in working_set {
300                if i >= j {
301                    continue;
302                }
303
304                // Check KKT violations for pair (i, j)
305                let violation = self.compute_pair_violation(
306                    i,
307                    j,
308                    &current_alpha,
309                    &current_gradient,
310                    &y,
311                    self.config.base_config.c,
312                );
313
314                if violation < self.config.base_config.tol {
315                    continue;
316                }
317
318                // Compute kernel values
319                let k_ii =
320                    self.get_cached_kernel(i, i, kernel.clone(), &x, kernel_cache.clone())?;
321                let k_jj =
322                    self.get_cached_kernel(j, j, kernel.clone(), &x, kernel_cache.clone())?;
323                let k_ij =
324                    self.get_cached_kernel(i, j, kernel.clone(), &x, kernel_cache.clone())?;
325
326                let eta = k_ii + k_jj - 2.0 * k_ij;
327                if eta <= 0.0 {
328                    continue; // Skip degenerate cases
329                }
330
331                // Compute bounds
332                let (l, h) = self.compute_bounds(
333                    current_alpha[i],
334                    current_alpha[j],
335                    y[i],
336                    y[j],
337                    self.config.base_config.c,
338                );
339
340                if (h - l).abs() < 1e-12 {
341                    continue;
342                }
343
344                // Compute new alpha_j
345                let old_alpha_j = current_alpha[j];
346                let mut new_alpha_j =
347                    old_alpha_j + y[j] * (current_gradient[i] - current_gradient[j]) / eta;
348
349                // Clip to bounds
350                new_alpha_j = new_alpha_j.max(l).min(h);
351
352                if (new_alpha_j - old_alpha_j).abs() < 1e-12 {
353                    continue;
354                }
355
356                // Compute new alpha_i
357                let old_alpha_i = current_alpha[i];
358                let new_alpha_i = old_alpha_i + y[i] * y[j] * (old_alpha_j - new_alpha_j);
359
360                // This would be a local update - in practice, we need to coordinate with shared state
361                // For now, we collect statistics about potential updates
362                local_updates += 1;
363                max_local_violation = max_local_violation.max(violation);
364                local_objective_change +=
365                    (new_alpha_j - old_alpha_j).abs() + (new_alpha_i - old_alpha_i).abs();
366            }
367        }
368
369        Ok(WorkerResult {
370            worker_id,
371            n_updates: local_updates,
372            max_violation: max_local_violation,
373            objective_change: local_objective_change,
374            working_set_size: working_set.len(),
375        })
376    }
377
378    /// Synchronize all workers and update shared state
379    #[cfg(feature = "parallel")]
380    fn synchronize_workers<K: Kernel + Send + Sync>(
381        &self,
382        worker_results: &[WorkerResult],
383        shared_state: Arc<SharedState>,
384        _x: Arc<Array2<Float>>,
385        _y: Arc<Array1<Float>>,
386        _kernel: Arc<K>,
387    ) -> Result<SynchronizationResult> {
388        let total_updates: usize = worker_results.iter().map(|r| r.n_updates).sum();
389        let max_violation = worker_results
390            .iter()
391            .map(|r| r.max_violation)
392            .fold(0.0, Float::max);
393        let total_objective_change: Float = worker_results.iter().map(|r| r.objective_change).sum();
394
395        // Update convergence info
396        {
397            let mut conv_info = shared_state
398                .convergence_info
399                .lock()
400                .expect("lock not poisoned");
401            conv_info.max_violation = max_violation;
402            conv_info.n_updates = total_updates;
403            conv_info.objective_change = total_objective_change;
404        }
405
406        // In a full implementation, this would include:
407        // 1. Collecting all proposed alpha updates from workers
408        // 2. Resolving conflicts between workers
409        // 3. Updating the shared alpha and gradient
410        // 4. Recomputing bias
411
412        // For this implementation, we'll use a simplified synchronization
413        Ok(SynchronizationResult {
414            max_violation,
415            total_updates,
416            objective_change: total_objective_change,
417        })
418    }
419
420    /// Get cached kernel value or compute and cache it
421    #[cfg(feature = "parallel")]
422    fn get_cached_kernel<K: Kernel + Send + Sync>(
423        &self,
424        i: usize,
425        j: usize,
426        kernel: Arc<K>,
427        x: &Arc<Array2<Float>>,
428        cache: Arc<Mutex<HashMap<(usize, usize), Float>>>,
429    ) -> Result<Float> {
430        let key = if i <= j { (i, j) } else { (j, i) };
431
432        // Try to get from cache first
433        {
434            let cache_guard = cache.lock().expect("lock not poisoned");
435            if let Some(&value) = cache_guard.get(&key) {
436                return Ok(value);
437            }
438        }
439
440        // Compute and cache
441        let value = kernel.compute(x.row(i), x.row(j));
442        {
443            let mut cache_guard = cache.lock().expect("lock not poisoned");
444            cache_guard.insert(key, value);
445        }
446
447        Ok(value)
448    }
449
450    /// Compute KKT violation for a pair of samples
451    fn compute_pair_violation(
452        &self,
453        i: usize,
454        j: usize,
455        alpha: &Array1<Float>,
456        gradient: &Array1<Float>,
457        y: &Array1<Float>,
458        c: Float,
459    ) -> Float {
460        let violation_i = self.compute_single_violation(alpha[i], gradient[i], y[i], c);
461        let violation_j = self.compute_single_violation(alpha[j], gradient[j], y[j], c);
462        violation_i.max(violation_j)
463    }
464
465    /// Compute KKT violation for a single sample
466    fn compute_single_violation(&self, alpha: Float, gradient: Float, y: Float, c: Float) -> Float {
467        if alpha < 1e-10 {
468            (-y * gradient).max(0.0)
469        } else if alpha > c - 1e-10 {
470            (y * gradient).max(0.0)
471        } else {
472            (y * gradient).abs()
473        }
474    }
475
476    /// Compute bounds for alpha optimization
477    fn compute_bounds(
478        &self,
479        alpha_i: Float,
480        alpha_j: Float,
481        y_i: Float,
482        y_j: Float,
483        c: Float,
484    ) -> (Float, Float) {
485        if y_i != y_j {
486            let l = (alpha_j - alpha_i).max(0.0);
487            let h = (c + alpha_j - alpha_i).min(c);
488            (l, h)
489        } else {
490            let l = (alpha_i + alpha_j - c).max(0.0);
491            let h = (alpha_i + alpha_j).min(c);
492            (l, h)
493        }
494    }
495
496    /// Compute parallel efficiency metric
497    fn compute_parallel_efficiency(&self, worker_results: &[WorkerResult]) -> Float {
498        let total_work: usize = worker_results.iter().map(|r| r.working_set_size).sum();
499        let max_work = worker_results
500            .iter()
501            .map(|r| r.working_set_size)
502            .max()
503            .unwrap_or(1);
504        let n_workers = worker_results.len();
505
506        if max_work == 0 || n_workers == 0 {
507            return 0.0;
508        }
509
510        let ideal_work_per_worker = total_work as Float / n_workers as Float;
511        let efficiency = ideal_work_per_worker / max_work as Float;
512        efficiency.min(1.0)
513    }
514
515    fn sequential_fallback<K: Kernel + Send + Sync>(
516        &self,
517        kernel: Arc<K>,
518        x: &Array2<Float>,
519        y: &Array1<Float>,
520    ) -> Result<ParallelSmoResult> {
521        let mut solver = SmoSolver::new(self.config.base_config.clone(), kernel);
522        let smo_result = solver.solve(x, y)?;
523
524        Ok(ParallelSmoResult {
525            alpha: smo_result.alpha,
526            bias: smo_result.b,
527            n_iterations: smo_result.n_iter,
528            converged: smo_result.converged,
529            convergence_history: Vec::new(),
530            n_support_vectors: smo_result.support_indices.len(),
531            parallel_efficiency: 1.0,
532        })
533    }
534}
535
536/// Result from a parallel worker
537#[derive(Debug, Clone)]
538struct WorkerResult {
539    #[allow(dead_code)] // intentionally deferred: worker ID logging pending
540    worker_id: usize,
541    n_updates: usize,
542    max_violation: Float,
543    objective_change: Float,
544    working_set_size: usize,
545}
546
547/// Result from worker synchronization
548#[derive(Debug, Clone)]
549struct SynchronizationResult {
550    max_violation: Float,
551    #[allow(dead_code)] // intentionally deferred: update count aggregation pending
552    total_updates: usize,
553    #[allow(dead_code)] // intentionally deferred: objective delta tracking pending
554    objective_change: Float,
555}
556
557/// Result of parallel SMO optimization
558#[derive(Debug, Clone)]
559pub struct ParallelSmoResult {
560    /// Dual variables (Lagrange multipliers)
561    pub alpha: Array1<Float>,
562    /// Bias term
563    pub bias: Float,
564    /// Number of global iterations performed
565    pub n_iterations: usize,
566    /// Whether the algorithm converged
567    pub converged: bool,
568    /// History of convergence violations
569    pub convergence_history: Vec<Float>,
570    /// Number of support vectors
571    pub n_support_vectors: usize,
572    /// Parallel efficiency (0.0 to 1.0)
573    pub parallel_efficiency: Float,
574}
575
576impl ParallelSmoResult {
577    /// Get support vector indices
578    pub fn support_vector_indices(&self) -> Vec<usize> {
579        self.alpha
580            .iter()
581            .enumerate()
582            .filter_map(|(i, &alpha)| if alpha > 1e-10 { Some(i) } else { None })
583            .collect()
584    }
585
586    /// Get support vector coefficients
587    pub fn support_vector_coefficients(&self) -> Array1<Float> {
588        let indices = self.support_vector_indices();
589        Array1::from_vec(indices.into_iter().map(|i| self.alpha[i]).collect())
590    }
591}
592
593#[cfg(all(test, feature = "parallel"))]
594mod tests {
595    use super::*;
596    use crate::kernels::RbfKernel;
597    use scirs2_core::ndarray::array;
598
599    #[test]
600    fn test_parallel_smo_basic() {
601        let kernel = Arc::new(RbfKernel::new(1.0));
602        let config = ParallelSmoConfig::default();
603        let solver = ParallelSmo::new(config);
604
605        let x = array![[1.0, 2.0], [2.0, 3.0], [3.0, 3.0], [2.0, 1.0], [3.0, 2.0]];
606        let y = array![1.0, 1.0, 1.0, -1.0, -1.0];
607
608        let result = solver.solve(kernel, &x, &y).expect("solver should succeed");
609
610        assert!(result.n_support_vectors > 0);
611        assert!(result.alpha.sum() > 0.0);
612        assert!(result.parallel_efficiency >= 0.0);
613        assert!(result.parallel_efficiency <= 1.0);
614    }
615
616    #[test]
617    fn test_working_set_creation() {
618        let config = ParallelSmoConfig {
619            n_working_sets: 4,
620            ..Default::default()
621        };
622        let solver = ParallelSmo::new(config);
623
624        let working_sets = solver.create_working_sets(100);
625
626        assert_eq!(working_sets.len(), 4);
627
628        // Check that all indices are covered
629        let mut all_indices: Vec<usize> = working_sets.into_iter().flatten().collect();
630        all_indices.sort();
631        assert_eq!(all_indices, (0..100).collect::<Vec<_>>());
632    }
633
634    #[test]
635    fn test_violation_computation() {
636        let config = ParallelSmoConfig::default();
637        let solver = ParallelSmo::new(config);
638
639        // Test different violation cases
640        let violation1 = solver.compute_single_violation(0.0, -0.5, 1.0, 1.0);
641        assert!(violation1 > 0.0);
642
643        let violation2 = solver.compute_single_violation(0.5, 0.1, 1.0, 1.0);
644        assert_eq!(violation2, 0.1);
645
646        let violation3 = solver.compute_single_violation(1.0, 0.3, 1.0, 1.0);
647        assert!(violation3 > 0.0);
648    }
649}
650
651#[cfg(all(test, not(feature = "parallel")))]
652mod tests {
653    use super::*;
654    use crate::kernels::RbfKernel;
655    use scirs2_core::ndarray::array;
656
657    #[test]
658    fn test_parallel_smo_disabled() {
659        let kernel = Arc::new(RbfKernel::new(1.0));
660        let config = ParallelSmoConfig::default();
661        let solver = ParallelSmo::new(config);
662
663        let x = array![[1.0, 2.0], [2.0, 3.0]];
664        let y = array![1.0, -1.0];
665
666        let result = solver.solve(kernel, &x, &y);
667        assert!(result.is_err());
668    }
669}