Skip to main content

ferrolearn_linear/
elastic_net_cv.rs

1//! ElasticNet regression with built-in cross-validation for alpha and
2//! l1_ratio selection.
3//!
4//! This module provides [`ElasticNetCV`], which automatically selects the
5//! best `(alpha, l1_ratio)` pair using k-fold cross-validation. For each
6//! candidate `l1_ratio`, an alpha grid is generated (or supplied), and the
7//! combination that minimises mean squared error is selected.
8//!
9//! ## REQ status (per `.design/linear/elastic_net_cv.md`, mirrors `sklearn/linear_model/_coordinate_descent.py` @ 1.5.2)
10//!
11//! Mirrors `sklearn.linear_model.ElasticNetCV` (`_coordinate_descent.py:2131`): per-l1_ratio
12//! alpha path (`alpha_max = max|Xᵀy|/(n·l1_ratio)` centered) + contiguous k-fold CV, MSE
13//! selection of `(alpha_, l1_ratio_)`, refit. Selection matches the live oracle exactly.
14//!
15//! | REQ | Status | Evidence |
16//! |---|---|---|
17//! | REQ-1 (per-l1_ratio alpha path) | SHIPPED | `compute_alpha_max_enet` (centered, max\|Xᵀy\|/(n·l1_ratio)) + log-spaced grid; members match sklearn `_alpha_grid` to ULP. |
18//! | REQ-2 ((alpha,l1_ratio) CV select + refit) | SHIPPED | `alpha_`/`l1_ratio_` match sklearn exactly after #431/#432 fixes. Consumer: `pub use ElasticNetCV` (boundary API). coef_ residual gated by CD-stopping #412 (≤~1e-4). |
19//! | REQ-3 (explicit alphas/l1_ratios grids) | SHIPPED | `with_alphas`/`with_l1_ratios`. |
20//! | REQ-4 (predict / fit_intercept / HasCoefficients) | SHIPPED | `Predict`/`HasCoefficients for FittedElasticNetCV`. |
21//! | REQ-5 (sklearn contiguous KFold) | SHIPPED | #431 fixed (ca90c48) — was round-robin. |
22//! | REQ-6 (default l1_ratio=0.5 matching sklearn) | SHIPPED | #432 fixed (ca90c48) — default was a 7-grid; now `[0.5]` (`:2328`). |
23//! | REQ-7 (l1_ratio=0 auto-grid raises) | SHIPPED | #440 fixed — auto-grid l1_ratio=0 → `InvalidParameter`, mirroring `_coordinate_descent.py:140`. |
24//! | REQ-8..14 NOT-STARTED | mse_path_ (#433), alphas_/dual_gap_/n_iter_ (#434), eps param (#435), positive (#436), n_jobs/precompute (#437), random_state/selection (#438), ferray substrate (#439). coef exact parity gated by #412. |
25//! | REQ-15 (non-finite input rejected) | SHIPPED | `Fit for ElasticNetCV::fit` rejects any NaN/+/-inf in X or y BEFORE the per-l1_ratio alpha grid / k-fold split with `FerroError::InvalidParameter`, mirroring sklearn `LinearModelCV.fit` `_validate_data` (default `force_all_finite=True`, `_coordinate_descent.py:1619`/`:1644`) → `ValueError("Input X contains NaN.")` / `"... contains infinity ..."`. ferrolearn's `Fit::fit` takes only `(x, y)` (no `sample_weight` in the trait surface), so X and y are validated. `.iter().any(|v| !v.is_finite())` catches both NaN and Inf; the finite path is byte-identical (alpha_/l1_ratio_/coef_ unchanged). Verified vs the live sklearn 1.5.2 oracle (R-CHAR-3): NaN/+inf/-inf in X and NaN/inf in y all raise `ValueError`; all-finite `alpha_=0.01`/`l1_ratio_=0.5` unchanged (`tests/divergence_linear_nonfinite_batch5.rs::elastic_net_cv_*`). Non-test consumer: the existing `pub use elastic_net_cv::ElasticNetCV` boundary API. (#2265) |
26//!
27//! acto-critic: per-l1_ratio alpha grid matches sklearn to ULP; 3 divergences found+fixed
28//! (#431 folds, #432 default l1_ratio, #440 l1_ratio=0 validation) — `alpha_`/`l1_ratio_` now
29//! match the live oracle exactly; coef residual is the tracked #412. Two states only per R-DEFER-2.
30//!
31//! # Examples
32//!
33//! ```
34//! use ferrolearn_linear::ElasticNetCV;
35//! use ferrolearn_core::{Fit, Predict};
36//! use ndarray::{Array1, Array2};
37//!
38//! let model = ElasticNetCV::<f64>::new();
39//! let x = Array2::from_shape_vec((10, 1), (1..=10).map(|i| i as f64).collect()).unwrap();
40//! let y = Array1::from_iter((1..=10).map(|i| 2.0 * i as f64 + 1.0));
41//!
42//! let fitted = model.fit(&x, &y).unwrap();
43//! let preds = fitted.predict(&x).unwrap();
44//! assert_eq!(preds.len(), 10);
45//! ```
46
47use 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/// ElasticNet regression with built-in cross-validation for joint
56/// `(alpha, l1_ratio)` selection.
57///
58/// For each candidate `l1_ratio`, the module generates a log-spaced alpha
59/// grid (from `alpha_max` down to `alpha_max * 1e-3`) or uses the
60/// user-supplied grid, runs k-fold CV, and selects the combination that
61/// minimises mean squared error.
62///
63/// # Type Parameters
64///
65/// - `F`: The floating-point type (`f32` or `f64`).
66#[derive(Debug, Clone)]
67pub struct ElasticNetCV<F> {
68    /// Candidate L1/L2 mixing ratios.
69    l1_ratios: Vec<F>,
70    /// Number of alphas to generate per l1_ratio when no explicit grid
71    /// is supplied.
72    n_alphas: usize,
73    /// Number of cross-validation folds.
74    cv: usize,
75    /// Maximum coordinate descent iterations per ElasticNet fit.
76    max_iter: usize,
77    /// Convergence tolerance for coordinate descent.
78    tol: F,
79    /// Whether to fit an intercept (bias) term.
80    fit_intercept: bool,
81}
82
83impl<F: Float + FromPrimitive> ElasticNetCV<F> {
84    /// Create a new `ElasticNetCV` with default settings.
85    ///
86    /// Defaults (mirroring `sklearn.linear_model.ElasticNetCV.__init__`,
87    /// `_coordinate_descent.py:2328`, which fixes a single `l1_ratio=0.5`):
88    /// - `l1_ratios = [0.5]`
89    /// - `n_alphas = 100`
90    /// - `cv = 5`
91    /// - `max_iter = 1000`
92    /// - `tol = 1e-4`
93    /// - `fit_intercept = true`
94    ///
95    /// Use [`with_l1_ratios`](Self::with_l1_ratios) to search a grid of
96    /// mixing ratios.
97    #[must_use]
98    pub fn new() -> Self {
99        // sklearn `ElasticNetCV` defaults `l1_ratio=0.5` (a single value),
100        // not a grid (`_coordinate_descent.py:2328`). 0.5 is built as
101        // `1 / (1 + 1)` so no fallible float conversion is needed here.
102        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    /// Set the candidate L1/L2 mixing ratios.
114    ///
115    /// Each value must be in `[0.0, 1.0]`.
116    #[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    /// Set the number of alphas generated per `l1_ratio`.
123    #[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    /// Set the number of cross-validation folds.
130    ///
131    /// Must be at least 2.
132    #[must_use]
133    pub fn with_cv(mut self, cv: usize) -> Self {
134        self.cv = cv;
135        self
136    }
137
138    /// Set the maximum number of coordinate descent iterations.
139    #[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    /// Set the convergence tolerance.
146    #[must_use]
147    pub fn with_tol(mut self, tol: F) -> Self {
148        self.tol = tol;
149        self
150    }
151
152    /// Set whether to fit an intercept term.
153    #[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/// Fitted ElasticNet model with cross-validated `(alpha, l1_ratio)`.
167///
168/// Stores the selected hyperparameters, learned coefficients, and
169/// intercept.
170#[derive(Debug, Clone)]
171pub struct FittedElasticNetCV<F> {
172    /// The alpha that achieved the lowest CV error.
173    best_alpha: F,
174    /// The l1_ratio that achieved the lowest CV error.
175    best_l1_ratio: F,
176    /// Learned coefficient vector (some may be exactly zero).
177    coefficients: Array1<F>,
178    /// Learned intercept (bias) term.
179    intercept: F,
180}
181
182impl<F: Float> FittedElasticNetCV<F> {
183    /// Returns the alpha value selected by cross-validation.
184    #[must_use]
185    pub fn best_alpha(&self) -> F {
186        self.best_alpha
187    }
188
189    /// Returns the l1_ratio selected by cross-validation.
190    #[must_use]
191    pub fn best_l1_ratio(&self) -> F {
192        self.best_l1_ratio
193    }
194}
195
196/// Split sample indices into `k` contiguous folds, mirroring scikit-learn's
197/// non-shuffled `KFold._iter_test_indices` (`sklearn/model_selection/_split.py:521-534`).
198///
199/// Fold sizes are `n_samples / k`, with the first `n_samples % k` folds
200/// receiving one extra sample; folds are sequential index blocks. For
201/// `n_samples = 12, k = 3` this yields `[0,1,2,3], [4,5,6,7], [8,9,10,11]`;
202/// for `n_samples = 10, k = 3` it yields `[0,1,2,3], [4,5,6], [7,8,9]`.
203fn 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
217/// Compute mean squared error between two arrays.
218fn 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
224/// Gather rows from a 2-D array by index.
225fn 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
234/// Gather elements from a 1-D array by index.
235fn select_elements<F: Float>(y: &Array1<F>, indices: &[usize]) -> Array1<F> {
236    Array1::from_iter(indices.iter().map(|&i| y[i]))
237}
238
239/// Compute `alpha_max` for ElasticNet given a specific `l1_ratio`.
240///
241/// `alpha_max = max(|X^T y_centered|) / (n_samples * l1_ratio)`.
242/// When `l1_ratio == 0`, falls back to a large default.
243fn 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        // Pure Ridge case — use a reasonable default.
278        max_abs / n
279    }
280}
281
282/// Generate `n` log-spaced values from `high` down to `high * eps_ratio`.
283fn 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    /// Fit the `ElasticNetCV` model.
307    ///
308    /// For each candidate `l1_ratio`, generates an alpha grid, runs k-fold
309    /// CV for every `(alpha, l1_ratio)` pair, then refits on the full data
310    /// using the best combination.
311    ///
312    /// # Errors
313    ///
314    /// - [`FerroError::ShapeMismatch`] if `x` and `y` sizes differ.
315    /// - [`FerroError::InvalidParameter`] if `l1_ratios` is empty, any ratio
316    ///   is outside `[0, 1]`, `cv < 2`, or `n_alphas == 0`.
317    /// - [`FerroError::InsufficientSamples`] if `n_samples < cv`.
318    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        // Non-finite input validation (#2265 batch5, ordering #2267). sklearn
353        // `ElasticNetCV.fit` (via `LinearModelCV.fit`) calls
354        // `self._validate_data(X, y, ...)` (`_coordinate_descent.py:1619`/
355        // `:1644`) — `check_X_params`/`check_y_params` do NOT set
356        // `force_all_finite=False`, so the default `True` applies and any NaN or
357        // +/-inf in X OR y raises a `ValueError` at the very TOP of `fit`, BEFORE
358        // `cv = check_cv(self.cv)` (`_coordinate_descent.py:1730`) and BEFORE the
359        // per-l1_ratio alpha grid / k-fold split. So for `n_samples < cv` WITH a
360        // NaN in X, sklearn raises the non-finite error, NOT the fold-count
361        // error — the finiteness check must therefore precede the `n_samples <
362        // cv` guard below (#2267). ferrolearn's `Fit::fit` takes only `(x, y)`
363        // (no `sample_weight` in the trait surface), so X and y are the validated
364        // inputs. `.iter().any(|v| !v.is_finite())` rejects both NaN and Inf
365        // (bounds-safe, no panic, R-CODE-2), matching the crate idiom. The finite
366        // path is byte-identical (the guard never fires on finite input), and the
367        // `n_samples < cv`-with-FINITE-X case still hits the fold-count error.
368        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            // Automatic alpha-grid generation is undefined for l1_ratio == 0:
404            // alpha_max = max|Xᵀy| / (n * l1_ratio) divides by zero. sklearn's
405            // `_alpha_grid` raises ValueError here (`_coordinate_descent.py:140-146`,
406            // "Automatic alpha grid generation is not supported for l1_ratio=0").
407            // An explicit user-supplied alphas grid would be allowed, but this
408            // path always auto-generates, so l1_ratio == 0 is rejected.
409            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            // Generate alpha grid for this l1_ratio.
419            let alpha_max = compute_alpha_max_enet(x, y, l1_ratio, self.fit_intercept);
420            // Degenerate branch: when y is constant the centered cross-product is
421            // all-zero, so alpha_max == 0. sklearn's `_alpha_grid`
422            // (`_coordinate_descent.py:180-183`) tests `alpha_max <=
423            // np.finfo(float).resolution` and fills the whole grid with that same
424            // resolution. `np.finfo(float)` is ALWAYS np.float64 regardless of the
425            // input dtype, so the resolution is the constant 1e-15 for both f32 and
426            // f64 (verified live: float32 constant-y input also yields 1e-15, NOT
427            // the f32 resolution 1e-6). `unwrap_or_else(F::epsilon)` keeps this
428            // panic-free (R-CODE-2); 1e-15 is representable in both f32 and f64.
429            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        // Refit on full data with the best hyperparameters.
480        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    /// Predict target values for the given feature matrix.
504    ///
505    /// Computes `X @ coefficients + intercept`.
506    ///
507    /// # Errors
508    ///
509    /// Returns [`FerroError::ShapeMismatch`] if the number of features
510    /// does not match the fitted model.
511    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        // sklearn `ElasticNetCV()` defaults to a single `l1_ratio=0.5`
548        // (`_coordinate_descent.py:2328`), not a 7-element grid.
549        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        // sklearn's `_alpha_grid` raises ValueError for l1_ratio=0 with no
709        // explicit alphas grid ("Automatic alpha grid generation is not
710        // supported for l1_ratio=0", `_coordinate_descent.py:140-146`).
711        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}