Skip to main content

sklears_svm/
smo.rs

1//! Sequential Minimal Optimization (SMO) algorithm for SVM training
2
3use crate::kernels::Kernel;
4use scirs2_core::ndarray::{Array1, Array2};
5use sklears_core::{
6    error::{Result, SklearsError},
7    types::Float,
8};
9use std::cell::RefCell;
10use std::collections::HashMap;
11
12/// Working set selection strategy
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub enum WorkingSetStrategy {
15    /// First-order heuristic (maximum violating pair)
16    FirstOrder,
17    /// Second-order heuristic (maximum objective decrease)
18    SecondOrder,
19    /// Mixed strategy combining both
20    Mixed,
21}
22
23/// SMO algorithm configuration
24#[derive(Debug, Clone)]
25pub struct SmoConfig {
26    /// Regularization parameter
27    pub c: Float,
28    /// Tolerance for stopping criterion
29    pub tol: Float,
30    /// Maximum number of iterations
31    pub max_iter: usize,
32    /// Cache size for kernel evaluations (in MB)
33    pub cache_size: usize,
34    /// Enable shrinking heuristic
35    pub shrinking: bool,
36    /// Working set selection strategy
37    pub working_set_strategy: WorkingSetStrategy,
38    /// Early stopping threshold
39    pub early_stopping_tol: Float,
40    /// Check convergence every N iterations
41    pub convergence_check_interval: usize,
42}
43
44impl Default for SmoConfig {
45    fn default() -> Self {
46        Self {
47            c: 1.0,
48            tol: 1e-3,
49            max_iter: 1000,
50            cache_size: 200,
51            shrinking: true,
52            working_set_strategy: WorkingSetStrategy::SecondOrder,
53            early_stopping_tol: 1e-4,
54            convergence_check_interval: 10,
55        }
56    }
57}
58
59/// SMO algorithm result
60#[derive(Debug, Clone)]
61pub struct SmoResult {
62    /// Lagrange multipliers (alpha values)
63    pub alpha: Array1<Float>,
64    /// Bias term
65    pub b: Float,
66    /// Support vector indices
67    pub support_indices: Vec<usize>,
68    /// Number of iterations performed
69    pub n_iter: usize,
70    /// Whether the algorithm converged
71    pub converged: bool,
72    /// Final objective value
73    pub objective_value: Float,
74    /// Cache hit ratio for performance analysis
75    pub cache_hit_ratio: Float,
76}
77
78/// Kernel cache for efficient computation
79#[derive(Debug)]
80struct KernelCache {
81    cache: HashMap<(usize, usize), Float>,
82    max_size: usize,
83    hits: usize,
84    total_requests: usize,
85}
86
87impl KernelCache {
88    fn new(max_size: usize) -> Self {
89        Self {
90            cache: HashMap::with_capacity(max_size),
91            max_size,
92            hits: 0,
93            total_requests: 0,
94        }
95    }
96
97    fn get(&mut self, i: usize, j: usize) -> Option<Float> {
98        self.total_requests += 1;
99        let key = if i <= j { (i, j) } else { (j, i) };
100        if let Some(&value) = self.cache.get(&key) {
101            self.hits += 1;
102            Some(value)
103        } else {
104            None
105        }
106    }
107
108    fn insert(&mut self, i: usize, j: usize, value: Float) {
109        let key = if i <= j { (i, j) } else { (j, i) };
110
111        if self.cache.len() >= self.max_size {
112            // Simple eviction: remove first entry (could be improved with LRU)
113            if let Some(first_key) = self.cache.keys().next().copied() {
114                self.cache.remove(&first_key);
115            }
116        }
117
118        self.cache.insert(key, value);
119    }
120
121    fn hit_ratio(&self) -> Float {
122        if self.total_requests == 0 {
123            0.0
124        } else {
125            self.hits as Float / self.total_requests as Float
126        }
127    }
128}
129
130/// SMO algorithm implementation
131pub struct SmoSolver<K: Kernel> {
132    config: SmoConfig,
133    kernel: K,
134    // Training data (stored as owned data for now, views in solve)
135    x: Array2<Float>,
136    y: Array1<Float>,
137    // Algorithm state
138    alpha: Array1<Float>,
139    f: Array1<Float>, // Cached decision function values
140    b: Float,
141    // Working set and shrinking
142    active_set: Vec<usize>,
143    inactive_set: Vec<usize>,
144    // Kernel cache
145    kernel_cache: RefCell<KernelCache>,
146    // Convergence tracking
147    objective_values: Vec<Float>,
148    convergence_history: Vec<Float>,
149}
150
151impl<K: Kernel> SmoSolver<K> {
152    /// Create a new SMO solver
153    pub fn new(config: SmoConfig, kernel: K) -> Self {
154        let cache_size = (config.cache_size * 1024 * 1024) / (std::mem::size_of::<Float>() * 2); // Convert MB to number of entries
155        Self {
156            kernel_cache: RefCell::new(KernelCache::new(cache_size)),
157            config,
158            kernel,
159            x: Array2::zeros((0, 0)),
160            y: Array1::zeros(0),
161            alpha: Array1::zeros(0),
162            f: Array1::zeros(0),
163            b: 0.0,
164            active_set: Vec::new(),
165            inactive_set: Vec::new(),
166            objective_values: Vec::new(),
167            convergence_history: Vec::new(),
168        }
169    }
170
171    /// Solve the SVM optimization problem
172    pub fn solve(&mut self, x: &Array2<Float>, y: &Array1<Float>) -> Result<SmoResult> {
173        self.solve_with_warm_start(x, y, None)
174    }
175
176    /// Solve the SVM optimization problem with optional warm start
177    pub fn solve_with_warm_start(
178        &mut self,
179        x: &Array2<Float>,
180        y: &Array1<Float>,
181        warm_start_alpha: Option<&Array1<Float>>,
182    ) -> Result<SmoResult> {
183        let (n_samples, _n_features) = x.dim();
184
185        if n_samples != y.len() {
186            return Err(SklearsError::InvalidInput(
187                "Number of samples in X and y must match".to_string(),
188            ));
189        }
190
191        if n_samples == 0 {
192            return Err(SklearsError::InvalidInput(
193                "Cannot solve SVM with empty dataset".to_string(),
194            ));
195        }
196
197        // Initialize solver state - avoid unnecessary clones by reusing storage when possible
198        if self.x.dim() == x.dim() {
199            self.x.assign(x); // In-place copy, no allocation
200        } else {
201            self.x = x.clone(); // Need to reallocate
202        }
203
204        if self.y.dim() == y.dim() {
205            self.y.assign(y); // In-place copy, no allocation
206        } else {
207            self.y = y.clone(); // Need to reallocate
208        }
209
210        // Handle warm start
211        if let Some(alpha_init) = warm_start_alpha {
212            if alpha_init.len() != n_samples {
213                return Err(SklearsError::InvalidInput(format!(
214                    "Warm start alpha length {} does not match number of samples {}",
215                    alpha_init.len(),
216                    n_samples
217                )));
218            }
219
220            // Validate alpha values
221            for &alpha in alpha_init.iter() {
222                if alpha < 0.0 || alpha > self.config.c + 1e-10 {
223                    return Err(SklearsError::InvalidInput(format!(
224                        "Invalid alpha value {} in warm start (must be between 0 and C={})",
225                        alpha, self.config.c
226                    )));
227                }
228            }
229
230            self.alpha = alpha_init.clone();
231
232            // Initialize f values based on warm start
233            self.f = Array1::zeros(n_samples);
234            for i in 0..n_samples {
235                let mut f_i = 0.0;
236                for j in 0..n_samples {
237                    if self.alpha[j] > 1e-10 {
238                        let k_ij = self.get_kernel_value(i, j);
239                        f_i += self.alpha[j] * self.y[j] * k_ij;
240                    }
241                }
242                self.f[i] = f_i - self.y[i];
243            }
244
245            // Initialize bias with warm start
246            let support_indices = self.find_support_vectors();
247            self.update_bias(&support_indices)?;
248        } else {
249            // Cold start
250            self.alpha = Array1::zeros(n_samples);
251            self.f = -y.clone(); // f = -y initially (assuming all alpha = 0)
252            self.b = 0.0;
253        }
254
255        self.active_set = (0..n_samples).collect();
256        self.inactive_set.clear();
257        self.objective_values.clear();
258        self.convergence_history.clear();
259
260        let mut n_iter = 0; // Count of successful updates
261        let mut loop_iter = 0; // Total loop iterations
262        let mut converged = false;
263        let mut no_change_count = 0; // Count consecutive iterations with no updates
264
265        // Main SMO loop
266        while n_iter < self.config.max_iter && loop_iter < self.config.max_iter * 10 {
267            loop_iter += 1;
268
269            let (i, j) = match self.select_working_set() {
270                Some(pair) => pair,
271                None => {
272                    converged = true;
273                    break;
274                }
275            };
276
277            if self.take_step(i, j)? {
278                n_iter += 1;
279                no_change_count = 0; // Reset counter on successful update
280
281                // Compute and store objective value
282                if n_iter % 5 == 0 {
283                    let obj_val = self.compute_objective();
284                    self.objective_values.push(obj_val);
285                }
286            } else {
287                no_change_count += 1;
288                // If we've had too many consecutive iterations without updates, stop
289                if no_change_count > 100 {
290                    converged = true;
291                    break;
292                }
293            }
294
295            // Apply shrinking periodically
296            if self.config.shrinking && n_iter > 0 && n_iter % 100 == 0 {
297                self.apply_shrinking();
298            }
299
300            // Check convergence periodically
301            if n_iter > 0 && n_iter % self.config.convergence_check_interval == 0 {
302                let convergence_measure = self.compute_convergence_measure();
303                self.convergence_history.push(convergence_measure);
304
305                if self.check_convergence() {
306                    converged = true;
307                    break;
308                }
309
310                // Early stopping based on convergence rate
311                if self.should_early_stop() {
312                    converged = true;
313                    break;
314                }
315            }
316        }
317
318        // Find support vectors
319        let support_indices = self.find_support_vectors();
320
321        // Update bias using support vectors
322        self.update_bias(&support_indices)?;
323
324        let final_objective = self.compute_objective();
325        let cache_hit_ratio = self.kernel_cache.borrow().hit_ratio();
326
327        Ok(SmoResult {
328            alpha: self.alpha.clone(),
329            b: self.b,
330            support_indices,
331            n_iter,
332            converged,
333            objective_value: final_objective,
334            cache_hit_ratio,
335        })
336    }
337
338    /// Select working set (i, j) using heuristics
339    fn select_working_set(&self) -> Option<(usize, usize)> {
340        match self.config.working_set_strategy {
341            WorkingSetStrategy::FirstOrder => self.select_working_set_first_order(),
342            WorkingSetStrategy::SecondOrder => self.select_working_set_second_order(),
343            WorkingSetStrategy::Mixed => {
344                // Alternate between strategies based on iteration count
345                if self.objective_values.len().is_multiple_of(2) {
346                    self.select_working_set_second_order()
347                } else {
348                    self.select_working_set_first_order()
349                }
350            }
351        }
352    }
353
354    /// First-order working set selection (WSS1 - Maximal Violating Pair)
355    /// O(n) complexity using maximal violating pair heuristic
356    fn select_working_set_first_order(&self) -> Option<(usize, usize)> {
357        let mut i_up = None;
358        let mut i_low = None;
359        let mut g_min_up = Float::INFINITY; // Minimum gradient in I_up (most violation)
360        let mut g_max_low = Float::NEG_INFINITY; // Maximum gradient in I_low (most violation)
361
362        for &t in &self.active_set {
363            let alpha_t = self.alpha[t];
364            let y_t = self.y[t];
365            let f_t = self.f[t];
366            let g_t = y_t * f_t; // Gradient: y_i * f_i
367
368            // I_up: samples that can increase their alpha (violate upper bound when g_t < 1)
369            // For y_i = +1: alpha_i < C
370            // For y_i = -1: alpha_i > 0
371            if (y_t > 0.0 && alpha_t < self.config.c - self.config.tol)
372                || (y_t < 0.0 && alpha_t > self.config.tol)
373            {
374                // Find most violating sample (minimum gradient)
375                if g_t < g_min_up {
376                    g_min_up = g_t;
377                    i_up = Some(t);
378                }
379            }
380
381            // I_low: samples that can decrease their alpha (violate lower bound when g_t > 1)
382            // For y_i = -1: alpha_i < C
383            // For y_i = +1: alpha_i > 0
384            if (y_t < 0.0 && alpha_t < self.config.c - self.config.tol)
385                || (y_t > 0.0 && alpha_t > self.config.tol)
386            {
387                // Find most violating sample (maximum gradient)
388                if g_t > g_max_low {
389                    g_max_low = g_t;
390                    i_low = Some(t);
391                }
392            }
393        }
394
395        // Check if we found a violating pair
396        match (i_up, i_low) {
397            (Some(i), Some(j)) => {
398                // Continue optimization when optimality gap > tolerance
399                // OR when we're at initialization (both have same gradient but violate KKT)
400                let gap = g_max_low - g_min_up;
401                if gap > self.config.tol
402                    || (gap.abs() <= self.config.tol && g_min_up < 1.0 - self.config.tol)
403                {
404                    Some((i, j))
405                } else {
406                    None
407                }
408            }
409            _ => None,
410        }
411    }
412
413    /// Second-order working set selection (maximum objective decrease)
414    /// Optimized to use WSS1 approach with second-order refinement
415    fn select_working_set_second_order(&self) -> Option<(usize, usize)> {
416        // First find i_up using maximal violating pair heuristic (O(n))
417        let mut i_up = None;
418        let mut g_min_up = Float::INFINITY;
419
420        for &t in &self.active_set {
421            let alpha_t = self.alpha[t];
422            let y_t = self.y[t];
423            let f_t = self.f[t];
424            let g_t = y_t * f_t;
425
426            // Check if in I_up set and find minimum gradient (most violating)
427            if ((y_t > 0.0 && alpha_t < self.config.c - self.config.tol)
428                || (y_t < 0.0 && alpha_t > self.config.tol))
429                && g_t < g_min_up
430            {
431                g_min_up = g_t;
432                i_up = Some(t);
433            }
434        }
435
436        let i = i_up?;
437
438        // Now find j that maximizes objective decrease (O(n))
439        let mut best_j = None;
440        let mut best_decrease = -1e-10; // Negative to allow selection at initialization when all decreases are 0
441
442        let k_ii = self.get_kernel_value(i, i);
443        let g_i = g_min_up;
444
445        for &j in &self.active_set {
446            if i == j {
447                continue;
448            }
449
450            let alpha_j = self.alpha[j];
451            let y_j = self.y[j];
452            let f_j = self.f[j];
453            let g_j = y_j * f_j;
454
455            // Check if j is in I_low
456            if !((y_j < 0.0 && alpha_j < self.config.c - self.config.tol)
457                || (y_j > 0.0 && alpha_j > self.config.tol))
458            {
459                continue;
460            }
461
462            // Compute second-order information
463            let k_jj = self.get_kernel_value(j, j);
464            let k_ij = self.get_kernel_value(i, j);
465            let eta = k_ii + k_jj - 2.0 * k_ij;
466
467            if eta <= 0.0 {
468                continue; // Skip degenerate cases
469            }
470
471            // Estimate objective decrease
472            let grad_diff = g_j - g_i; // Correct gradient difference
473            let decrease = (grad_diff * grad_diff) / eta;
474
475            if decrease > best_decrease {
476                best_decrease = decrease;
477                best_j = Some(j);
478            }
479        }
480
481        // If no j found with positive decrease, fall back to first-order selection
482        // This can happen at initialization when all pairs have the same gradient
483        if best_j.is_none() {
484            self.select_working_set_first_order()
485        } else {
486            best_j.map(|j| (i, j))
487        }
488    }
489
490    /// Estimate objective function decrease for pair (i, j)
491    #[allow(dead_code)] // intentionally deferred: objective heuristic not yet used in selection
492    fn estimate_objective_decrease(&self, i: usize, j: usize) -> Float {
493        let y_i = self.y[i];
494        let y_j = self.y[j];
495        let f_i = self.f[i];
496        let f_j = self.f[j];
497
498        // Simplified estimate based on gradient difference
499        let grad_diff = (y_i * f_i - 1.0).abs() + (y_j * f_j - 1.0).abs();
500        let f_diff = (f_i - f_j).abs();
501
502        grad_diff * f_diff
503    }
504
505    /// Check if sample i violates KKT conditions
506    fn violates_kkt(&self, i: usize) -> bool {
507        let alpha_i = self.alpha[i];
508        let y_i = self.y[i];
509        let f_i = self.f[i];
510
511        let tol = self.config.tol;
512
513        if alpha_i < tol {
514            // alpha_i = 0, should have y_i * f_i >= 1
515            y_i * f_i < 1.0 - tol
516        } else if alpha_i > self.config.c - tol {
517            // alpha_i = C, should have y_i * f_i <= 1
518            y_i * f_i > 1.0 + tol
519        } else {
520            // 0 < alpha_i < C, should have y_i * f_i = 1
521            (y_i * f_i - 1.0).abs() > tol
522        }
523    }
524
525    /// Take optimization step for pair (i, j)
526    fn take_step(&mut self, i: usize, j: usize) -> Result<bool> {
527        if i == j {
528            return Ok(false);
529        }
530
531        let alpha_i_old = self.alpha[i];
532        let alpha_j_old = self.alpha[j];
533        let y_i = self.y[i];
534        let y_j = self.y[j];
535
536        // Compute bounds L and H
537        let (l, h) = if y_i == y_j {
538            let gamma = alpha_i_old + alpha_j_old;
539            ((gamma - self.config.c).max(0.0), gamma.min(self.config.c))
540        } else {
541            let gamma = alpha_i_old - alpha_j_old;
542            (
543                (-gamma).max(0.0),
544                (self.config.c - gamma).min(self.config.c),
545            )
546        };
547
548        if (l - h).abs() < 1e-10 {
549            return Ok(false);
550        }
551
552        // Compute kernel values with caching
553        let k_ii = self.get_kernel_value(i, i);
554        let k_jj = self.get_kernel_value(j, j);
555        let k_ij = self.get_kernel_value(i, j);
556
557        // Compute second derivative
558        let eta = k_ii + k_jj - 2.0 * k_ij;
559
560        let alpha_j_new = if eta > 0.0 {
561            // Normal case: optimize along eta
562            let alpha_j_unc = alpha_j_old + y_j * (self.f[i] - self.f[j]) / eta;
563            alpha_j_unc.max(l).min(h)
564        } else {
565            // Degenerate case: evaluate objective at endpoints
566            let f1 = self.objective_at_endpoint(i, j, l)?;
567            let f2 = self.objective_at_endpoint(i, j, h)?;
568
569            if f1 < f2 - 1e-10 {
570                l
571            } else if f2 < f1 - 1e-10 {
572                h
573            } else {
574                alpha_j_old
575            }
576        };
577
578        if (alpha_j_new - alpha_j_old).abs() < 1e-10 {
579            return Ok(false);
580        }
581
582        // Compute new alpha_i
583        let alpha_i_new = alpha_i_old + y_i * y_j * (alpha_j_old - alpha_j_new);
584
585        // Update alpha values
586        self.alpha[i] = alpha_i_new;
587        self.alpha[j] = alpha_j_new;
588
589        // Update cached f values
590        self.update_f_values(i, j, alpha_i_old, alpha_j_old)?;
591
592        Ok(true)
593    }
594
595    /// Compute objective function value at endpoint
596    fn objective_at_endpoint(&self, i: usize, j: usize, alpha_j: Float) -> Result<Float> {
597        let y_i = self.y[i];
598        let y_j = self.y[j];
599        let alpha_i_old = self.alpha[i];
600        let alpha_j_old = self.alpha[j];
601
602        let alpha_i = alpha_i_old + y_i * y_j * (alpha_j_old - alpha_j);
603
604        // This is a simplified objective computation
605        // In practice, you'd want to compute the full dual objective
606        let k_ii = self.get_kernel_value(i, i);
607        let k_jj = self.get_kernel_value(j, j);
608        let k_ij = self.get_kernel_value(i, j);
609
610        Ok(alpha_i * self.f[i] + alpha_j * self.f[j]
611            - 0.5
612                * (alpha_i * alpha_i * k_ii
613                    + alpha_j * alpha_j * k_jj
614                    + 2.0 * alpha_i * alpha_j * k_ij))
615    }
616
617    /// Update cached f values after alpha update
618    fn update_f_values(
619        &mut self,
620        i: usize,
621        j: usize,
622        alpha_i_old: Float,
623        alpha_j_old: Float,
624    ) -> Result<()> {
625        let delta_alpha_i = self.alpha[i] - alpha_i_old;
626        let delta_alpha_j = self.alpha[j] - alpha_j_old;
627
628        // Skip update if both deltas are near zero
629        if delta_alpha_i.abs() < 1e-10 && delta_alpha_j.abs() < 1e-10 {
630            return Ok(());
631        }
632
633        // Update f values for active samples to improve efficiency
634        for &k in &self.active_set {
635            let k_ik = self.get_kernel_value(i, k);
636            let k_jk = self.get_kernel_value(j, k);
637            self.f[k] += self.y[i] * delta_alpha_i * k_ik + self.y[j] * delta_alpha_j * k_jk;
638        }
639
640        // Also update inactive samples if shrinking is disabled
641        if !self.config.shrinking {
642            for &k in &self.inactive_set {
643                let k_ik = self.get_kernel_value(i, k);
644                let k_jk = self.get_kernel_value(j, k);
645                self.f[k] += self.y[i] * delta_alpha_i * k_ik + self.y[j] * delta_alpha_j * k_jk;
646            }
647        }
648
649        Ok(())
650    }
651
652    /// Check convergence using KKT conditions
653    fn check_convergence(&self) -> bool {
654        for &i in &self.active_set {
655            if self.violates_kkt(i) {
656                return false;
657            }
658        }
659        true
660    }
661
662    /// Find support vector indices
663    fn find_support_vectors(&self) -> Vec<usize> {
664        let mut support_indices = Vec::new();
665
666        for i in 0..self.alpha.len() {
667            if self.alpha[i] > 1e-10 {
668                support_indices.push(i);
669            }
670        }
671
672        support_indices
673    }
674
675    /// Update bias term using support vectors
676    fn update_bias(&mut self, support_indices: &[usize]) -> Result<()> {
677        if support_indices.is_empty() {
678            self.b = 0.0;
679            return Ok(());
680        }
681
682        // Use support vectors that are not at bounds
683        let mut bias_sum = 0.0;
684        let mut n_free = 0;
685
686        for &i in support_indices {
687            let alpha_i = self.alpha[i];
688            if alpha_i > 1e-10 && alpha_i < self.config.c - 1e-10 {
689                // Free support vector: 0 < alpha < C
690                // For margin support vectors: y[i] * (sum(alpha[j] * y[j] * K(x[i], x[j])) + b) = 1
691                // Since f[i] = sum(alpha[j] * y[j] * K(x[i], x[j])) - y[i], we have:
692                // sum(alpha[j] * y[j] * K(x[i], x[j])) = f[i] + y[i]
693                // Therefore: y[i] * (f[i] + y[i] + b) = 1
694                //           y[i] * f[i] + 1 + y[i] * b = 1  (since y[i]^2 = 1)
695                //           y[i] * (f[i] + b) = 0
696                //           b = -f[i]
697                bias_sum += -self.f[i];
698                n_free += 1;
699            }
700        }
701
702        if n_free > 0 {
703            self.b = bias_sum / n_free as Float;
704        } else {
705            // Use all support vectors
706            bias_sum = 0.0;
707            for &i in support_indices {
708                bias_sum += -self.f[i];
709            }
710            self.b = bias_sum / support_indices.len() as Float;
711        }
712
713        Ok(())
714    }
715
716    /// Get kernel value with caching
717    fn get_kernel_value(&self, i: usize, j: usize) -> Float {
718        let mut cache = self.kernel_cache.borrow_mut();
719        if let Some(value) = cache.get(i, j) {
720            value
721        } else {
722            // Use views directly without allocating - major performance improvement
723            let value = self.kernel.compute(self.x.row(i), self.x.row(j));
724            cache.insert(i, j, value);
725            value
726        }
727    }
728
729    /// Apply shrinking heuristic to reduce problem size
730    fn apply_shrinking(&mut self) {
731        if !self.config.shrinking {
732            return;
733        }
734
735        let mut new_active = Vec::new();
736        let tol = self.config.tol;
737
738        for &i in &self.active_set {
739            let alpha_i = self.alpha[i];
740            let y_i = self.y[i];
741            let f_i = self.f[i];
742
743            // Keep samples that are likely to change
744            let should_keep = if alpha_i < tol {
745                // alpha = 0: keep if violates upper bound
746                y_i * f_i < 1.0 + tol
747            } else if alpha_i > self.config.c - tol {
748                // alpha = C: keep if violates lower bound
749                y_i * f_i > 1.0 - tol
750            } else {
751                // 0 < alpha < C: always keep (support vectors)
752                true
753            };
754
755            if should_keep {
756                new_active.push(i);
757            } else {
758                self.inactive_set.push(i);
759            }
760        }
761
762        self.active_set = new_active;
763    }
764
765    /// Compute convergence measure
766    fn compute_convergence_measure(&self) -> Float {
767        let mut max_violation: Float = 0.0;
768
769        for &i in &self.active_set {
770            let alpha_i = self.alpha[i];
771            let y_i = self.y[i];
772            let f_i = self.f[i];
773
774            let violation = if alpha_i < self.config.tol {
775                (1.0 - y_i * f_i).max(0.0)
776            } else if alpha_i > self.config.c - self.config.tol {
777                (y_i * f_i - 1.0).max(0.0)
778            } else {
779                (y_i * f_i - 1.0).abs()
780            };
781
782            max_violation = max_violation.max(violation);
783        }
784
785        max_violation
786    }
787
788    /// Check if early stopping should be applied
789    fn should_early_stop(&self) -> bool {
790        if self.convergence_history.len() < 3 {
791            return false;
792        }
793
794        let recent = &self.convergence_history[self.convergence_history.len() - 3..];
795        let improvement = recent[0] - recent[2];
796
797        improvement < self.config.early_stopping_tol
798    }
799
800    /// Compute dual objective function value
801    fn compute_objective(&mut self) -> Float {
802        let mut objective = 0.0;
803
804        // Sum of alpha_i
805        for &alpha in self.alpha.iter() {
806            objective += alpha;
807        }
808
809        // Subtract 0.5 * alpha^T K alpha
810        for i in 0..self.alpha.len() {
811            for j in 0..self.alpha.len() {
812                if self.alpha[i] > 1e-10 && self.alpha[j] > 1e-10 {
813                    let k_ij = self.get_kernel_value(i, j);
814                    objective -= 0.5 * self.alpha[i] * self.alpha[j] * self.y[i] * self.y[j] * k_ij;
815                }
816            }
817        }
818
819        objective
820    }
821}
822
823#[allow(non_snake_case)]
824#[cfg(test)]
825mod tests {
826    use super::*;
827    use crate::kernels::LinearKernel;
828    use approx::assert_abs_diff_eq;
829    use scirs2_core::ndarray::array;
830
831    #[test]
832    fn test_smo_linear_separable() {
833        let x = array![[1.0, 1.0], [2.0, 2.0], [-1.0, -1.0], [-2.0, -2.0],];
834        let y = array![1.0, 1.0, -1.0, -1.0];
835
836        let config = SmoConfig {
837            c: 1.0,
838            tol: 1e-3,
839            max_iter: 200,
840            working_set_strategy: WorkingSetStrategy::SecondOrder,
841            ..Default::default()
842        };
843
844        let mut solver = SmoSolver::new(config, LinearKernel::new());
845        let result = solver.solve(&x, &y).expect("solver should succeed");
846
847        // Should converge for linearly separable data
848        assert!(result.converged || result.n_iter < 1000);
849        assert!(!result.support_indices.is_empty());
850        assert!(result.cache_hit_ratio >= 0.0);
851
852        // Check that alpha values are non-negative and sum correctly
853        for &alpha in result.alpha.iter() {
854            assert!(alpha >= -1e-10);
855        }
856    }
857
858    #[test]
859    fn test_working_set_strategies() {
860        let x = array![[1.0, 1.0], [2.0, 2.0], [-1.0, -1.0], [-2.0, -2.0],];
861        let y = array![1.0, 1.0, -1.0, -1.0];
862
863        for strategy in [
864            WorkingSetStrategy::FirstOrder,
865            WorkingSetStrategy::SecondOrder,
866            WorkingSetStrategy::Mixed,
867        ] {
868            let config = SmoConfig {
869                c: 1.0,
870                tol: 1e-2,     // More tolerant for testing
871                max_iter: 500, // More iterations for FirstOrder strategy
872                working_set_strategy: strategy,
873                ..Default::default()
874            };
875
876            let mut solver = SmoSolver::new(config, LinearKernel::new());
877            let result = solver.solve(&x, &y).expect("solver should succeed");
878
879            // Allow for the possibility that the algorithm converges without finding many support vectors
880            // This can happen with perfectly separable data and lenient tolerance
881            assert!(
882                result.converged || !result.support_indices.is_empty(),
883                "Strategy {:?} should either converge or find support vectors",
884                strategy
885            );
886        }
887    }
888
889    #[test]
890    fn test_kernel_cache() {
891        let mut cache = KernelCache::new(10);
892
893        // Test miss
894        assert_eq!(cache.get(0, 1), None);
895        assert_eq!(cache.hit_ratio(), 0.0);
896
897        // Test insert and hit
898        cache.insert(0, 1, 5.0);
899        assert_eq!(cache.get(0, 1), Some(5.0));
900        assert_eq!(cache.get(1, 0), Some(5.0)); // Symmetric
901
902        assert!(cache.hit_ratio() > 0.0);
903    }
904
905    #[test]
906    fn test_smo_config_default() {
907        let config = SmoConfig::default();
908        assert_eq!(config.c, 1.0);
909        assert_eq!(config.tol, 1e-3);
910        assert_eq!(config.max_iter, 1000);
911        assert_eq!(config.working_set_strategy, WorkingSetStrategy::SecondOrder);
912        assert!(config.shrinking);
913    }
914
915    #[test]
916    fn test_smo_warm_start() {
917        let x = array![[1.0, 1.0], [2.0, 2.0], [-1.0, -1.0], [-2.0, -2.0],];
918        let y = array![1.0, 1.0, -1.0, -1.0];
919
920        let config = SmoConfig {
921            c: 1.0,
922            tol: 1e-2,
923            max_iter: 50,
924            ..Default::default()
925        };
926
927        // First solve without warm start
928        let mut solver1 = SmoSolver::new(config.clone(), LinearKernel::new());
929        let result1 = solver1.solve(&x, &y).expect("solver should succeed");
930
931        // Now solve with warm start using the previous solution
932        let mut solver2 = SmoSolver::new(config, LinearKernel::new());
933        let result2 = solver2
934            .solve_with_warm_start(&x, &y, Some(&result1.alpha))
935            .expect("operation should succeed");
936
937        // Warm start should converge faster (fewer iterations)
938        assert!(result2.n_iter <= result1.n_iter);
939
940        // Results should be similar
941        for (a1, a2) in result1.alpha.iter().zip(result2.alpha.iter()) {
942            assert_abs_diff_eq!(a1, a2, epsilon = 1e-1);
943        }
944    }
945
946    #[test]
947    fn test_smo_warm_start_invalid_alpha() {
948        let x = array![[1.0, 1.0], [2.0, 2.0]];
949        let y = array![1.0, -1.0];
950
951        let config = SmoConfig::default();
952        let mut solver = SmoSolver::new(config, LinearKernel::new());
953
954        // Test with negative alpha (invalid)
955        let invalid_alpha = array![-0.1, 0.5];
956        let result = solver.solve_with_warm_start(&x, &y, Some(&invalid_alpha));
957        assert!(result.is_err());
958
959        // Test with alpha > C (invalid)
960        let invalid_alpha = array![0.5, 2.0]; // C = 1.0 by default
961        let result = solver.solve_with_warm_start(&x, &y, Some(&invalid_alpha));
962        assert!(result.is_err());
963
964        // Test with wrong size
965        let invalid_alpha = array![0.1]; // Only 1 element, but we have 2 samples
966        let result = solver.solve_with_warm_start(&x, &y, Some(&invalid_alpha));
967        assert!(result.is_err());
968    }
969}