1use scirs2_core::ndarray::{s, Array1, Array2, ArrayView1};
15use sklears_core::{
16 error::{Result, SklearsError},
17 types::Float,
18};
19use std::collections::HashMap;
20
21#[derive(Debug, Clone, PartialEq, Default)]
23pub enum RegularizationPathType {
24 #[default]
26 Lasso,
27 ElasticNet { l1_ratio: Float },
29 GroupLasso { groups: Vec<Vec<usize>> },
31 AdaptiveLasso { weights: Array1<Float> },
33 FusedLasso,
35}
36
37#[derive(Debug, Clone, PartialEq)]
39pub enum CrossValidationStrategy {
40 KFold { k: usize },
42 LeaveOneOut,
44 TimeSeriesSplit { n_splits: usize },
46 StratifiedKFold { k: usize },
48}
49
50impl Default for CrossValidationStrategy {
51 fn default() -> Self {
52 CrossValidationStrategy::KFold { k: 5 }
53 }
54}
55
56#[derive(Debug, Clone)]
58pub struct RegularizationPathConfig {
59 pub path_type: RegularizationPathType,
61 pub n_lambdas: usize,
63 pub lambda_min_ratio: Float,
65 pub lambdas: Option<Array1<Float>>,
67 pub tol: Float,
69 pub max_iter: usize,
71 pub fit_intercept: bool,
73 pub cv_strategy: CrossValidationStrategy,
75 pub standardize: bool,
77 pub early_stopping: bool,
79 pub min_improvement: Float,
81 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#[derive(Debug, Clone)]
106pub struct RegularizationPathResult {
107 pub lambdas: Array1<Float>,
109 pub coef_path: Array2<Float>,
111 pub intercept_path: Array1<Float>,
113 pub cv_scores: Array1<Float>,
115 pub cv_scores_std: Array1<Float>,
117 pub n_nonzero: Array1<usize>,
119 pub active_features: Vec<Vec<usize>>,
121 pub best_lambda: Float,
123 pub best_lambda_idx: usize,
125 pub lambda_1se: Float,
127 pub lambda_1se_idx: usize,
129}
130
131#[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 pub fn new(config: RegularizationPathConfig) -> Self {
146 Self { config }
147 }
148
149 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 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 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 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 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 let mut coef = Array1::zeros(n_features);
202
203 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 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 coef_path.row_mut(i).assign(&new_coef);
222 intercept_path[i] = intercept;
223
224 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 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 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 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 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 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 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 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 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 for j in 0..n_features {
336 means[j] = x.column(j).mean().unwrap_or(0.0);
337 }
338
339 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 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 fn compute_lambda_sequence(
363 &self,
364 x: &Array2<Float>,
365 y: &Array1<Float>,
366 ) -> Result<Array1<Float>> {
367 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 self.solve_lasso(x, y, lambda, initial_coef, y_mean)
820 }
821
822 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 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 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 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 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 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 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 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 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 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 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 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]); for i in 1..lambdas.len() {
1054 assert!(lambdas[i - 1] >= lambdas[i]); }
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 assert!(result.n_nonzero[0] >= result.n_nonzero[result.n_nonzero.len() - 1]);
1089
1090 assert!(result.best_lambda_idx < result.lambdas.len());
1092 assert!(result.lambda_1se_idx < result.lambdas.len());
1093
1094 let best_coef = result.coef_path.row(result.best_lambda_idx);
1096 assert_eq!(best_coef.len(), 3);
1097
1098 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 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 assert!((means[0] - 2.0).abs() < 1e-10);
1148 assert!((means[1] - 20.0).abs() < 1e-10);
1149 }
1150}