1use 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#[derive(Debug, Clone)]
20pub struct ParallelSmoConfig {
21 pub base_config: SmoConfig,
23 pub n_working_sets: usize,
25 pub working_set_size: usize,
27 pub sync_frequency: usize,
29 pub min_improvement: Float,
31 pub load_balancing: LoadBalancingStrategy,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq)]
37pub enum LoadBalancingStrategy {
38 Static,
40 WorkStealing,
42 RoundRobin,
44 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
61pub struct ParallelSmo {
63 config: ParallelSmoConfig,
64}
65
66struct 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#[derive(Debug, Clone)]
77struct ConvergenceInfo {
78 max_violation: Float,
79 n_updates: usize,
80 objective_change: Float,
81 #[allow(dead_code)] last_objective: Float,
83}
84
85#[allow(dead_code)] struct WorkingSet {
88 indices: Vec<usize>,
89 local_alpha: Array1<Float>,
90 local_gradient: Array1<Float>,
91 worker_id: usize,
92}
93
94impl ParallelSmo {
95 pub fn new(config: ParallelSmoConfig) -> Self {
97 Self { config }
98 }
99
100 #[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 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 let working_sets = self.create_working_sets(n_samples);
126
127 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 for global_iter in 0..self.config.base_config.max_iter {
137 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 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 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 *shared_state.iteration.lock().expect("lock not poisoned") = global_iter + 1;
193 }
194
195 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, };
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 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 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 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 #[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 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 for &i in working_set {
299 for &j in working_set {
300 if i >= j {
301 continue;
302 }
303
304 let violation = self.compute_pair_violation(
306 i,
307 j,
308 ¤t_alpha,
309 ¤t_gradient,
310 &y,
311 self.config.base_config.c,
312 );
313
314 if violation < self.config.base_config.tol {
315 continue;
316 }
317
318 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; }
330
331 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 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 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 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 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 #[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 {
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 Ok(SynchronizationResult {
414 max_violation,
415 total_updates,
416 objective_change: total_objective_change,
417 })
418 }
419
420 #[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 {
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 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 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 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 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 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#[derive(Debug, Clone)]
538struct WorkerResult {
539 #[allow(dead_code)] worker_id: usize,
541 n_updates: usize,
542 max_violation: Float,
543 objective_change: Float,
544 working_set_size: usize,
545}
546
547#[derive(Debug, Clone)]
549struct SynchronizationResult {
550 max_violation: Float,
551 #[allow(dead_code)] total_updates: usize,
553 #[allow(dead_code)] objective_change: Float,
555}
556
557#[derive(Debug, Clone)]
559pub struct ParallelSmoResult {
560 pub alpha: Array1<Float>,
562 pub bias: Float,
564 pub n_iterations: usize,
566 pub converged: bool,
568 pub convergence_history: Vec<Float>,
570 pub n_support_vectors: usize,
572 pub parallel_efficiency: Float,
574}
575
576impl ParallelSmoResult {
577 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 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 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 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}