Skip to main content

sklears_svm/
regularization_path.rs

1//! Regularization Path Algorithms for SVM
2//!
3//! This module implements algorithms to compute the full regularization path for
4//! various types of regularized SVMs. The regularization path shows how the solution
5//! changes as the regularization parameter varies, which is useful for model selection
6//! and understanding the trade-off between complexity and fit.
7//!
8//! Algorithms included:
9//! - Lasso Path: For L1-regularized linear SVMs
10//! - Elastic Net Path: For combined L1/L2 regularized SVMs  
11//! - Group Lasso Path: For group-structured regularization
12//! - Adaptive Lasso Path: With adaptive weights for feature selection
13
14use scirs2_core::ndarray::{s, Array1, Array2, ArrayView1};
15use sklears_core::{
16    error::{Result, SklearsError},
17    types::Float,
18};
19use std::collections::HashMap;
20
21/// Regularization path algorithm types
22#[derive(Debug, Clone, PartialEq, Default)]
23pub enum RegularizationPathType {
24    /// Lasso regularization path (L1)
25    #[default]
26    Lasso,
27    /// Elastic Net regularization path (L1 + L2)
28    ElasticNet { l1_ratio: Float },
29    /// Group Lasso regularization path
30    GroupLasso { groups: Vec<Vec<usize>> },
31    /// Adaptive Lasso with feature-specific weights
32    AdaptiveLasso { weights: Array1<Float> },
33    /// Fused Lasso for sequence data
34    FusedLasso,
35}
36
37/// Cross-validation strategy for path selection
38#[derive(Debug, Clone, PartialEq)]
39pub enum CrossValidationStrategy {
40    /// K-fold cross-validation
41    KFold { k: usize },
42    /// Leave-one-out cross-validation
43    LeaveOneOut,
44    /// Time series split for temporal data
45    TimeSeriesSplit { n_splits: usize },
46    /// Stratified K-fold for classification
47    StratifiedKFold { k: usize },
48}
49
50impl Default for CrossValidationStrategy {
51    fn default() -> Self {
52        CrossValidationStrategy::KFold { k: 5 }
53    }
54}
55
56/// Configuration for regularization path computation
57#[derive(Debug, Clone)]
58pub struct RegularizationPathConfig {
59    /// Type of regularization path
60    pub path_type: RegularizationPathType,
61    /// Number of lambda values to compute
62    pub n_lambdas: usize,
63    /// Minimum lambda value (relative to lambda_max)
64    pub lambda_min_ratio: Float,
65    /// Custom lambda values (if provided, overrides n_lambdas)
66    pub lambdas: Option<Array1<Float>>,
67    /// Tolerance for convergence
68    pub tol: Float,
69    /// Maximum number of iterations per lambda
70    pub max_iter: usize,
71    /// Whether to fit an intercept
72    pub fit_intercept: bool,
73    /// Cross-validation strategy
74    pub cv_strategy: CrossValidationStrategy,
75    /// Whether to standardize features
76    pub standardize: bool,
77    /// Early stopping for path computation
78    pub early_stopping: bool,
79    /// Minimum improvement for early stopping
80    pub min_improvement: Float,
81    /// Verbose output
82    pub verbose: bool,
83}
84
85impl Default for RegularizationPathConfig {
86    fn default() -> Self {
87        Self {
88            path_type: RegularizationPathType::default(),
89            n_lambdas: 100,
90            lambda_min_ratio: 1e-4,
91            lambdas: None,
92            tol: 1e-4,
93            max_iter: 1000,
94            fit_intercept: true,
95            cv_strategy: CrossValidationStrategy::default(),
96            standardize: true,
97            early_stopping: true,
98            min_improvement: 1e-6,
99            verbose: false,
100        }
101    }
102}
103
104/// Results of regularization path computation
105#[derive(Debug, Clone)]
106pub struct RegularizationPathResult {
107    /// Lambda values used
108    pub lambdas: Array1<Float>,
109    /// Coefficient paths (n_lambdas × n_features)
110    pub coef_path: Array2<Float>,
111    /// Intercept paths (n_lambdas)
112    pub intercept_path: Array1<Float>,
113    /// Cross-validation scores (n_lambdas)
114    pub cv_scores: Array1<Float>,
115    /// Standard errors of CV scores (n_lambdas)
116    pub cv_scores_std: Array1<Float>,
117    /// Number of non-zero coefficients at each lambda
118    pub n_nonzero: Array1<usize>,
119    /// Indices of selected features at each lambda
120    pub active_features: Vec<Vec<usize>>,
121    /// Best lambda value (based on CV)
122    pub best_lambda: Float,
123    /// Index of best lambda
124    pub best_lambda_idx: usize,
125    /// Lambda at 1 standard error rule
126    pub lambda_1se: Float,
127    /// Index of lambda at 1 standard error rule
128    pub lambda_1se_idx: usize,
129}
130
131/// Regularization Path Solver for SVMs
132#[derive(Debug)]
133pub struct RegularizationPathSolver {
134    config: RegularizationPathConfig,
135}
136
137impl Default for RegularizationPathSolver {
138    fn default() -> Self {
139        Self::new(RegularizationPathConfig::default())
140    }
141}
142
143impl RegularizationPathSolver {
144    /// Create a new regularization path solver
145    pub fn new(config: RegularizationPathConfig) -> Self {
146        Self { config }
147    }
148
149    /// Fit regularization path
150    pub fn fit_path(
151        &self,
152        x: &Array2<Float>,
153        y: &Array1<Float>,
154    ) -> Result<RegularizationPathResult> {
155        let n_samples = x.nrows();
156        let n_features = x.ncols();
157
158        if n_samples != y.len() {
159            return Err(SklearsError::InvalidInput(
160                "Shape mismatch: X and y must have the same number of samples".to_string(),
161            ));
162        }
163
164        // Standardize features if requested
165        let (x_processed, _feature_means, feature_stds) = if self.config.standardize {
166            self.standardize_features(x)?
167        } else {
168            (
169                x.clone(),
170                Array1::zeros(n_features),
171                Array1::ones(n_features),
172            )
173        };
174
175        // Center targets
176        let y_mean = if self.config.fit_intercept {
177            y.mean().unwrap_or(0.0)
178        } else {
179            0.0
180        };
181        let y_centered = y.mapv(|val| val - y_mean);
182
183        // Compute lambda sequence
184        let mut lambdas = if let Some(custom_lambdas) = &self.config.lambdas {
185            custom_lambdas.clone()
186        } else {
187            self.compute_lambda_sequence(&x_processed, &y_centered)?
188        };
189
190        let n_lambdas = lambdas.len();
191
192        // Initialize path storage
193        let mut coef_path = Array2::zeros((n_lambdas, n_features));
194        let mut intercept_path = Array1::zeros(n_lambdas);
195        let mut cv_scores = Array1::zeros(n_lambdas);
196        let mut cv_scores_std = Array1::zeros(n_lambdas);
197        let mut n_nonzero = Array1::zeros(n_lambdas);
198        let mut active_features = Vec::with_capacity(n_lambdas);
199
200        // Initial coefficient vector
201        let mut coef = Array1::zeros(n_features);
202
203        // Compute path
204        for (i, &lambda) in lambdas.iter().enumerate() {
205            if self.config.verbose && i % 10 == 0 {
206                println!(
207                    "Computing path for lambda {}/{}: {:.6}",
208                    i + 1,
209                    n_lambdas,
210                    lambda
211                );
212            }
213
214            // Warm start from previous solution
215            let (new_coef, intercept) =
216                self.solve_for_lambda(&x_processed, &y_centered, lambda, &coef, y_mean)?;
217
218            coef = new_coef.clone();
219
220            // Store results
221            coef_path.row_mut(i).assign(&new_coef);
222            intercept_path[i] = intercept;
223
224            // Count non-zero coefficients
225            let nonzero_count = new_coef
226                .iter()
227                .filter(|&&x| x.abs() > self.config.tol)
228                .count();
229            n_nonzero[i] = nonzero_count;
230
231            // Track active features
232            let active: Vec<usize> = new_coef
233                .iter()
234                .enumerate()
235                .filter(|(_, &x)| x.abs() > self.config.tol)
236                .map(|(idx, _)| idx)
237                .collect();
238            active_features.push(active);
239
240            // Cross-validation for this lambda
241            let (cv_score, cv_std) =
242                self.cross_validate_lambda(&x_processed, &y_centered, lambda, y_mean)?;
243            cv_scores[i] = cv_score;
244            cv_scores_std[i] = cv_std;
245
246            // Early stopping check
247            if self.config.early_stopping && i > 10 {
248                let recent_improvement = if i >= 5 {
249                    let recent_avg = cv_scores
250                        .slice(s![i - 4..=i])
251                        .mean()
252                        .expect("mean should not fail on non-empty array");
253                    let prev_avg = cv_scores
254                        .slice(s![i - 9..=i - 5])
255                        .mean()
256                        .expect("mean should not fail on non-empty array");
257                    recent_avg - prev_avg
258                } else {
259                    Float::INFINITY
260                };
261
262                if recent_improvement.abs() < self.config.min_improvement {
263                    if self.config.verbose {
264                        println!("Early stopping at lambda index {i}");
265                    }
266                    // Truncate arrays
267                    let actual_n_lambdas = i + 1;
268                    lambdas = lambdas.slice(s![..actual_n_lambdas]).to_owned();
269                    coef_path = coef_path.slice(s![..actual_n_lambdas, ..]).to_owned();
270                    intercept_path = intercept_path.slice(s![..actual_n_lambdas]).to_owned();
271                    cv_scores = cv_scores.slice(s![..actual_n_lambdas]).to_owned();
272                    cv_scores_std = cv_scores_std.slice(s![..actual_n_lambdas]).to_owned();
273                    n_nonzero = n_nonzero.slice(s![..actual_n_lambdas]).to_owned();
274                    active_features.truncate(actual_n_lambdas);
275                    break;
276                }
277            }
278        }
279
280        // Reverse standardization for coefficients
281        if self.config.standardize {
282            for i in 0..coef_path.nrows() {
283                for j in 0..n_features {
284                    if feature_stds[j] > 1e-10 {
285                        coef_path[[i, j]] /= feature_stds[j];
286                    }
287                }
288            }
289        }
290
291        // Find best lambda (minimum CV error)
292        let best_lambda_idx = cv_scores
293            .iter()
294            .enumerate()
295            .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
296            .map(|(idx, _)| idx)
297            .unwrap_or(0);
298        let best_lambda = lambdas[best_lambda_idx];
299
300        // Find lambda at 1 standard error rule
301        let best_score = cv_scores[best_lambda_idx];
302        let best_std = cv_scores_std[best_lambda_idx];
303        let threshold = best_score + best_std;
304
305        let lambda_1se_idx = (0..lambdas.len())
306            .find(|&i| cv_scores[i] <= threshold && lambdas[i] >= best_lambda)
307            .unwrap_or(best_lambda_idx);
308        let lambda_1se = lambdas[lambda_1se_idx];
309
310        Ok(RegularizationPathResult {
311            lambdas,
312            coef_path,
313            intercept_path,
314            cv_scores,
315            cv_scores_std,
316            n_nonzero,
317            active_features,
318            best_lambda,
319            best_lambda_idx,
320            lambda_1se,
321            lambda_1se_idx,
322        })
323    }
324
325    /// Standardize features
326    fn standardize_features(
327        &self,
328        x: &Array2<Float>,
329    ) -> Result<(Array2<Float>, Array1<Float>, Array1<Float>)> {
330        let n_features = x.ncols();
331        let mut means = Array1::zeros(n_features);
332        let mut stds = Array1::ones(n_features);
333
334        // Compute means
335        for j in 0..n_features {
336            means[j] = x.column(j).mean().unwrap_or(0.0);
337        }
338
339        // Compute standard deviations
340        for j in 0..n_features {
341            let variance = x
342                .column(j)
343                .iter()
344                .map(|&val| (val - means[j]).powi(2))
345                .sum::<Float>()
346                / (x.nrows() - 1) as Float;
347            stds[j] = variance.sqrt().max(1e-10);
348        }
349
350        // Standardize
351        let mut x_std = x.clone();
352        for i in 0..x.nrows() {
353            for j in 0..n_features {
354                x_std[[i, j]] = (x_std[[i, j]] - means[j]) / stds[j];
355            }
356        }
357
358        Ok((x_std, means, stds))
359    }
360
361    /// Compute lambda sequence
362    fn compute_lambda_sequence(
363        &self,
364        x: &Array2<Float>,
365        y: &Array1<Float>,
366    ) -> Result<Array1<Float>> {
367        // Compute lambda_max (smallest lambda that gives all-zero solution)
368        let lambda_max = match &self.config.path_type {
369            RegularizationPathType::Lasso | RegularizationPathType::AdaptiveLasso { .. } => {
370                self.compute_lasso_lambda_max(x, y)?
371            }
372            RegularizationPathType::ElasticNet { l1_ratio } => {
373                self.compute_lasso_lambda_max(x, y)? / l1_ratio
374            }
375            RegularizationPathType::GroupLasso { .. } => {
376                self.compute_group_lasso_lambda_max(x, y)?
377            }
378            RegularizationPathType::FusedLasso => self.compute_fused_lasso_lambda_max(x, y)?,
379        };
380
381        let lambda_min = lambda_max * self.config.lambda_min_ratio;
382
383        // Create log-spaced sequence
384        let mut lambdas = Array1::zeros(self.config.n_lambdas);
385        let log_max = lambda_max.ln();
386        let log_min = lambda_min.ln();
387        let step = (log_max - log_min) / (self.config.n_lambdas - 1) as Float;
388
389        for i in 0..self.config.n_lambdas {
390            lambdas[i] = (log_max - i as Float * step).exp();
391        }
392
393        Ok(lambdas)
394    }
395
396    /// Compute lambda_max for Lasso
397    fn compute_lasso_lambda_max(&self, x: &Array2<Float>, y: &Array1<Float>) -> Result<Float> {
398        let mut max_correlation: Float = 0.0;
399
400        for j in 0..x.ncols() {
401            let correlation = x
402                .column(j)
403                .iter()
404                .zip(y.iter())
405                .map(|(&xi, &yi)| xi * yi)
406                .sum::<Float>()
407                .abs()
408                / x.nrows() as Float;
409
410            max_correlation = max_correlation.max(correlation);
411        }
412
413        Ok(max_correlation)
414    }
415
416    /// Compute lambda_max for Group Lasso
417    fn compute_group_lasso_lambda_max(
418        &self,
419        x: &Array2<Float>,
420        y: &Array1<Float>,
421    ) -> Result<Float> {
422        if let RegularizationPathType::GroupLasso { groups } = &self.config.path_type {
423            let mut max_group_norm: Float = 0.0;
424
425            for group in groups {
426                let mut group_norm = 0.0;
427                for &feature_idx in group {
428                    if feature_idx < x.ncols() {
429                        let correlation = x
430                            .column(feature_idx)
431                            .iter()
432                            .zip(y.iter())
433                            .map(|(&xi, &yi)| xi * yi)
434                            .sum::<Float>()
435                            / x.nrows() as Float;
436                        group_norm += correlation * correlation;
437                    }
438                }
439                group_norm = group_norm.sqrt();
440                max_group_norm = max_group_norm.max(group_norm);
441            }
442
443            Ok(max_group_norm)
444        } else {
445            Err(SklearsError::InvalidInput(
446                "Invalid path type for Group Lasso lambda_max computation".to_string(),
447            ))
448        }
449    }
450
451    /// Compute lambda_max for Fused Lasso
452    fn compute_fused_lasso_lambda_max(
453        &self,
454        x: &Array2<Float>,
455        y: &Array1<Float>,
456    ) -> Result<Float> {
457        let lasso_max = self.compute_lasso_lambda_max(x, y)?;
458
459        // For fused lasso, also consider difference penalties
460        let mut max_diff_correlation: Float = 0.0;
461        for j in 0..(x.ncols() - 1) {
462            let diff_feature: Array1<Float> = x
463                .rows()
464                .into_iter()
465                .map(|row| row[j + 1] - row[j])
466                .collect();
467
468            let correlation = diff_feature
469                .iter()
470                .zip(y.iter())
471                .map(|(&xi, &yi)| xi * yi)
472                .sum::<Float>()
473                .abs()
474                / x.nrows() as Float;
475
476            max_diff_correlation = max_diff_correlation.max(correlation);
477        }
478
479        Ok(lasso_max.max(max_diff_correlation))
480    }
481
482    /// Solve for a specific lambda value
483    fn solve_for_lambda(
484        &self,
485        x: &Array2<Float>,
486        y: &Array1<Float>,
487        lambda: Float,
488        initial_coef: &Array1<Float>,
489        y_mean: Float,
490    ) -> Result<(Array1<Float>, Float)> {
491        match &self.config.path_type {
492            RegularizationPathType::Lasso => self.solve_lasso(x, y, lambda, initial_coef, y_mean),
493            RegularizationPathType::ElasticNet { l1_ratio } => {
494                self.solve_elastic_net(x, y, lambda, *l1_ratio, initial_coef, y_mean)
495            }
496            RegularizationPathType::GroupLasso { groups } => {
497                self.solve_group_lasso(x, y, lambda, groups, initial_coef, y_mean)
498            }
499            RegularizationPathType::AdaptiveLasso { weights } => {
500                self.solve_adaptive_lasso(x, y, lambda, weights, initial_coef, y_mean)
501            }
502            RegularizationPathType::FusedLasso => {
503                self.solve_fused_lasso(x, y, lambda, initial_coef, y_mean)
504            }
505        }
506    }
507
508    /// Solve Lasso for a specific lambda using coordinate descent
509    fn solve_lasso(
510        &self,
511        x: &Array2<Float>,
512        y: &Array1<Float>,
513        lambda: Float,
514        initial_coef: &Array1<Float>,
515        y_mean: Float,
516    ) -> Result<(Array1<Float>, Float)> {
517        let n_samples = x.nrows();
518        let n_features = x.ncols();
519        let mut coef = initial_coef.clone();
520        let mut intercept = y_mean;
521
522        // Precompute X^T X diagonal for efficiency
523        let mut xtx_diag = Array1::zeros(n_features);
524        for j in 0..n_features {
525            xtx_diag[j] = x.column(j).iter().map(|&val| val * val).sum::<Float>();
526        }
527
528        // Coordinate descent
529        for _ in 0..self.config.max_iter {
530            let mut converged = true;
531
532            for j in 0..n_features {
533                let old_coef_j = coef[j];
534
535                // Compute residual without feature j
536                let mut residual_sum = 0.0;
537                for i in 0..n_samples {
538                    let mut prediction = intercept;
539                    for k in 0..n_features {
540                        if k != j {
541                            prediction += coef[k] * x[[i, k]];
542                        }
543                    }
544                    residual_sum += x[[i, j]] * (y[i] - prediction);
545                }
546
547                // Soft thresholding
548                let threshold = lambda * n_samples as Float;
549                if residual_sum > threshold {
550                    coef[j] = (residual_sum - threshold) / xtx_diag[j];
551                } else if residual_sum < -threshold {
552                    coef[j] = (residual_sum + threshold) / xtx_diag[j];
553                } else {
554                    coef[j] = 0.0;
555                }
556
557                if (coef[j] - old_coef_j).abs() > self.config.tol {
558                    converged = false;
559                }
560            }
561
562            // Update intercept
563            if self.config.fit_intercept {
564                let mut residual_sum = 0.0;
565                for i in 0..n_samples {
566                    let mut prediction = 0.0;
567                    for k in 0..n_features {
568                        prediction += coef[k] * x[[i, k]];
569                    }
570                    residual_sum += y[i] - prediction;
571                }
572                intercept = residual_sum / n_samples as Float;
573            }
574
575            if converged {
576                break;
577            }
578        }
579
580        Ok((coef, intercept))
581    }
582
583    /// Solve Elastic Net for a specific lambda
584    fn solve_elastic_net(
585        &self,
586        x: &Array2<Float>,
587        y: &Array1<Float>,
588        lambda: Float,
589        l1_ratio: Float,
590        initial_coef: &Array1<Float>,
591        y_mean: Float,
592    ) -> Result<(Array1<Float>, Float)> {
593        let n_samples = x.nrows();
594        let n_features = x.ncols();
595        let mut coef = initial_coef.clone();
596        let mut intercept = y_mean;
597
598        let l1_penalty = lambda * l1_ratio;
599        let l2_penalty = lambda * (1.0 - l1_ratio);
600
601        // Precompute X^T X diagonal
602        let mut xtx_diag = Array1::zeros(n_features);
603        for j in 0..n_features {
604            xtx_diag[j] = x.column(j).iter().map(|&val| val * val).sum::<Float>()
605                + l2_penalty * n_samples as Float;
606        }
607
608        // Coordinate descent
609        for _ in 0..self.config.max_iter {
610            let mut converged = true;
611
612            for j in 0..n_features {
613                let old_coef_j = coef[j];
614
615                // Compute residual without feature j
616                let mut residual_sum = 0.0;
617                for i in 0..n_samples {
618                    let mut prediction = intercept;
619                    for k in 0..n_features {
620                        if k != j {
621                            prediction += coef[k] * x[[i, k]];
622                        }
623                    }
624                    residual_sum += x[[i, j]] * (y[i] - prediction);
625                }
626
627                // Soft thresholding with L2 penalty
628                let threshold = l1_penalty * n_samples as Float;
629                if residual_sum > threshold {
630                    coef[j] = (residual_sum - threshold) / xtx_diag[j];
631                } else if residual_sum < -threshold {
632                    coef[j] = (residual_sum + threshold) / xtx_diag[j];
633                } else {
634                    coef[j] = 0.0;
635                }
636
637                if (coef[j] - old_coef_j).abs() > self.config.tol {
638                    converged = false;
639                }
640            }
641
642            // Update intercept
643            if self.config.fit_intercept {
644                let mut residual_sum = 0.0;
645                for i in 0..n_samples {
646                    let mut prediction = 0.0;
647                    for k in 0..n_features {
648                        prediction += coef[k] * x[[i, k]];
649                    }
650                    residual_sum += y[i] - prediction;
651                }
652                intercept = residual_sum / n_samples as Float;
653            }
654
655            if converged {
656                break;
657            }
658        }
659
660        Ok((coef, intercept))
661    }
662
663    /// Solve Group Lasso for a specific lambda
664    fn solve_group_lasso(
665        &self,
666        x: &Array2<Float>,
667        y: &Array1<Float>,
668        lambda: Float,
669        groups: &[Vec<usize>],
670        initial_coef: &Array1<Float>,
671        y_mean: Float,
672    ) -> Result<(Array1<Float>, Float)> {
673        let n_samples = x.nrows();
674        let n_features = x.ncols();
675        let mut coef = initial_coef.clone();
676        let intercept = y_mean;
677
678        // Group coordinate descent
679        for _ in 0..self.config.max_iter {
680            let mut converged = true;
681
682            for group in groups {
683                let group_size = group.len();
684                let mut old_group_coef = Array1::zeros(group_size);
685                for (idx, &feature_idx) in group.iter().enumerate() {
686                    if feature_idx < n_features {
687                        old_group_coef[idx] = coef[feature_idx];
688                    }
689                }
690
691                // Compute group gradient
692                let mut group_gradient = Array1::zeros(group_size);
693                for i in 0..n_samples {
694                    let mut prediction = intercept;
695                    for k in 0..n_features {
696                        prediction += coef[k] * x[[i, k]];
697                    }
698                    let residual = y[i] - prediction;
699
700                    for (idx, &feature_idx) in group.iter().enumerate() {
701                        if feature_idx < n_features {
702                            group_gradient[idx] +=
703                                x[[i, feature_idx]] * residual / n_samples as Float;
704                        }
705                    }
706                }
707
708                // Group soft thresholding
709                let group_norm = group_gradient
710                    .iter()
711                    .map(|&x: &Float| x * x)
712                    .sum::<Float>()
713                    .sqrt();
714                if group_norm > lambda {
715                    let shrinkage_factor = (1.0 - lambda / group_norm).max(0.0);
716                    for (idx, &feature_idx) in group.iter().enumerate() {
717                        if feature_idx < n_features {
718                            coef[feature_idx] = group_gradient[idx] * shrinkage_factor;
719                            if (coef[feature_idx] - old_group_coef[idx]).abs() > self.config.tol {
720                                converged = false;
721                            }
722                        }
723                    }
724                } else {
725                    // Shrink entire group to zero
726                    for &feature_idx in group {
727                        if feature_idx < n_features {
728                            if coef[feature_idx].abs() > self.config.tol {
729                                converged = false;
730                            }
731                            coef[feature_idx] = 0.0;
732                        }
733                    }
734                }
735            }
736
737            if converged {
738                break;
739            }
740        }
741
742        Ok((coef, intercept))
743    }
744
745    /// Solve Adaptive Lasso for a specific lambda
746    fn solve_adaptive_lasso(
747        &self,
748        x: &Array2<Float>,
749        y: &Array1<Float>,
750        lambda: Float,
751        weights: &Array1<Float>,
752        initial_coef: &Array1<Float>,
753        y_mean: Float,
754    ) -> Result<(Array1<Float>, Float)> {
755        let n_samples = x.nrows();
756        let n_features = x.ncols();
757        let mut coef = initial_coef.clone();
758        let intercept = y_mean;
759
760        // Precompute X^T X diagonal
761        let mut xtx_diag = Array1::zeros(n_features);
762        for j in 0..n_features {
763            xtx_diag[j] = x.column(j).iter().map(|&val| val * val).sum::<Float>();
764        }
765
766        // Coordinate descent with adaptive weights
767        for _ in 0..self.config.max_iter {
768            let mut converged = true;
769
770            for j in 0..n_features {
771                let old_coef_j = coef[j];
772
773                // Compute residual without feature j
774                let mut residual_sum = 0.0;
775                for i in 0..n_samples {
776                    let mut prediction = intercept;
777                    for k in 0..n_features {
778                        if k != j {
779                            prediction += coef[k] * x[[i, k]];
780                        }
781                    }
782                    residual_sum += x[[i, j]] * (y[i] - prediction);
783                }
784
785                // Adaptive soft thresholding
786                let adaptive_threshold = lambda * weights[j] * n_samples as Float;
787                if residual_sum > adaptive_threshold {
788                    coef[j] = (residual_sum - adaptive_threshold) / xtx_diag[j];
789                } else if residual_sum < -adaptive_threshold {
790                    coef[j] = (residual_sum + adaptive_threshold) / xtx_diag[j];
791                } else {
792                    coef[j] = 0.0;
793                }
794
795                if (coef[j] - old_coef_j).abs() > self.config.tol {
796                    converged = false;
797                }
798            }
799
800            if converged {
801                break;
802            }
803        }
804
805        Ok((coef, intercept))
806    }
807
808    /// Solve Fused Lasso for a specific lambda
809    fn solve_fused_lasso(
810        &self,
811        x: &Array2<Float>,
812        y: &Array1<Float>,
813        lambda: Float,
814        initial_coef: &Array1<Float>,
815        y_mean: Float,
816    ) -> Result<(Array1<Float>, Float)> {
817        // For simplicity, implement as standard Lasso
818        // A full implementation would include difference penalties
819        self.solve_lasso(x, y, lambda, initial_coef, y_mean)
820    }
821
822    /// Cross-validate for a specific lambda value
823    fn cross_validate_lambda(
824        &self,
825        x: &Array2<Float>,
826        y: &Array1<Float>,
827        lambda: Float,
828        y_mean: Float,
829    ) -> Result<(Float, Float)> {
830        let n_samples = x.nrows();
831        let mut cv_scores = Vec::new();
832
833        match &self.config.cv_strategy {
834            CrossValidationStrategy::KFold { k } => {
835                let fold_size = n_samples / k;
836
837                for fold in 0..*k {
838                    let test_start = fold * fold_size;
839                    let test_end = if fold == k - 1 {
840                        n_samples
841                    } else {
842                        (fold + 1) * fold_size
843                    };
844
845                    // Create train/test splits
846                    let mut train_indices = Vec::new();
847                    let mut test_indices = Vec::new();
848
849                    for i in 0..n_samples {
850                        if i >= test_start && i < test_end {
851                            test_indices.push(i);
852                        } else {
853                            train_indices.push(i);
854                        }
855                    }
856
857                    if train_indices.is_empty() || test_indices.is_empty() {
858                        continue;
859                    }
860
861                    // Extract train/test data
862                    let x_train = self.extract_rows(x, &train_indices)?;
863                    let y_train = self.extract_elements(y, &train_indices)?;
864                    let x_test = self.extract_rows(x, &test_indices)?;
865                    let y_test = self.extract_elements(y, &test_indices)?;
866
867                    // Train on fold
868                    let zero_coef = Array1::zeros(x.ncols());
869                    let (coef_fold, intercept_fold) =
870                        self.solve_for_lambda(&x_train, &y_train, lambda, &zero_coef, y_mean)?;
871
872                    // Evaluate on test fold
873                    let mut test_error = 0.0;
874                    for i in 0..x_test.nrows() {
875                        let mut prediction = if self.config.fit_intercept {
876                            intercept_fold
877                        } else {
878                            0.0
879                        };
880
881                        for j in 0..x_test.ncols() {
882                            prediction += coef_fold[j] * x_test[[i, j]];
883                        }
884
885                        test_error += (y_test[i] - prediction).powi(2);
886                    }
887                    test_error /= x_test.nrows() as Float;
888                    cv_scores.push(test_error);
889                }
890            }
891            _ => {
892                // For other CV strategies, implement similarly
893                // Using simple holdout for now
894                let n_test = n_samples / 5;
895                let n_train = n_samples - n_test;
896
897                let x_train = x.slice(s![..n_train, ..]).to_owned();
898                let y_train = y.slice(s![..n_train]).to_owned();
899                let x_test = x.slice(s![n_train.., ..]).to_owned();
900                let y_test = y.slice(s![n_train..]).to_owned();
901
902                let zero_coef = Array1::zeros(x.ncols());
903                let (coef_fold, intercept_fold) =
904                    self.solve_for_lambda(&x_train, &y_train, lambda, &zero_coef, y_mean)?;
905
906                let mut test_error = 0.0;
907                for i in 0..x_test.nrows() {
908                    let mut prediction = if self.config.fit_intercept {
909                        intercept_fold
910                    } else {
911                        0.0
912                    };
913
914                    for j in 0..x_test.ncols() {
915                        prediction += coef_fold[j] * x_test[[i, j]];
916                    }
917
918                    test_error += (y_test[i] - prediction).powi(2);
919                }
920                test_error /= x_test.nrows() as Float;
921                cv_scores.push(test_error);
922            }
923        }
924
925        if cv_scores.is_empty() {
926            return Ok((Float::INFINITY, 0.0));
927        }
928
929        let mean_score = cv_scores.iter().sum::<Float>() / cv_scores.len() as Float;
930        let variance = cv_scores
931            .iter()
932            .map(|&score| (score - mean_score).powi(2))
933            .sum::<Float>()
934            / cv_scores.len() as Float;
935        let std_score = variance.sqrt();
936
937        Ok((mean_score, std_score))
938    }
939
940    /// Extract specific rows from a matrix
941    fn extract_rows(&self, matrix: &Array2<Float>, indices: &[usize]) -> Result<Array2<Float>> {
942        let n_features = matrix.ncols();
943        let mut result = Array2::zeros((indices.len(), n_features));
944
945        for (i, &idx) in indices.iter().enumerate() {
946            if idx < matrix.nrows() {
947                result.row_mut(i).assign(&matrix.row(idx));
948            }
949        }
950
951        Ok(result)
952    }
953
954    /// Extract specific elements from an array
955    fn extract_elements(&self, array: &Array1<Float>, indices: &[usize]) -> Result<Array1<Float>> {
956        let mut result = Array1::zeros(indices.len());
957
958        for (i, &idx) in indices.iter().enumerate() {
959            if idx < array.len() {
960                result[i] = array[idx];
961            }
962        }
963
964        Ok(result)
965    }
966}
967
968impl RegularizationPathResult {
969    /// Get coefficient at a specific lambda value
970    pub fn coef_at_lambda(&self, lambda: Float) -> Option<ArrayView1<'_, Float>> {
971        let idx = self
972            .lambdas
973            .iter()
974            .position(|&l| (l - lambda).abs() < 1e-10)?;
975        Some(self.coef_path.row(idx))
976    }
977
978    /// Get the coefficient path for a specific feature
979    pub fn feature_path(&self, feature_idx: usize) -> Option<ArrayView1<'_, Float>> {
980        if feature_idx < self.coef_path.ncols() {
981            Some(self.coef_path.column(feature_idx))
982        } else {
983            None
984        }
985    }
986
987    /// Find the sparsest model within 1 standard error of the best
988    pub fn sparse_model_1se(&self) -> (Float, ArrayView1<'_, Float>) {
989        let lambda = self.lambda_1se;
990        let coef = self.coef_path.row(self.lambda_1se_idx);
991        (lambda, coef)
992    }
993
994    /// Get summary statistics
995    pub fn summary(&self) -> HashMap<String, Float> {
996        let mut summary = HashMap::new();
997
998        summary.insert("n_lambdas".to_string(), self.lambdas.len() as Float);
999        summary.insert("best_lambda".to_string(), self.best_lambda);
1000        summary.insert("lambda_1se".to_string(), self.lambda_1se);
1001        summary.insert(
1002            "best_cv_score".to_string(),
1003            self.cv_scores[self.best_lambda_idx],
1004        );
1005        summary.insert(
1006            "min_nonzero_features".to_string(),
1007            *self.n_nonzero.iter().min().unwrap_or(&0) as Float,
1008        );
1009        summary.insert(
1010            "max_nonzero_features".to_string(),
1011            *self.n_nonzero.iter().max().unwrap_or(&0) as Float,
1012        );
1013
1014        summary
1015    }
1016}
1017
1018#[allow(non_snake_case)]
1019#[cfg(test)]
1020mod tests {
1021    use super::*;
1022    use scirs2_core::ndarray::array;
1023
1024    #[test]
1025    fn test_regularization_path_config() {
1026        let config = RegularizationPathConfig {
1027            path_type: RegularizationPathType::Lasso,
1028            n_lambdas: 50,
1029            lambda_min_ratio: 1e-3,
1030            ..Default::default()
1031        };
1032
1033        assert_eq!(config.n_lambdas, 50);
1034        assert_eq!(config.lambda_min_ratio, 1e-3);
1035        assert!(matches!(config.path_type, RegularizationPathType::Lasso));
1036    }
1037
1038    #[test]
1039    fn test_lambda_sequence_computation() {
1040        let x = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
1041        let y = array![1.0, 2.0, 3.0];
1042
1043        let config = RegularizationPathConfig::default();
1044        let solver = RegularizationPathSolver::new(config);
1045
1046        let lambdas = solver
1047            .compute_lambda_sequence(&x, &y)
1048            .expect("operation should succeed");
1049
1050        assert_eq!(lambdas.len(), 100);
1051        assert!(lambdas[0] > lambdas[lambdas.len() - 1]); // Decreasing sequence
1052
1053        for i in 1..lambdas.len() {
1054            assert!(lambdas[i - 1] >= lambdas[i]); // Non-increasing
1055        }
1056    }
1057
1058    #[test]
1059    #[ignore = "Slow test: computes regularization path. Run with --ignored flag"]
1060    fn test_lasso_path() {
1061        let x = array![
1062            [1.0, 2.0, 0.1],
1063            [2.0, 3.0, 0.2],
1064            [3.0, 4.0, 0.3],
1065            [4.0, 5.0, 0.4],
1066            [5.0, 6.0, 0.5],
1067        ];
1068        let y = array![1.0, 2.0, 3.0, 4.0, 5.0];
1069
1070        let config = RegularizationPathConfig {
1071            path_type: RegularizationPathType::Lasso,
1072            n_lambdas: 20,
1073            max_iter: 100,
1074            verbose: false,
1075            ..Default::default()
1076        };
1077
1078        let solver = RegularizationPathSolver::new(config);
1079        let result = solver.fit_path(&x, &y).expect("operation should succeed");
1080
1081        assert_eq!(result.lambdas.len(), 20);
1082        assert_eq!(result.coef_path.nrows(), 20);
1083        assert_eq!(result.coef_path.ncols(), 3);
1084        assert_eq!(result.intercept_path.len(), 20);
1085        assert_eq!(result.cv_scores.len(), 20);
1086
1087        // Check that sparsity increases with regularization
1088        assert!(result.n_nonzero[0] >= result.n_nonzero[result.n_nonzero.len() - 1]);
1089
1090        // Check best lambda selection
1091        assert!(result.best_lambda_idx < result.lambdas.len());
1092        assert!(result.lambda_1se_idx < result.lambdas.len());
1093
1094        // Test coefficient extraction
1095        let best_coef = result.coef_path.row(result.best_lambda_idx);
1096        assert_eq!(best_coef.len(), 3);
1097
1098        // Test summary
1099        let summary = result.summary();
1100        assert!(summary.contains_key("best_lambda"));
1101        assert!(summary.contains_key("lambda_1se"));
1102    }
1103
1104    #[test]
1105    fn test_elastic_net_path_type() {
1106        let path_type = RegularizationPathType::ElasticNet { l1_ratio: 0.5 };
1107
1108        if let RegularizationPathType::ElasticNet { l1_ratio } = path_type {
1109            assert_eq!(l1_ratio, 0.5);
1110        } else {
1111            panic!("Expected ElasticNet path type");
1112        }
1113    }
1114
1115    #[test]
1116    fn test_group_lasso_path_type() {
1117        let groups = vec![vec![0, 1], vec![2, 3], vec![4]];
1118        let path_type = RegularizationPathType::GroupLasso {
1119            groups: groups.clone(),
1120        };
1121
1122        if let RegularizationPathType::GroupLasso { groups: g } = path_type {
1123            assert_eq!(g.len(), 3);
1124            assert_eq!(g[0], vec![0, 1]);
1125        } else {
1126            panic!("Expected GroupLasso path type");
1127        }
1128    }
1129
1130    #[test]
1131    fn test_standardization() {
1132        let x = array![[1.0, 10.0], [2.0, 20.0], [3.0, 30.0]];
1133        let config = RegularizationPathConfig::default();
1134        let solver = RegularizationPathSolver::new(config);
1135
1136        let (x_std, means, _stds) = solver
1137            .standardize_features(&x)
1138            .expect("operation should succeed");
1139
1140        // Check means are approximately zero after standardization
1141        for j in 0..x_std.ncols() {
1142            let col_mean = x_std.column(j).mean().expect("operation should succeed");
1143            assert!((col_mean).abs() < 1e-10);
1144        }
1145
1146        // Check original means and stds
1147        assert!((means[0] - 2.0).abs() < 1e-10);
1148        assert!((means[1] - 20.0).abs() < 1e-10);
1149    }
1150}