1use ferrolearn_core::error::FerroError;
48use ferrolearn_core::introspection::HasCoefficients;
49use ferrolearn_core::traits::{Fit, Predict};
50use ndarray::{Array1, Array2, Axis, ScalarOperand};
51use num_traits::{Float, FromPrimitive};
52
53use crate::ElasticNet;
54
55#[derive(Debug, Clone)]
67pub struct ElasticNetCV<F> {
68 l1_ratios: Vec<F>,
70 n_alphas: usize,
73 cv: usize,
75 max_iter: usize,
77 tol: F,
79 fit_intercept: bool,
81}
82
83impl<F: Float + FromPrimitive> ElasticNetCV<F> {
84 #[must_use]
98 pub fn new() -> Self {
99 let half = F::one() / (F::one() + F::one());
103 Self {
104 l1_ratios: vec![half],
105 n_alphas: 100,
106 cv: 5,
107 max_iter: 1000,
108 tol: F::from(1e-4).unwrap(),
109 fit_intercept: true,
110 }
111 }
112
113 #[must_use]
117 pub fn with_l1_ratios(mut self, l1_ratios: Vec<F>) -> Self {
118 self.l1_ratios = l1_ratios;
119 self
120 }
121
122 #[must_use]
124 pub fn with_n_alphas(mut self, n_alphas: usize) -> Self {
125 self.n_alphas = n_alphas;
126 self
127 }
128
129 #[must_use]
133 pub fn with_cv(mut self, cv: usize) -> Self {
134 self.cv = cv;
135 self
136 }
137
138 #[must_use]
140 pub fn with_max_iter(mut self, max_iter: usize) -> Self {
141 self.max_iter = max_iter;
142 self
143 }
144
145 #[must_use]
147 pub fn with_tol(mut self, tol: F) -> Self {
148 self.tol = tol;
149 self
150 }
151
152 #[must_use]
154 pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
155 self.fit_intercept = fit_intercept;
156 self
157 }
158}
159
160impl<F: Float + FromPrimitive> Default for ElasticNetCV<F> {
161 fn default() -> Self {
162 Self::new()
163 }
164}
165
166#[derive(Debug, Clone)]
171pub struct FittedElasticNetCV<F> {
172 best_alpha: F,
174 best_l1_ratio: F,
176 coefficients: Array1<F>,
178 intercept: F,
180}
181
182impl<F: Float> FittedElasticNetCV<F> {
183 #[must_use]
185 pub fn best_alpha(&self) -> F {
186 self.best_alpha
187 }
188
189 #[must_use]
191 pub fn best_l1_ratio(&self) -> F {
192 self.best_l1_ratio
193 }
194}
195
196fn kfold_indices(n_samples: usize, k: usize) -> Vec<Vec<usize>> {
204 let base = n_samples / k;
205 let remainder = n_samples % k;
206 let mut folds: Vec<Vec<usize>> = Vec::with_capacity(k);
207 let mut current = 0;
208 for fold in 0..k {
209 let fold_size = if fold < remainder { base + 1 } else { base };
210 let stop = current + fold_size;
211 folds.push((current..stop).collect());
212 current = stop;
213 }
214 folds
215}
216
217fn mse<F: Float + FromPrimitive + 'static>(y_true: &Array1<F>, y_pred: &Array1<F>) -> F {
219 let n = F::from(y_true.len()).unwrap();
220 let diff = y_true - y_pred;
221 diff.dot(&diff) / n
222}
223
224fn select_rows<F: Float>(x: &Array2<F>, indices: &[usize]) -> Array2<F> {
226 let ncols = x.ncols();
227 let mut out = Array2::<F>::zeros((indices.len(), ncols));
228 for (out_row, &idx) in indices.iter().enumerate() {
229 out.row_mut(out_row).assign(&x.row(idx));
230 }
231 out
232}
233
234fn select_elements<F: Float>(y: &Array1<F>, indices: &[usize]) -> Array1<F> {
236 Array1::from_iter(indices.iter().map(|&i| y[i]))
237}
238
239fn compute_alpha_max_enet<F: Float + FromPrimitive + ScalarOperand>(
244 x: &Array2<F>,
245 y: &Array1<F>,
246 l1_ratio: F,
247 fit_intercept: bool,
248) -> F {
249 let n = F::from(x.nrows()).unwrap();
250
251 let y_work = if fit_intercept {
252 let y_mean = y.mean().unwrap_or_else(F::zero);
253 y - y_mean
254 } else {
255 y.clone()
256 };
257
258 let x_work = if fit_intercept {
259 let x_mean = x.mean_axis(Axis(0)).unwrap();
260 x - &x_mean
261 } else {
262 x.clone()
263 };
264
265 let xty = x_work.t().dot(&y_work);
266 let mut max_abs = F::zero();
267 for &v in &xty {
268 let abs_v = v.abs();
269 if abs_v > max_abs {
270 max_abs = abs_v;
271 }
272 }
273
274 if l1_ratio > F::zero() {
275 max_abs / (n * l1_ratio)
276 } else {
277 max_abs / n
279 }
280}
281
282fn logspace<F: Float + FromPrimitive>(high: F, eps_ratio: F, n: usize) -> Vec<F> {
284 if n == 0 {
285 return Vec::new();
286 }
287 if n == 1 {
288 return vec![high];
289 }
290
291 let log_high = high.ln();
292 let log_low = (high * eps_ratio).ln();
293 let step = (log_low - log_high) / F::from(n - 1).unwrap();
294
295 (0..n)
296 .map(|i| (log_high + step * F::from(i).unwrap()).exp())
297 .collect()
298}
299
300impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + 'static> Fit<Array2<F>, Array1<F>>
301 for ElasticNetCV<F>
302{
303 type Fitted = FittedElasticNetCV<F>;
304 type Error = FerroError;
305
306 fn fit(&self, x: &Array2<F>, y: &Array1<F>) -> Result<FittedElasticNetCV<F>, FerroError> {
319 let (n_samples, _n_features) = x.dim();
320
321 if n_samples != y.len() {
322 return Err(FerroError::ShapeMismatch {
323 expected: vec![n_samples],
324 actual: vec![y.len()],
325 context: "y length must match number of samples in X".into(),
326 });
327 }
328
329 if self.l1_ratios.is_empty() {
330 return Err(FerroError::InvalidParameter {
331 name: "l1_ratios".into(),
332 reason: "must contain at least one candidate".into(),
333 });
334 }
335
336 for &r in &self.l1_ratios {
337 if r < F::zero() || r > F::one() {
338 return Err(FerroError::InvalidParameter {
339 name: "l1_ratios".into(),
340 reason: "all l1_ratio values must be in [0, 1]".into(),
341 });
342 }
343 }
344
345 if self.cv < 2 {
346 return Err(FerroError::InvalidParameter {
347 name: "cv".into(),
348 reason: "number of folds must be at least 2".into(),
349 });
350 }
351
352 if x.iter().any(|v| !v.is_finite()) {
369 return Err(FerroError::InvalidParameter {
370 name: "X".into(),
371 reason: "Input X contains NaN or infinity.".into(),
372 });
373 }
374 if y.iter().any(|v| !v.is_finite()) {
375 return Err(FerroError::InvalidParameter {
376 name: "y".into(),
377 reason: "Input y contains NaN or infinity.".into(),
378 });
379 }
380
381 if n_samples < self.cv {
382 return Err(FerroError::InsufficientSamples {
383 required: self.cv,
384 actual: n_samples,
385 context: "ElasticNetCV requires at least as many samples as folds".into(),
386 });
387 }
388
389 if self.n_alphas == 0 {
390 return Err(FerroError::InvalidParameter {
391 name: "n_alphas".into(),
392 reason: "must be at least 1".into(),
393 });
394 }
395
396 let folds = kfold_indices(n_samples, self.cv);
397
398 let mut best_alpha = F::one();
399 let mut best_l1_ratio = self.l1_ratios[0];
400 let mut best_mse = F::infinity();
401
402 for &l1_ratio in &self.l1_ratios {
403 if l1_ratio == F::zero() {
410 return Err(FerroError::InvalidParameter {
411 name: "l1_ratio".into(),
412 reason: "Automatic alpha grid generation is not supported for \
413 l1_ratio=0; supply an explicit alphas grid"
414 .into(),
415 });
416 }
417
418 let alpha_max = compute_alpha_max_enet(x, y, l1_ratio, self.fit_intercept);
420 let resolution = F::from(1e-15_f64).unwrap_or_else(F::epsilon);
430 let alpha_grid = if alpha_max <= resolution {
431 vec![resolution; self.n_alphas]
432 } else {
433 logspace(
434 alpha_max,
435 F::from(1e-3).unwrap_or_else(F::epsilon),
436 self.n_alphas,
437 )
438 };
439
440 for &alpha in &alpha_grid {
441 let mut total_mse = F::zero();
442
443 for fold_idx in 0..self.cv {
444 let test_indices = &folds[fold_idx];
445 let train_indices: Vec<usize> = folds
446 .iter()
447 .enumerate()
448 .filter(|&(i, _)| i != fold_idx)
449 .flat_map(|(_, v)| v.iter().copied())
450 .collect();
451
452 let x_train = select_rows(x, &train_indices);
453 let y_train = select_elements(y, &train_indices);
454 let x_test = select_rows(x, test_indices);
455 let y_test = select_elements(y, test_indices);
456
457 let model = ElasticNet::<F>::new()
458 .with_alpha(alpha)
459 .with_l1_ratio(l1_ratio)
460 .with_max_iter(self.max_iter)
461 .with_tol(self.tol)
462 .with_fit_intercept(self.fit_intercept);
463
464 let fitted = model.fit(&x_train, &y_train)?;
465 let preds = fitted.predict(&x_test)?;
466 total_mse = total_mse + mse(&y_test, &preds);
467 }
468
469 let avg_mse = total_mse / F::from(self.cv).unwrap();
470
471 if avg_mse < best_mse {
472 best_mse = avg_mse;
473 best_alpha = alpha;
474 best_l1_ratio = l1_ratio;
475 }
476 }
477 }
478
479 let final_model = ElasticNet::<F>::new()
481 .with_alpha(best_alpha)
482 .with_l1_ratio(best_l1_ratio)
483 .with_max_iter(self.max_iter)
484 .with_tol(self.tol)
485 .with_fit_intercept(self.fit_intercept);
486 let final_fitted = final_model.fit(x, y)?;
487
488 Ok(FittedElasticNetCV {
489 best_alpha,
490 best_l1_ratio,
491 coefficients: final_fitted.coefficients().clone(),
492 intercept: final_fitted.intercept(),
493 })
494 }
495}
496
497impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>>
498 for FittedElasticNetCV<F>
499{
500 type Output = Array1<F>;
501 type Error = FerroError;
502
503 fn predict(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
512 let n_features = x.ncols();
513 if n_features != self.coefficients.len() {
514 return Err(FerroError::ShapeMismatch {
515 expected: vec![self.coefficients.len()],
516 actual: vec![n_features],
517 context: "number of features must match fitted model".into(),
518 });
519 }
520
521 let preds = x.dot(&self.coefficients) + self.intercept;
522 Ok(preds)
523 }
524}
525
526impl<F: Float + Send + Sync + ScalarOperand + 'static> HasCoefficients<F>
527 for FittedElasticNetCV<F>
528{
529 fn coefficients(&self) -> &Array1<F> {
530 &self.coefficients
531 }
532
533 fn intercept(&self) -> F {
534 self.intercept
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541 use approx::assert_relative_eq;
542 use ndarray::array;
543
544 #[test]
545 fn test_elastic_net_cv_default_builder() {
546 let m = ElasticNetCV::<f64>::new();
547 assert_eq!(m.l1_ratios.len(), 1);
550 assert_eq!(m.l1_ratios[0], 0.5);
551 assert_eq!(m.n_alphas, 100);
552 assert_eq!(m.cv, 5);
553 assert_eq!(m.max_iter, 1000);
554 assert!(m.fit_intercept);
555 }
556
557 #[test]
558 fn test_elastic_net_cv_builder_setters() {
559 let m = ElasticNetCV::<f64>::new()
560 .with_l1_ratios(vec![0.5, 0.9])
561 .with_n_alphas(20)
562 .with_cv(3)
563 .with_max_iter(500)
564 .with_tol(1e-6)
565 .with_fit_intercept(false);
566 assert_eq!(m.l1_ratios.len(), 2);
567 assert_eq!(m.n_alphas, 20);
568 assert_eq!(m.cv, 3);
569 assert_eq!(m.max_iter, 500);
570 assert!(!m.fit_intercept);
571 }
572
573 #[test]
574 fn test_elastic_net_cv_fit_selects_params() {
575 let x = Array2::from_shape_vec((20, 1), (1..=20).map(f64::from).collect()).unwrap();
576 let y = Array1::from_iter((1..=20).map(|i| 2.0 * f64::from(i) + 1.0));
577
578 let model = ElasticNetCV::<f64>::new()
579 .with_l1_ratios(vec![0.5, 0.9, 1.0])
580 .with_n_alphas(10)
581 .with_cv(3);
582
583 let fitted = model.fit(&x, &y).unwrap();
584
585 assert!(fitted.best_alpha() > 0.0);
586 assert!(fitted.best_l1_ratio() >= 0.0);
587 assert!(fitted.best_l1_ratio() <= 1.0);
588 }
589
590 #[test]
591 fn test_elastic_net_cv_predict() {
592 let x = Array2::from_shape_vec((10, 1), (1..=10).map(f64::from).collect()).unwrap();
593 let y = Array1::from_iter((1..=10).map(|i| 2.0 * f64::from(i) + 1.0));
594
595 let model = ElasticNetCV::<f64>::new()
596 .with_l1_ratios(vec![0.5, 0.9])
597 .with_n_alphas(10)
598 .with_cv(3);
599 let fitted = model.fit(&x, &y).unwrap();
600
601 let preds = fitted.predict(&x).unwrap();
602 assert_eq!(preds.len(), 10);
603
604 for i in 0..10 {
605 assert_relative_eq!(preds[i], y[i], epsilon = 2.0);
606 }
607 }
608
609 #[test]
610 fn test_elastic_net_cv_has_coefficients() {
611 let x = Array2::from_shape_vec((10, 2), (0..20).map(f64::from).collect()).unwrap();
612 let y = Array1::from_iter((0..10).map(f64::from));
613
614 let model = ElasticNetCV::<f64>::new()
615 .with_l1_ratios(vec![0.5])
616 .with_n_alphas(5)
617 .with_cv(3);
618 let fitted = model.fit(&x, &y).unwrap();
619
620 assert_eq!(fitted.coefficients().len(), 2);
621 }
622
623 #[test]
624 fn test_elastic_net_cv_empty_l1_ratios_error() {
625 let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
626 let y = array![1.0, 2.0, 3.0, 4.0, 5.0];
627
628 let model = ElasticNetCV::<f64>::new().with_l1_ratios(vec![]);
629 let result = model.fit(&x, &y);
630 assert!(result.is_err());
631 }
632
633 #[test]
634 fn test_elastic_net_cv_invalid_l1_ratio_error() {
635 let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
636 let y = array![1.0, 2.0, 3.0, 4.0, 5.0];
637
638 let model = ElasticNetCV::<f64>::new().with_l1_ratios(vec![0.5, 1.5]);
639 let result = model.fit(&x, &y);
640 assert!(result.is_err());
641 }
642
643 #[test]
644 fn test_elastic_net_cv_shape_mismatch() {
645 let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
646 let y = array![1.0, 2.0];
647
648 let model = ElasticNetCV::<f64>::new();
649 let result = model.fit(&x, &y);
650 assert!(result.is_err());
651 }
652
653 #[test]
654 fn test_elastic_net_cv_insufficient_samples() {
655 let x = Array2::from_shape_vec((2, 1), vec![1.0, 2.0]).unwrap();
656 let y = array![1.0, 2.0];
657
658 let model = ElasticNetCV::<f64>::new().with_cv(5);
659 let result = model.fit(&x, &y);
660 assert!(result.is_err());
661 }
662
663 #[test]
664 fn test_elastic_net_cv_cv_too_small() {
665 let x = Array2::from_shape_vec((10, 1), (1..=10).map(f64::from).collect()).unwrap();
666 let y = Array1::from_iter((1..=10).map(f64::from));
667
668 let model = ElasticNetCV::<f64>::new().with_cv(1);
669 let result = model.fit(&x, &y);
670 assert!(result.is_err());
671 }
672
673 #[test]
674 fn test_elastic_net_cv_predict_feature_mismatch() {
675 let x_train = Array2::from_shape_vec((10, 2), (0..20).map(f64::from).collect()).unwrap();
676 let y = Array1::from_iter((0..10).map(f64::from));
677
678 let fitted = ElasticNetCV::<f64>::new()
679 .with_l1_ratios(vec![0.5])
680 .with_n_alphas(5)
681 .with_cv(3)
682 .fit(&x_train, &y)
683 .unwrap();
684
685 let x_bad = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
686 let result = fitted.predict(&x_bad);
687 assert!(result.is_err());
688 }
689
690 #[test]
691 fn test_elastic_net_cv_no_intercept() {
692 let x = Array2::from_shape_vec((10, 1), (1..=10).map(f64::from).collect()).unwrap();
693 let y = Array1::from_iter((1..=10).map(|i| 2.0 * f64::from(i)));
694
695 let model = ElasticNetCV::<f64>::new()
696 .with_l1_ratios(vec![0.5])
697 .with_n_alphas(5)
698 .with_cv(3)
699 .with_fit_intercept(false);
700 let fitted = model.fit(&x, &y).unwrap();
701
702 let preds = fitted.predict(&x).unwrap();
703 assert_eq!(preds.len(), 10);
704 }
705
706 #[test]
707 fn test_elastic_net_cv_l1_ratio_zero_auto_grid_errors() {
708 let x = Array2::from_shape_vec((10, 1), (1..=10).map(f64::from).collect()).unwrap();
712 let y = Array1::from_iter((1..=10).map(|i| 2.0 * f64::from(i) + 1.0));
713
714 let model = ElasticNetCV::<f64>::new()
715 .with_l1_ratios(vec![0.0, 0.5, 1.0])
716 .with_n_alphas(5)
717 .with_cv(3);
718 let result = model.fit(&x, &y);
719 assert!(result.is_err());
720 }
721}