Skip to main content

ferrolearn_linear/
elastic_net.rs

1//! ElasticNet regression (combined L1 and L2 regularization).
2//!
3//! This module provides [`ElasticNet`], which fits a linear model with a
4//! blended L1/L2 regularization penalty using coordinate descent with
5//! soft-thresholding:
6//!
7//! ```text
8//! minimize (1/(2n)) * ||X @ w - y||^2
9//!        + alpha * l1_ratio * ||w||_1
10//!        + (alpha/2) * (1 - l1_ratio) * ||w||_2^2
11//! ```
12//!
13//! When `l1_ratio = 1`, ElasticNet is equivalent to Lasso. When
14//! `l1_ratio = 0`, it is equivalent to Ridge. Intermediate values produce
15//! solutions that are both sparse (L1) and small in magnitude (L2).
16//!
17//! ## REQ status (per `.design/linear/elastic_net.md`, mirrors `sklearn/linear_model/_coordinate_descent.py` @ 1.5.2)
18//!
19//! Mirrors `sklearn.linear_model.ElasticNet` (`_coordinate_descent.py:729`). CD with the L1/L2
20//! split `soft_threshold(Xⱼᵀr/n, α·l1_ratio)/(XⱼᵀXⱼ/n + α·(1−l1_ratio))` ≡ sklearn's
21//! `l1_reg=α·l1_ratio·n` / `l2_reg=α·(1−l1_ratio)·n`, stopped on sklearn's relative-change +
22//! dual-gap criterion (REQ-13). coef_/intercept_ AND `n_iter_` match the live oracle exactly
23//! (coef_ ≤1e-7); default `l1_ratio=0.5` matches sklearn.
24//!
25//! | REQ | Status | Evidence |
26//! |---|---|---|
27//! | REQ-1 (CD ElasticNet fit, L1/L2 split) | SHIPPED | `Fit for ElasticNet`; converged coef/intercept match oracle <1e-5 over alpha∈{0.01,0.1,1}×l1_ratio∈{0.1,0.5,0.9}. Consumers: `RsElasticNet` in `ferrolearn-python`, `ElasticNetCV`. |
28//! | REQ-2 (predict) | SHIPPED | `Predict for FittedElasticNet`. |
29//! | REQ-3 (fit_intercept incl. false) | SHIPPED | centering. |
30//! | REQ-4 (l1_ratio mixing; =1→Lasso, =0→L2) | SHIPPED | l1_ratio=1 ≡ Lasso; l1_ratio=0 ≡ sklearn ElasticNet L2; both match oracle. |
31//! | REQ-5 (L1 sparsity) | SHIPPED | exact-zero support set bit-identical to sklearn. |
32//! | REQ-6 (HasCoefficients) | SHIPPED | `HasCoefficients for FittedElasticNet`. |
33//! | REQ-7 (alpha/l1_ratio validation; l1_ratio∈[0,1]) | SHIPPED | matches sklearn's `_parameter_constraints` (l1_ratio=0 accepted by the class; the auto-grid error is owned by elastic_net_cv). |
34//! | REQ-8 (positive=True) | SHIPPED | `positive` field + `with_positive` builder; CD loop branches on `self.positive` to `soft_threshold_positive(rho_j, alpha_l1) / denominators[j]` (non-negative soft-threshold, L2 in the denominator unchanged), mirroring sklearn's `positive` param (`_coordinate_descent.py:800`) clip `if positive and tmp < 0: w[ii] = 0.0` (`_cd_fast.pyx:191-195`). Oracle test `elasticnet_positive_matches_sklearn` → coef `[1.13685345, 0.0]`, intercept `-5.96023707` (live sklearn 1.5.2, differs from unconstrained `[0.9081389, -1.7687475]`); `elasticnet_positive_false_unchanged` regression guard. |
35//! | REQ-12 (n_iter_ / dual_gap_ attrs) | SHIPPED | `FittedElasticNet<F>` carries `n_iter`/`dual_gap` fields + `n_iter()`/`dual_gap()` getters, mirroring sklearn `ElasticNet.n_iter_` (`_coordinate_descent.py:827`) and `dual_gap_` (`:831`). `fn enet_dual_gap` computes the duality gap on the CD design (centered/raw) using sklearn's `_cd_fast.pyx:216-247` formula (`l1_reg = α·l1_ratio·n`, `beta = α·(1−l1_ratio)·n`, the `XtA = XᵀR − beta·w` term + `0.5·beta·(1+const²)·‖w‖²`) with a final `/n` mapping to the `(1/2n)` objective; reduces to `lasso_dual_gap` when `l1_ratio = 1` (`beta = 0`). With REQ-13's dual-gap stopping criterion now landed, `n_iter_`'s VALUE matches sklearn exactly (`n_iter_ == 16` at alpha=0.3, `== 19` at alpha=0.1 on the fixture); `dual_gap_` matches sklearn's formula/value (`0.00010575563` at `alpha=0.3, l1_ratio=0.5`). Verification: `cargo test -p ferrolearn-linear --lib elastic_net` (`enet_dual_gap_formula_matches_numpy`, `enet_fitted_dual_gap_and_n_iter`, `enet_fields_dont_change_coef`, `enet_dual_gap_stopping_matches_sklearn_coef_and_niter`). |
36//! | REQ-13 (dual-gap stopping criterion) | SHIPPED | `Fit::fit for ElasticNet` now uses sklearn's two-level criterion (`_cd_fast.pyx:167-249`): `tol_scaled = tol·(target·target)` (`:167-168`), per sweep track `w_max`/`d_w_max`, gate on `w_max==0 || d_w_max/w_max < tol || last_iter` (`:207-211`), and inside the gate break only when the UN-normalized gap `enet_dual_gap(...)·n < tol_scaled` (`:249`) — `enet_dual_gap` already carries the L2/beta term. Matches sklearn's `coef_` to ≤1e-7 and `n_iter_` exactly (16 at alpha=0.3, 19 at alpha=0.1). Verification: `cargo test -p ferrolearn-linear --lib elastic_net` (`enet_dual_gap_stopping_matches_sklearn_coef_and_niter`, `enet_dual_gap_stopping_second_alpha`). |
37//! | REQ-10 (selection='random' + random_state) | SHIPPED | Reuses `pub enum CoordSelection { Cyclic, Random }` from `lasso.rs` + `pub selection`/`pub random_state` fields on `ElasticNet` with `with_selection`/`with_random_state` builders, mirroring sklearn `ElasticNet(selection=..., random_state=...)` (`_coordinate_descent.py` `__init__`). `Fit::fit`'s CD loop visits `0..n_features` in order for `Cyclic` (BYTE-IDENTICAL to the prior cyclic path, so coef_/`n_iter_`/dual-gap stay unchanged) and shuffles a reused index `Vec` each sweep for `Random` via `StdRng::seed_from_u64(random_state.unwrap_or(0))` (sklearn `_cd_fast.pyx` `enet_coordinate_descent` `random` branch picks `ii` instead of `f_iter`); per-coordinate update math + dual-gap stopping (REQ-13) are unchanged. The ElasticNet optimum is unique, so `Random` converges to the same optimum (≈3e-4 from cyclic due to stopping-within-tol). Exact bit-match to sklearn's `selection='random'` is numpy-MT19937-RNG-blocked (Rust `StdRng` ≠ numpy MT), so the random path verifies convergence-to-the-unique-optimum, not bitwise sklearn parity; the cyclic default IS bit-exact. Verification: `cargo test -p ferrolearn-linear --lib elastic_net` (`enet_selection_cyclic_default_unchanged`, `enet_selection_random_converges_to_optimum`). |
38//! | REQ-11 (precompute/Gram) | SHIPPED | `pub precompute: bool` field (default `false`) on `ElasticNet` + `with_precompute` builder, mirroring sklearn `ElasticNet(precompute=False)` (`_coordinate_descent.py:774`). When `true`, `Fit::fit` runs CD on the precomputed `Q = Xcᵀ Xc` / `q = Xcᵀ yc` with an incrementally-maintained `H = Q·w` (sklearn `_cd_fast.pyx enet_coordinate_descent_gram`); `tmp = (q[j]−H[j])/n + col_norms[j]·w[j] ≡` the direct path's `rho_j + (XⱼᵀXⱼ/n)·w_old` since `Xⱼᵀr = q[j]−(Q·w)[j]`, then `soft_threshold(tmp, α·l1_ratio)/(col_norm + α·(1−l1_ratio))` keeps the L2 term in the denominator, so it reaches the SAME unique optimum (to ~1e-10 fp reassociation) with the SAME coordinate order + dual-gap stopping. `precompute=false` (default) is the byte-identical direct path. Verification: `cargo test -p ferrolearn-linear --lib elastic_net` (`enet_precompute_matches_sklearn`, `enet_precompute_default_false_unchanged`, `enet_precompute_equals_direct`). |
39//! | REQ-9 (warm_start) | SHIPPED | `ElasticNet<F>` carries `pub warm_start: bool` (default `false`) + `pub coef_init: Option<Array1<F>>` (default `None`) with `with_warm_start`/`with_coef_init` builders, mirroring sklearn `ElasticNet(warm_start=False)` (`_coordinate_descent.py:795`). R-DEV-4 adaptation: ferrolearn estimators are immutable value types — there is no mutable `self.coef_` carried across repeated `.fit()` calls like sklearn's mutable estimator (`_coordinate_descent.py:1062-1063` reuses `self.coef_` when `warm_start`), so the prior coefficient vector is supplied EXPLICITLY via `coef_init` (sklearn's path solver seeds the same way: `_coordinate_descent.py:648-651`, `coef_ = np.zeros(...)` when `coef_init is None` else `np.asfortranarray(coef_init, ...)`). In `Fit::fit`, when `warm_start && coef_init.is_some()` the init vector is length-validated (`ShapeMismatch` on mismatch) and `w` is cloned from it (the direct path also seeds `residual = y_work − X_work·w`; the Gram path's `H = Q·w` already derives from the actual `w`); otherwise `w = zeros` — BYTE-IDENTICAL to the cold path. The numerics are identical, only the CD start point changes, so warm-from-converged reaches the same unique optimum in fewer sweeps. Verification (live sklearn 1.5.2 oracle, R-CHAR-3): cold `ElasticNet(alpha=0.5, l1_ratio=0.5)` → coef `[0.7643620892, 1.2564536255]`, `n_iter_=14`; warm (refit from converged coef) → coef `[0.7642996441, 1.2564980309]`, `n_iter_=1`. Tests `enet_warm_start_from_converged_matches_sklearn`, `enet_warm_start_default_unchanged`, `enet_warm_start_none_coef_init_equals_cold`, `enet_warm_start_coef_init_wrong_len_errors`. |
40//! | REQ-14..15 NOT-STARTED | MultiTaskElasticNet (#418), ferray substrate (#419). |
41//! | REQ-16 (non-finite input rejected) | SHIPPED | `Fit::fit for ElasticNet` rejects any NaN/+/-inf in X or y BEFORE coordinate descent with `FerroError::InvalidParameter`, mirroring sklearn's `_validate_data(force_all_finite=True)` (`_coordinate_descent.py:980`, default `force_all_finite=True` → `check_array` raises `ValueError("Input X contains NaN.")` / `"... contains infinity ..."`). `.iter().any(|v| !v.is_finite())` catches both NaN and Inf; the finite path is byte-identical (the guard never fires on finite input). Verified vs the live sklearn 1.5.2 oracle (R-CHAR-3): `ElasticNet().fit` raises `ValueError` for NaN/+inf/-inf in X and NaN/inf in y (`tests/divergence_linear_nonfinite.rs::enet_*`). Non-test consumer: the existing `Fit::fit` / `RsElasticNet` / `ElasticNetCV` consumers. (#2256) |
42//!
43//! acto-critic: NO DIVERGENCE FOUND — coef/intercept grid parity, l1_ratio=1↔Lasso, l1_ratio=0↔L2,
44//! sparsity support, default l1_ratio, and a badly-scaled-feature stress all match the live oracle.
45//! Two states only per goal.md R-DEFER-2.
46//!
47//! # Examples
48//!
49//! ```
50//! use ferrolearn_linear::ElasticNet;
51//! use ferrolearn_core::{Fit, Predict};
52//! use ndarray::{array, Array1, Array2};
53//!
54//! let model = ElasticNet::<f64>::new()
55//!     .with_alpha(0.1)
56//!     .with_l1_ratio(0.5);
57//! let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
58//! let y = array![2.0, 4.0, 6.0, 8.0];
59//!
60//! let fitted = model.fit(&x, &y).unwrap();
61//! let preds = fitted.predict(&x).unwrap();
62//! ```
63
64use crate::lasso::CoordSelection;
65use ferrolearn_core::error::FerroError;
66use ferrolearn_core::introspection::HasCoefficients;
67use ferrolearn_core::pipeline::{FittedPipelineEstimator, PipelineEstimator};
68use ferrolearn_core::traits::{Fit, Predict};
69use ndarray::{Array1, Array2, Axis, ScalarOperand};
70use num_traits::{Float, FromPrimitive};
71use rand::SeedableRng;
72use rand::seq::SliceRandom;
73
74/// ElasticNet regression (L1 + L2 regularized least squares).
75///
76/// Minimizes a combination of L1 and L2 penalties controlled by
77/// `alpha` and `l1_ratio`. Uses coordinate descent with soft-thresholding
78/// to handle the non-smooth L1 component.
79///
80/// # Type Parameters
81///
82/// - `F`: The floating-point type (`f32` or `f64`).
83#[derive(Debug, Clone)]
84pub struct ElasticNet<F> {
85    /// Overall regularization strength. Larger values enforce stronger
86    /// regularization.
87    pub alpha: F,
88    /// Mix between L1 and L2 regularization.
89    /// - `l1_ratio = 1.0` → pure Lasso (L1 only)
90    /// - `l1_ratio = 0.0` → pure Ridge (L2 only)
91    /// - `0.0 < l1_ratio < 1.0` → ElasticNet blend
92    pub l1_ratio: F,
93    /// Maximum number of coordinate descent iterations.
94    pub max_iter: usize,
95    /// Convergence tolerance on the maximum coefficient change per pass.
96    pub tol: F,
97    /// Whether to fit an intercept (bias) term.
98    pub fit_intercept: bool,
99    /// When `true`, constrain coefficients to be non-negative.
100    pub positive: bool,
101    /// When `true`, run coordinate descent on the precomputed Gram matrix
102    /// `Q = Xcᵀ Xc` and `q = Xcᵀ yc` instead of recomputing residuals each
103    /// pass.
104    ///
105    /// Mirrors sklearn `ElasticNet(precompute=False)` (`_coordinate_descent.py:774`);
106    /// the Gram path runs sklearn's `enet_coordinate_descent_gram`
107    /// (`_cd_fast.pyx`) with the ElasticNet L2 term `α·(1−l1_ratio)` in the
108    /// denominator. Reaches the same unique optimum (differing only at
109    /// floating-point reassociation level, ~1e-10).
110    pub precompute: bool,
111    /// Order in which coordinates are visited each coordinate-descent sweep.
112    ///
113    /// Mirrors sklearn `ElasticNet(selection=...)` (default `Cyclic`).
114    pub selection: CoordSelection,
115    /// Seed for the RNG used when `selection == CoordSelection::Random`.
116    ///
117    /// Mirrors sklearn `ElasticNet(random_state=...)` (default `None`). `None`
118    /// falls back to seed `0`.
119    pub random_state: Option<u64>,
120    /// When `true`, initialize coordinate descent from [`ElasticNet::coef_init`]
121    /// (the prior solution) instead of zeros.
122    ///
123    /// Mirrors sklearn `ElasticNet(warm_start=False)`
124    /// (`_coordinate_descent.py:795`), which "reuse[s] the solution of the
125    /// previous call to fit as initialization" (`:796`). In sklearn the prior
126    /// solution is the mutable estimator's own `self.coef_`, reused when
127    /// `warm_start` is set (`_coordinate_descent.py:1062-1063`: `if not
128    /// self.warm_start or not hasattr(self, "coef_"): coef_ = np.zeros(...)`).
129    ///
130    /// R-DEV-4 adaptation: ferrolearn estimators are immutable value types —
131    /// there is no mutable `self.coef_` carried across repeated `.fit()` calls.
132    /// So the prior coefficient vector is supplied EXPLICITLY through
133    /// [`ElasticNet::coef_init`] rather than read off the estimator. The numerics
134    /// are identical: CD starts from `coef_init` instead of zeros.
135    pub warm_start: bool,
136    /// Explicit coordinate-descent initialization vector used when
137    /// [`ElasticNet::warm_start`] is `true` (the R-DEV-4 stand-in for sklearn's
138    /// reused `self.coef_`).
139    ///
140    /// Mirrors the `coef_init` seed fed to the path solver
141    /// (`_coordinate_descent.py:648-651`: `coef_ = np.zeros(...)` when
142    /// `coef_init is None`, else `coef_ = np.asfortranarray(coef_init, ...)`).
143    /// `None` (the default) — or `warm_start == false` — initializes `w` to
144    /// zeros, the byte-identical cold-start path. When `Some`, its length must
145    /// equal `n_features`.
146    pub coef_init: Option<Array1<F>>,
147}
148
149impl<F: Float + FromPrimitive> ElasticNet<F> {
150    /// Create a new `ElasticNet` with default settings.
151    ///
152    /// Defaults: `alpha = 1.0`, `l1_ratio = 0.5`, `max_iter = 1000`,
153    /// `tol = 1e-4`, `fit_intercept = true`.
154    #[must_use]
155    pub fn new() -> Self {
156        Self {
157            alpha: F::one(),
158            l1_ratio: F::from(0.5).unwrap(),
159            max_iter: 1000,
160            tol: F::from(1e-4).unwrap(),
161            fit_intercept: true,
162            positive: false,
163            precompute: false,
164            selection: CoordSelection::Cyclic,
165            random_state: None,
166            warm_start: false,
167            coef_init: None,
168        }
169    }
170
171    /// Set the overall regularization strength.
172    #[must_use]
173    pub fn with_alpha(mut self, alpha: F) -> Self {
174        self.alpha = alpha;
175        self
176    }
177
178    /// Set the L1/L2 mixing ratio.
179    ///
180    /// Must be in `[0.0, 1.0]`. Values outside this range will be rejected
181    /// at fit time.
182    #[must_use]
183    pub fn with_l1_ratio(mut self, l1_ratio: F) -> Self {
184        self.l1_ratio = l1_ratio;
185        self
186    }
187
188    /// Set the maximum number of coordinate descent iterations.
189    #[must_use]
190    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
191        self.max_iter = max_iter;
192        self
193    }
194
195    /// Set the convergence tolerance on maximum coefficient change.
196    #[must_use]
197    pub fn with_tol(mut self, tol: F) -> Self {
198        self.tol = tol;
199        self
200    }
201
202    /// Set whether to fit an intercept term.
203    #[must_use]
204    pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
205        self.fit_intercept = fit_intercept;
206        self
207    }
208
209    /// Set whether to constrain coefficients to be non-negative.
210    ///
211    /// Mirrors `sklearn.linear_model.ElasticNet(positive=...)`.
212    #[must_use]
213    pub fn with_positive(mut self, positive: bool) -> Self {
214        self.positive = positive;
215        self
216    }
217
218    /// Set whether to run coordinate descent on the precomputed Gram matrix.
219    ///
220    /// Mirrors `sklearn.linear_model.ElasticNet(precompute=...)`
221    /// (`_coordinate_descent.py:774`); `true` selects sklearn's
222    /// `enet_coordinate_descent_gram` (`_cd_fast.pyx`), with the ElasticNet
223    /// L2 term `α·(1−l1_ratio)` in the per-coordinate denominator.
224    #[must_use]
225    pub fn with_precompute(mut self, precompute: bool) -> Self {
226        self.precompute = precompute;
227        self
228    }
229
230    /// Set the coordinate-selection order for coordinate descent.
231    ///
232    /// Mirrors `sklearn.linear_model.ElasticNet(selection=...)`.
233    #[must_use]
234    pub fn with_selection(mut self, selection: CoordSelection) -> Self {
235        self.selection = selection;
236        self
237    }
238
239    /// Set the RNG seed used when `selection == CoordSelection::Random`.
240    ///
241    /// Mirrors `sklearn.linear_model.ElasticNet(random_state=...)`.
242    #[must_use]
243    pub fn with_random_state(mut self, seed: u64) -> Self {
244        self.random_state = Some(seed);
245        self
246    }
247
248    /// Enable/disable warm-start coordinate-descent initialization.
249    ///
250    /// Mirrors `sklearn.linear_model.ElasticNet(warm_start=...)`
251    /// (`_coordinate_descent.py:795`): when `true`, "reuse the solution of the
252    /// previous call to fit as initialization". R-DEV-4: ferrolearn estimators
253    /// are immutable value types with no mutable `self.coef_` carried across
254    /// `.fit()` calls, so the prior solution is supplied explicitly via
255    /// [`ElasticNet::with_coef_init`]; `warm_start` only gates whether that
256    /// vector (when present) is used instead of zeros.
257    #[must_use]
258    pub fn with_warm_start(mut self, warm_start: bool) -> Self {
259        self.warm_start = warm_start;
260        self
261    }
262
263    /// Provide the explicit coordinate-descent initialization vector used when
264    /// [`ElasticNet::warm_start`] is `true`.
265    ///
266    /// R-DEV-4 adaptation of sklearn's reused `self.coef_`
267    /// (`_coordinate_descent.py:1062-1063`, seeded into the path solver's
268    /// `coef_init` at `:648-651`): because ferrolearn estimators are immutable
269    /// value types, the prior coefficient vector is passed in explicitly rather
270    /// than read off a mutated estimator. Its length must equal `n_features` at
271    /// fit time, else [`Fit::fit`] returns [`FerroError::ShapeMismatch`].
272    #[must_use]
273    pub fn with_coef_init(mut self, coef: Array1<F>) -> Self {
274        self.coef_init = Some(coef);
275        self
276    }
277}
278
279impl<F: Float + FromPrimitive> Default for ElasticNet<F> {
280    fn default() -> Self {
281        Self::new()
282    }
283}
284
285/// Fitted ElasticNet regression model.
286///
287/// Stores the learned (potentially sparse) coefficients and intercept.
288/// Implements [`Predict`] and [`HasCoefficients`].
289#[derive(Debug, Clone)]
290pub struct FittedElasticNet<F> {
291    /// Learned coefficient vector (some may be exactly zero when L1 > 0).
292    coefficients: Array1<F>,
293    /// Learned intercept (bias) term.
294    intercept: F,
295    /// Number of full coordinate-descent sweeps performed before
296    /// convergence/break (mirrors sklearn `ElasticNet.n_iter_`).
297    n_iter: usize,
298    /// Duality gap at the returned solution (mirrors sklearn `ElasticNet.dual_gap_`).
299    dual_gap: F,
300}
301
302impl<F: Float> FittedElasticNet<F> {
303    /// Returns the intercept (bias) term learned during fitting.
304    pub fn intercept(&self) -> F {
305        self.intercept
306    }
307
308    /// Number of coordinate-descent sweeps run by the solver.
309    ///
310    /// Mirrors sklearn's `ElasticNet.n_iter_` attribute
311    /// (`_coordinate_descent.py:827`). ferrolearn uses sklearn's relative-change
312    /// and dual-gap stopping criterion (REQ-13, `_cd_fast.pyx:167-249`), so this
313    /// 1-based count matches sklearn's `n_iter_` value exactly at the same
314    /// optimum.
315    #[must_use]
316    pub fn n_iter(&self) -> usize {
317        self.n_iter
318    }
319
320    /// Duality gap at the returned solution, on the `(1/2n)`-scaled objective.
321    ///
322    /// Mirrors sklearn's `ElasticNet.dual_gap_` attribute
323    /// (`_coordinate_descent.py:831`); computed by [`enet_dual_gap`] on the same
324    /// (centered/raw) design the coordinate descent solved.
325    #[must_use]
326    pub fn dual_gap(&self) -> F {
327        self.dual_gap
328    }
329}
330
331/// ElasticNet duality gap on the `(1/2n)`-scaled objective, mirroring sklearn's
332/// `enet_coordinate_descent` gap (`_cd_fast.pyx:216-247`) with the final `/n`
333/// mapping sklearn's un-normalized `(1/2)||y−Xw||² + l1_reg·||w||₁ +
334/// (1/2)·l2_reg·||w||²` (`l1_reg = alpha·l1_ratio·n`, `l2_reg =
335/// alpha·(1−l1_ratio)·n`, `_coordinate_descent.py:655-656`) back to ferrolearn's
336/// `(1/2n)` scaling. Reduces to the Lasso gap when `l1_ratio = 1` (`beta = 0`).
337///
338/// `xc`/`yc` are the design the coordinate descent actually solved on
339/// (centered when `fit_intercept`, raw otherwise); `w` is the fitted coef.
340pub(crate) fn enet_dual_gap<F>(
341    xc: &Array2<F>,
342    yc: &Array1<F>,
343    w: &Array1<F>,
344    alpha: F,
345    l1_ratio: F,
346) -> F
347where
348    F: Float + ScalarOperand + 'static,
349{
350    let n = xc.nrows();
351    let n_f = F::from(n).unwrap_or_else(F::one);
352
353    // R = yc − Xc·w
354    let residual = yc - &xc.dot(w);
355
356    // l1_reg = alpha · l1_ratio · n ; beta = alpha · (1 − l1_ratio) · n.
357    let l1_reg = alpha * l1_ratio * n_f;
358    let beta = alpha * (F::one() - l1_ratio) * n_f;
359
360    // XtA = Xcᵀ·R − beta·w ; dual_norm_XtA = max(|XtA[j]|).
361    let xt_a = xc.t().dot(&residual) - &(w * beta);
362    let dual_norm_xt_a = xt_a.iter().fold(F::zero(), |acc, &v| acc.max(v.abs()));
363
364    let r_norm2 = residual.dot(&residual);
365    let w_norm2 = w.dot(w);
366
367    let (const_factor, mut gap) = if dual_norm_xt_a > l1_reg {
368        let c = l1_reg / dual_norm_xt_a;
369        let half = F::from(0.5).unwrap_or_else(F::one);
370        (c, half * (r_norm2 + r_norm2 * c * c))
371    } else {
372        (F::one(), r_norm2)
373    };
374
375    // l1_norm = ‖w‖₁
376    let l1_norm = w.iter().fold(F::zero(), |acc, &wj| acc + wj.abs());
377    // R · yc
378    let r_dot_y = residual.dot(yc);
379    let half = F::from(0.5).unwrap_or_else(F::one);
380
381    gap = gap + l1_reg * l1_norm - const_factor * r_dot_y
382        + half * beta * (F::one() + const_factor * const_factor) * w_norm2;
383
384    gap / n_f
385}
386
387/// Soft-thresholding operator used in coordinate descent for L1 penalty.
388///
389/// Returns `sign(x) * max(|x| - threshold, 0)`.
390#[inline]
391fn soft_threshold<F: Float>(x: F, threshold: F) -> F {
392    if x > threshold {
393        x - threshold
394    } else if x < -threshold {
395        x + threshold
396    } else {
397        F::zero()
398    }
399}
400
401/// Non-negative soft-thresholding operator for `positive=True` ElasticNet.
402///
403/// Returns `max(x - threshold, 0)`, dropping the negative branch so the
404/// coordinate is never negative. Mirrors sklearn `_cd_fast.pyx:191-195`
405/// (`if positive and tmp < 0: w[ii] = 0.0`); the L2 term lives in the
406/// denominator and is unaffected.
407#[inline]
408fn soft_threshold_positive<F: Float>(x: F, threshold: F) -> F {
409    let z = x - threshold;
410    if z > F::zero() { z } else { F::zero() }
411}
412
413impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + 'static> Fit<Array2<F>, Array1<F>>
414    for ElasticNet<F>
415{
416    type Fitted = FittedElasticNet<F>;
417    type Error = FerroError;
418
419    /// Fit the ElasticNet model using coordinate descent.
420    ///
421    /// Centers the data if `fit_intercept` is `true`, then alternates
422    /// coordinate updates using the soft-threshold rule with L2 scaling.
423    ///
424    /// # Errors
425    ///
426    /// - [`FerroError::ShapeMismatch`] if `x` and `y` have different numbers
427    ///   of samples.
428    /// - [`FerroError::InvalidParameter`] if `alpha` is negative, `l1_ratio`
429    ///   is outside `[0, 1]`, or `tol` is non-positive.
430    /// - [`FerroError::InsufficientSamples`] if `n_samples == 0`.
431    fn fit(&self, x: &Array2<F>, y: &Array1<F>) -> Result<FittedElasticNet<F>, FerroError> {
432        let (n_samples, n_features) = x.dim();
433
434        if n_samples != y.len() {
435            return Err(FerroError::ShapeMismatch {
436                expected: vec![n_samples],
437                actual: vec![y.len()],
438                context: "y length must match number of samples in X".into(),
439            });
440        }
441
442        if self.alpha < F::zero() {
443            return Err(FerroError::InvalidParameter {
444                name: "alpha".into(),
445                reason: "must be non-negative".into(),
446            });
447        }
448
449        if self.l1_ratio < F::zero() || self.l1_ratio > F::one() {
450            return Err(FerroError::InvalidParameter {
451                name: "l1_ratio".into(),
452                reason: "must be in [0, 1]".into(),
453            });
454        }
455
456        if n_samples == 0 {
457            return Err(FerroError::InsufficientSamples {
458                required: 1,
459                actual: 0,
460                context: "ElasticNet requires at least one sample".into(),
461            });
462        }
463
464        // sklearn `ElasticNet.fit` -> `self._validate_data(X, y, ...)`
465        // (`_coordinate_descent.py:980`); the call keeps the default
466        // `force_all_finite=True`, so `check_array` rejects any NaN or +/-inf in
467        // X OR y with a `ValueError` BEFORE coordinate descent runs.
468        // `.iter().any(|v| !v.is_finite())` rejects both NaN and Inf (bounds-safe,
469        // no panic, R-CODE-2), matching the crate idiom (`multi_task_lasso.rs`).
470        // (#2256)
471        if x.iter().any(|v| !v.is_finite()) {
472            return Err(FerroError::InvalidParameter {
473                name: "X".into(),
474                reason: "Input X contains NaN or infinity.".into(),
475            });
476        }
477        if y.iter().any(|v| !v.is_finite()) {
478            return Err(FerroError::InvalidParameter {
479                name: "y".into(),
480                reason: "Input y contains NaN or infinity.".into(),
481            });
482        }
483
484        let n_f = F::from(n_samples).ok_or_else(|| FerroError::NumericalInstability {
485            message: "failed to convert n_samples to float".into(),
486        })?;
487
488        // Center data when fitting intercept.
489        let (x_work, y_work, x_mean, y_mean) = if self.fit_intercept {
490            let x_mean = x
491                .mean_axis(Axis(0))
492                .ok_or_else(|| FerroError::NumericalInstability {
493                    message: "failed to compute column means".into(),
494                })?;
495            let y_mean = y.mean().ok_or_else(|| FerroError::NumericalInstability {
496                message: "failed to compute target mean".into(),
497            })?;
498
499            let x_c = x - &x_mean;
500            let y_c = y - y_mean;
501            (x_c, y_c, Some(x_mean), Some(y_mean))
502        } else {
503            (x.clone(), y.clone(), None, None)
504        };
505
506        // Precompute per-column X_j^T X_j / n (used as denominator).
507        let col_norms: Vec<F> = (0..n_features)
508            .map(|j| {
509                let col = x_work.column(j);
510                col.dot(&col) / n_f
511            })
512            .collect();
513
514        // L1 and L2 penalty strengths split from alpha/l1_ratio.
515        let alpha_l1 = self.alpha * self.l1_ratio;
516        let alpha_l2 = self.alpha * (F::one() - self.l1_ratio);
517
518        // Effective denominator per column: (X_j^T X_j / n) + alpha_l2.
519        let denominators: Vec<F> = col_norms.iter().map(|&cn| cn + alpha_l2).collect();
520
521        // Initialize coefficients. Cold start (default) is zeros; warm start
522        // reuses the explicit `coef_init` (the R-DEV-4 stand-in for sklearn's
523        // reused mutable `self.coef_`, `_coordinate_descent.py:1062-1063`/
524        // `:648-651`). `warm_start == false` or `coef_init == None` is the
525        // byte-identical zeros path.
526        let mut w = if self.warm_start
527            && let Some(coef) = &self.coef_init
528        {
529            if coef.len() != n_features {
530                return Err(FerroError::ShapeMismatch {
531                    expected: vec![n_features],
532                    actual: vec![coef.len()],
533                    context: "coef_init length must equal number of features".into(),
534                });
535            }
536            coef.clone()
537        } else {
538            Array1::<F>::zeros(n_features)
539        };
540        // Keep the (centered/raw) target for the final dual-gap computation;
541        // the CD loop consumes a working copy into `residual`.
542        let target = y_work.clone();
543        let mut residual = y_work;
544
545        // sklearn's stopping criterion (`_cd_fast.pyx:144-249`):
546        //  - `d_w_tol = tol` is the UN-scaled relative-change gate (`:144`);
547        //  - `tol_scaled = tol · (target·target)` is the gap threshold,
548        //    `tol *= dot(y, y)` at `:167-168` (`target` is the centered/raw
549        //    target the CD actually solves on).
550        let d_w_tol = self.tol;
551        let tol_scaled = self.tol * target.dot(&target);
552
553        // For `selection == Random`, build the RNG ONCE before the sweep loop
554        // and reuse a reusable index buffer; each sweep shuffles the visiting
555        // order (sklearn `_cd_fast.pyx` `enet_coordinate_descent` `random`
556        // branch picks `ii` via `rand_int` instead of the cyclic `f_iter`).
557        // `Cyclic` keeps the byte-identical `0..n_features` order, so the
558        // per-coordinate update math AND the dual-gap stopping criterion
559        // (REQ-13) stay unchanged.
560        let mut rng = rand::rngs::StdRng::seed_from_u64(self.random_state.unwrap_or(0));
561        let mut order: Vec<usize> = (0..n_features).collect();
562
563        let mut n_iter = 0_usize;
564
565        // REQ-11: Gram (precompute) coordinate-descent path. Mirrors sklearn's
566        // `enet_coordinate_descent_gram` (`_cd_fast.pyx`): run CD on the
567        // precomputed `Q = Xcᵀ Xc` and `q = Xcᵀ yc`, maintaining `H = Q·w`
568        // incrementally instead of recomputing residuals each sweep.
569        // Algebraically identical to the direct path (`Xⱼᵀr = q[j] − (Q·w)[j]`),
570        // so it converges to the same unique optimum (to fp reassociation,
571        // ~1e-10). Keeps the SAME `(1/n)` normalization, the same L1 threshold
572        // `alpha_l1` and L2-in-the-denominator `alpha_l2`, the same coordinate
573        // visiting order, and the same dual-gap stopping criterion as the direct
574        // path so `n_iter_` matches.
575        if self.precompute {
576            // Q = Xcᵀ Xc  (n_features × n_features); q = Xcᵀ yc (here `residual`
577            // still equals the centered/raw target — it is not yet adjusted for
578            // a warm-start `w` since the Gram path tracks `H = Q·w` instead).
579            let gram = x_work.t().dot(&x_work);
580            let q = x_work.t().dot(&residual);
581            // H = Q·w  (zeros for a cold start where `w == 0`; the actual `Q·w`
582            // for a warm start, so `tmp = (q[j] − H[j])/n + col_norms[j]·w[j]`
583            // is correct from the first sweep regardless of the init).
584            let mut h = gram.dot(&w);
585
586            for iter in 0..self.max_iter {
587                n_iter = iter + 1;
588                let mut w_max = F::zero();
589                let mut d_w_max = F::zero();
590
591                if self.selection == CoordSelection::Random {
592                    order.shuffle(&mut rng);
593                }
594
595                for &j in &order {
596                    let w_old = w[j];
597                    // tmp ≡ direct `rho_j` + (XⱼᵀXⱼ/n)·w_old:
598                    // (q[j] − H[j])/n + col_norms[j]·w[j], since
599                    // Xⱼᵀr = q[j] − (Q·w)[j] and col_norms[j] = Q[j,j]/n.
600                    let tmp = (q[j] - h[j]) / n_f + col_norms[j] * w_old;
601
602                    // Soft-threshold for L1 (alpha_l1), then divide by the
603                    // ElasticNet denominator (col_norm + alpha_l2). Identical
604                    // to the direct path's per-coordinate update, just
605                    // Gram-sourced.
606                    let w_new = if denominators[j] > F::zero() {
607                        let thresholded = if self.positive {
608                            soft_threshold_positive(tmp, alpha_l1)
609                        } else {
610                            soft_threshold(tmp, alpha_l1)
611                        };
612                        thresholded / denominators[j]
613                    } else {
614                        F::zero()
615                    };
616
617                    if w_new != w_old {
618                        // H += (w_new − w_old) · Q.column(j).
619                        let delta = w_new - w_old;
620                        let col = gram.column(j);
621                        for i in 0..n_features {
622                            h[i] = h[i] + delta * col[i];
623                        }
624                    }
625
626                    let change = (w_new - w_old).abs();
627                    if change > d_w_max {
628                        d_w_max = change;
629                    }
630                    if w_new.abs() > w_max {
631                        w_max = w_new.abs();
632                    }
633
634                    w[j] = w_new;
635                }
636
637                // SAME dual-gap stopping as the direct path: reuse the
638                // residual-based `enet_dual_gap` on (x_work, target) — equal to
639                // the Gram gap to fp precision, so `n_iter_` matches.
640                let last_iter = iter == self.max_iter - 1;
641                if w_max == F::zero() || d_w_max / w_max < d_w_tol || last_iter {
642                    let dual_gap = enet_dual_gap(&x_work, &target, &w, self.alpha, self.l1_ratio);
643                    let gap_raw = dual_gap * n_f;
644
645                    if gap_raw < tol_scaled {
646                        let intercept = compute_intercept(&x_mean, &y_mean, &w);
647                        return Ok(FittedElasticNet {
648                            coefficients: w,
649                            intercept,
650                            n_iter,
651                            dual_gap,
652                        });
653                    }
654                }
655            }
656
657            // Did not converge within max_iter; return the current solution.
658            let intercept = compute_intercept(&x_mean, &y_mean, &w);
659            let dual_gap = enet_dual_gap(&x_work, &target, &w, self.alpha, self.l1_ratio);
660            return Ok(FittedElasticNet {
661                coefficients: w,
662                intercept,
663                n_iter,
664                dual_gap,
665            });
666        }
667
668        // Direct path: the CD loop maintains `residual = y_work − X_work·w`,
669        // adding back `X_j·w_old` per coordinate before recomputing `rho_j`. With
670        // a non-zero warm-start `w`, seed the residual with that running
671        // contribution removed. For the cold path (`w == 0`) `X_work·w` is the
672        // zero vector and the subtraction is a byte-identical no-op, so this is
673        // gated on warm start to leave the default path provably untouched.
674        if self.warm_start && self.coef_init.is_some() {
675            residual = &residual - &x_work.dot(&w);
676        }
677
678        for iter in 0..self.max_iter {
679            n_iter = iter + 1;
680            let mut w_max = F::zero();
681            let mut d_w_max = F::zero();
682
683            if self.selection == CoordSelection::Random {
684                order.shuffle(&mut rng);
685            }
686
687            for &j in &order {
688                let col_j = x_work.column(j);
689                let w_old = w[j];
690
691                // Add back contribution of current coefficient j to residual.
692                if w_old != F::zero() {
693                    for i in 0..n_samples {
694                        residual[i] = residual[i] + col_j[i] * w_old;
695                    }
696                }
697
698                // Unpenalized correlation: X_j^T r / n.
699                let rho_j = col_j.dot(&residual) / n_f;
700
701                // Apply soft-threshold for L1, then divide by (col_norm + alpha_l2).
702                // For `positive=True`, use the non-negative soft-threshold so the
703                // coefficient is never negative (sklearn `_cd_fast.pyx:191-195`); the
704                // L2 term in the denominator is unchanged.
705                let w_new = if denominators[j] > F::zero() {
706                    let thresholded = if self.positive {
707                        soft_threshold_positive(rho_j, alpha_l1)
708                    } else {
709                        soft_threshold(rho_j, alpha_l1)
710                    };
711                    thresholded / denominators[j]
712                } else {
713                    F::zero()
714                };
715
716                // Update residual with new coefficient.
717                if w_new != F::zero() {
718                    for i in 0..n_samples {
719                        residual[i] = residual[i] - col_j[i] * w_new;
720                    }
721                }
722
723                // Track the largest coordinate update and the largest
724                // coefficient magnitude this sweep (`_cd_fast.pyx:201-205`).
725                let change = (w_new - w_old).abs();
726                if change > d_w_max {
727                    d_w_max = change;
728                }
729                if w_new.abs() > w_max {
730                    w_max = w_new.abs();
731                }
732
733                w[j] = w_new;
734            }
735
736            // sklearn's two-level convergence gate (`_cd_fast.pyx:207-251`):
737            // only when coordinates barely moved (relative gate) or on the
738            // last iteration do we compute the (expensive) dual gap, and we
739            // break only if the UN-normalized gap clears `tol · (target·target)`.
740            let last_iter = iter == self.max_iter - 1;
741            if w_max == F::zero() || d_w_max / w_max < d_w_tol || last_iter {
742                // `enet_dual_gap` returns the gap divided by `n` (the
743                // `dual_gap_` attribute scaling, REQ-12); multiply back to the
744                // un-normalized objective sklearn compares against
745                // `tol · (target·target)` (`:249`). The L2/beta term is already
746                // included in `enet_dual_gap`.
747                let dual_gap = enet_dual_gap(&x_work, &target, &w, self.alpha, self.l1_ratio);
748                let gap_raw = dual_gap * n_f;
749
750                if gap_raw < tol_scaled {
751                    let intercept = compute_intercept(&x_mean, &y_mean, &w);
752                    return Ok(FittedElasticNet {
753                        coefficients: w,
754                        intercept,
755                        n_iter,
756                        dual_gap,
757                    });
758                }
759            }
760        }
761
762        // Return best solution found even without full convergence.
763        let intercept = compute_intercept(&x_mean, &y_mean, &w);
764        let dual_gap = enet_dual_gap(&x_work, &target, &w, self.alpha, self.l1_ratio);
765        Ok(FittedElasticNet {
766            coefficients: w,
767            intercept,
768            n_iter,
769            dual_gap,
770        })
771    }
772}
773
774/// Compute intercept from the centered means and fitted coefficients.
775fn compute_intercept<F: Float + 'static>(
776    x_mean: &Option<Array1<F>>,
777    y_mean: &Option<F>,
778    w: &Array1<F>,
779) -> F {
780    if let (Some(xm), Some(ym)) = (x_mean, y_mean) {
781        *ym - xm.dot(w)
782    } else {
783        F::zero()
784    }
785}
786
787impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>> for FittedElasticNet<F> {
788    type Output = Array1<F>;
789    type Error = FerroError;
790
791    /// Predict target values for the given feature matrix.
792    ///
793    /// Computes `X @ coefficients + intercept`.
794    ///
795    /// # Errors
796    ///
797    /// Returns [`FerroError::ShapeMismatch`] if the number of features
798    /// does not match the fitted model.
799    fn predict(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
800        let n_features = x.ncols();
801        if n_features != self.coefficients.len() {
802            return Err(FerroError::ShapeMismatch {
803                expected: vec![self.coefficients.len()],
804                actual: vec![n_features],
805                context: "number of features must match fitted model".into(),
806            });
807        }
808
809        let preds = x.dot(&self.coefficients) + self.intercept;
810        Ok(preds)
811    }
812}
813
814impl<F: Float + Send + Sync + ScalarOperand + 'static> HasCoefficients<F> for FittedElasticNet<F> {
815    /// Returns the learned coefficient vector.
816    fn coefficients(&self) -> &Array1<F> {
817        &self.coefficients
818    }
819
820    /// Returns the learned intercept term.
821    fn intercept(&self) -> F {
822        self.intercept
823    }
824}
825
826// Pipeline integration.
827impl<F> PipelineEstimator<F> for ElasticNet<F>
828where
829    F: Float + FromPrimitive + ScalarOperand + Send + Sync + 'static,
830{
831    /// Fit the model and return it as a boxed pipeline estimator.
832    ///
833    /// # Errors
834    ///
835    /// Propagates any [`FerroError`] from `fit`.
836    fn fit_pipeline(
837        &self,
838        x: &Array2<F>,
839        y: &Array1<F>,
840    ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
841        let fitted = self.fit(x, y)?;
842        Ok(Box::new(fitted))
843    }
844}
845
846impl<F> FittedPipelineEstimator<F> for FittedElasticNet<F>
847where
848    F: Float + ScalarOperand + Send + Sync + 'static,
849{
850    /// Generate predictions via the pipeline interface.
851    ///
852    /// # Errors
853    ///
854    /// Propagates any [`FerroError`] from `predict`.
855    fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
856        self.predict(x)
857    }
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863    use approx::assert_relative_eq;
864    use ndarray::array;
865
866    // ---- soft_threshold helpers ----
867
868    #[test]
869    fn test_soft_threshold_positive() {
870        assert_relative_eq!(soft_threshold(5.0_f64, 1.0), 4.0);
871    }
872
873    #[test]
874    fn test_soft_threshold_negative() {
875        assert_relative_eq!(soft_threshold(-5.0_f64, 1.0), -4.0);
876    }
877
878    #[test]
879    fn test_soft_threshold_within_band() {
880        assert_relative_eq!(soft_threshold(0.5_f64, 1.0), 0.0);
881        assert_relative_eq!(soft_threshold(-0.5_f64, 1.0), 0.0);
882        assert_relative_eq!(soft_threshold(0.0_f64, 1.0), 0.0);
883    }
884
885    // ---- Builder ----
886
887    #[test]
888    fn test_default_builder() {
889        let m = ElasticNet::<f64>::new();
890        assert_relative_eq!(m.alpha, 1.0);
891        assert_relative_eq!(m.l1_ratio, 0.5);
892        assert_eq!(m.max_iter, 1000);
893        assert!(m.fit_intercept);
894    }
895
896    #[test]
897    fn test_builder_setters() {
898        let m = ElasticNet::<f64>::new()
899            .with_alpha(0.5)
900            .with_l1_ratio(0.2)
901            .with_max_iter(500)
902            .with_tol(1e-6)
903            .with_fit_intercept(false);
904        assert_relative_eq!(m.alpha, 0.5);
905        assert_relative_eq!(m.l1_ratio, 0.2);
906        assert_eq!(m.max_iter, 500);
907        assert!(!m.fit_intercept);
908    }
909
910    // ---- Validation errors ----
911
912    #[test]
913    fn test_negative_alpha_error() {
914        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
915        let y = array![1.0, 2.0, 3.0];
916        let result = ElasticNet::<f64>::new().with_alpha(-1.0).fit(&x, &y);
917        assert!(result.is_err());
918    }
919
920    #[test]
921    fn test_l1_ratio_out_of_range_error() {
922        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
923        let y = array![1.0, 2.0, 3.0];
924        let result = ElasticNet::<f64>::new().with_l1_ratio(1.5).fit(&x, &y);
925        assert!(result.is_err());
926    }
927
928    #[test]
929    fn test_shape_mismatch_error() {
930        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
931        let y = array![1.0, 2.0];
932        let result = ElasticNet::<f64>::new().fit(&x, &y);
933        assert!(result.is_err());
934    }
935
936    // ---- Correctness ----
937
938    #[test]
939    fn test_lasso_limit_l1_ratio_one() {
940        // With l1_ratio=1, ElasticNet should behave like Lasso.
941        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
942        let y = array![3.0, 5.0, 7.0, 9.0, 11.0];
943
944        let model = ElasticNet::<f64>::new().with_alpha(0.0).with_l1_ratio(1.0);
945        let fitted = model.fit(&x, &y).unwrap();
946
947        assert_relative_eq!(fitted.coefficients()[0], 2.0, epsilon = 1e-4);
948        assert_relative_eq!(fitted.intercept(), 1.0, epsilon = 1e-4);
949    }
950
951    #[test]
952    fn test_ridge_limit_l1_ratio_zero() {
953        // With l1_ratio=0 and alpha=0, should recover OLS.
954        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
955        let y = array![3.0, 5.0, 7.0, 9.0, 11.0];
956
957        let model = ElasticNet::<f64>::new().with_alpha(0.0).with_l1_ratio(0.0);
958        let fitted = model.fit(&x, &y).unwrap();
959
960        assert_relative_eq!(fitted.coefficients()[0], 2.0, epsilon = 1e-4);
961        assert_relative_eq!(fitted.intercept(), 1.0, epsilon = 1e-4);
962    }
963
964    #[test]
965    fn test_sparsity_with_high_l1_ratio() {
966        // High alpha with l1_ratio=1 should zero out irrelevant features.
967        let x = Array2::from_shape_vec(
968            (10, 3),
969            vec![
970                1.0, 0.0, 0.0, 2.0, 0.0, 0.0, 3.0, 0.0, 0.0, 4.0, 0.0, 0.0, 5.0, 0.0, 0.0, 6.0,
971                0.0, 0.0, 7.0, 0.0, 0.0, 8.0, 0.0, 0.0, 9.0, 0.0, 0.0, 10.0, 0.0, 0.0,
972            ],
973        )
974        .unwrap();
975        let y = array![2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0, 16.0, 18.0, 20.0];
976
977        let model = ElasticNet::<f64>::new().with_alpha(5.0).with_l1_ratio(1.0);
978        let fitted = model.fit(&x, &y).unwrap();
979
980        assert_relative_eq!(fitted.coefficients()[1], 0.0, epsilon = 1e-10);
981        assert_relative_eq!(fitted.coefficients()[2], 0.0, epsilon = 1e-10);
982    }
983
984    #[test]
985    fn test_higher_alpha_shrinks_more() {
986        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
987        let y = array![3.0, 5.0, 7.0, 9.0, 11.0];
988
989        let low = ElasticNet::<f64>::new()
990            .with_alpha(0.01)
991            .with_l1_ratio(0.5)
992            .fit(&x, &y)
993            .unwrap();
994        let high = ElasticNet::<f64>::new()
995            .with_alpha(2.0)
996            .with_l1_ratio(0.5)
997            .fit(&x, &y)
998            .unwrap();
999
1000        assert!(high.coefficients()[0].abs() <= low.coefficients()[0].abs());
1001    }
1002
1003    #[test]
1004    fn test_no_intercept() {
1005        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1006        let y = array![2.0, 4.0, 6.0, 8.0];
1007
1008        let fitted = ElasticNet::<f64>::new()
1009            .with_alpha(0.0)
1010            .with_l1_ratio(0.5)
1011            .with_fit_intercept(false)
1012            .fit(&x, &y)
1013            .unwrap();
1014
1015        assert_relative_eq!(fitted.intercept(), 0.0, epsilon = 1e-10);
1016    }
1017
1018    #[test]
1019    fn test_predict_correct_length() {
1020        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1021        let y = array![2.0, 4.0, 6.0, 8.0];
1022
1023        let fitted = ElasticNet::<f64>::new()
1024            .with_alpha(0.01)
1025            .fit(&x, &y)
1026            .unwrap();
1027        let preds = fitted.predict(&x).unwrap();
1028        assert_eq!(preds.len(), 4);
1029    }
1030
1031    #[test]
1032    fn test_predict_feature_mismatch() {
1033        let x_train = Array2::from_shape_vec((3, 2), vec![1.0, 0.0, 2.0, 0.0, 3.0, 0.0]).unwrap();
1034        let y = array![1.0, 2.0, 3.0];
1035        let fitted = ElasticNet::<f64>::new()
1036            .with_alpha(0.01)
1037            .fit(&x_train, &y)
1038            .unwrap();
1039
1040        let x_bad = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
1041        let result = fitted.predict(&x_bad);
1042        assert!(result.is_err());
1043    }
1044
1045    #[test]
1046    fn test_has_coefficients_length() {
1047        let x = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
1048        let y = array![1.0, 2.0, 3.0];
1049        let fitted = ElasticNet::<f64>::new()
1050            .with_alpha(0.1)
1051            .fit(&x, &y)
1052            .unwrap();
1053
1054        assert_eq!(fitted.coefficients().len(), 2);
1055    }
1056
1057    #[test]
1058    fn test_pipeline_integration() {
1059        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1060        let y = array![3.0, 5.0, 7.0, 9.0];
1061
1062        let model = ElasticNet::<f64>::new().with_alpha(0.01);
1063        let fitted_pipe = model.fit_pipeline(&x, &y).unwrap();
1064        let preds = fitted_pipe.predict_pipeline(&x).unwrap();
1065        assert_eq!(preds.len(), 4);
1066    }
1067
1068    // ---- positive=True (REQ-8) ----
1069
1070    #[test]
1071    fn test_soft_threshold_positive_helper() {
1072        // Non-negative branch: max(x - t, 0). Negative side clamps to 0.
1073        assert_relative_eq!(soft_threshold_positive(5.0_f64, 1.0), 4.0);
1074        assert_relative_eq!(soft_threshold_positive(-5.0_f64, 1.0), 0.0);
1075        assert_relative_eq!(soft_threshold_positive(0.5_f64, 1.0), 0.0);
1076        assert_relative_eq!(soft_threshold_positive(-0.5_f64, 1.0), 0.0);
1077        assert_relative_eq!(soft_threshold_positive(0.0_f64, 1.0), 0.0);
1078    }
1079
1080    /// Oracle fixture from live sklearn 1.5.2 (R-CHAR-3):
1081    /// `X = [[1,3],[2,1],[3,4],[4,2],[5,5],[6,1],[2,4],[5,2]]`,
1082    /// `y = X[:,0] - 2*X[:,1] + noise`.
1083    fn positive_oracle_fixture() -> (Array2<f64>, Array1<f64>) {
1084        let x: Array2<f64> = array![
1085            [1.0, 3.0],
1086            [2.0, 1.0],
1087            [3.0, 4.0],
1088            [4.0, 2.0],
1089            [5.0, 5.0],
1090            [6.0, 1.0],
1091            [2.0, 4.0],
1092            [5.0, 2.0],
1093        ];
1094        let noise = array![0.1, -0.2, 0.15, 0.0, -0.1, 0.05, 0.2, -0.05];
1095        let y: Array1<f64> = (0..8)
1096            .map(|i| 1.0 * x[[i, 0]] - 2.0 * x[[i, 1]] + noise[i])
1097            .collect();
1098        (x, y)
1099    }
1100
1101    #[test]
1102    fn elasticnet_positive_matches_sklearn() {
1103        // Live sklearn 1.5.2 oracle:
1104        //   ElasticNet(alpha=0.3, l1_ratio=0.5, positive=True)
1105        //     -> coef_ [1.13685345, 0.0], intercept_ -5.96023707
1106        //   (unconstrained ElasticNet(alpha=0.3, l1_ratio=0.5)
1107        //     -> coef_ [0.9081389, -1.7687475], intercept_ -0.29568051).
1108        let (x, y) = positive_oracle_fixture();
1109
1110        let fit_res = ElasticNet::<f64>::new()
1111            .with_alpha(0.3)
1112            .with_l1_ratio(0.5)
1113            .with_positive(true)
1114            .fit(&x, &y);
1115        assert!(fit_res.is_ok(), "positive fit should succeed");
1116        let fitted = match fit_res {
1117            Ok(f) => f,
1118            Err(_) => return,
1119        };
1120
1121        let coef = fitted.coefficients();
1122        assert_relative_eq!(coef[0], 1.13685345, epsilon = 1e-5);
1123        assert_relative_eq!(coef[1], 0.0, epsilon = 1e-5);
1124        assert_relative_eq!(fitted.intercept(), -5.96023707, epsilon = 1e-4);
1125
1126        // All coefficients are non-negative.
1127        for &c in coef.iter() {
1128            assert!(c >= 0.0, "coefficient {c} should be non-negative");
1129        }
1130
1131        // Differs materially from the unconstrained solution (~1.77 gap on
1132        // feature 1), confirming the constraint is non-tautological.
1133        let unc_res = ElasticNet::<f64>::new()
1134            .with_alpha(0.3)
1135            .with_l1_ratio(0.5)
1136            .fit(&x, &y);
1137        assert!(unc_res.is_ok(), "unconstrained fit should succeed");
1138        let unconstrained = match unc_res {
1139            Ok(f) => f,
1140            Err(_) => return,
1141        };
1142        assert!((coef[1] - unconstrained.coefficients()[1]).abs() > 1.0);
1143    }
1144
1145    #[test]
1146    fn elasticnet_positive_false_unchanged() {
1147        // positive=false (default) must be byte-identical to the plain fit.
1148        let (x, y) = positive_oracle_fixture();
1149
1150        let default_res = ElasticNet::<f64>::new()
1151            .with_alpha(0.3)
1152            .with_l1_ratio(0.5)
1153            .fit(&x, &y);
1154        assert!(default_res.is_ok(), "default fit should succeed");
1155        let default_fit = match default_res {
1156            Ok(f) => f,
1157            Err(_) => return,
1158        };
1159        let false_res = ElasticNet::<f64>::new()
1160            .with_alpha(0.3)
1161            .with_l1_ratio(0.5)
1162            .with_positive(false)
1163            .fit(&x, &y);
1164        assert!(
1165            false_res.is_ok(),
1166            "explicit positive=false fit should succeed"
1167        );
1168        let explicit_false = match false_res {
1169            Ok(f) => f,
1170            Err(_) => return,
1171        };
1172
1173        assert_eq!(
1174            default_fit.coefficients(),
1175            explicit_false.coefficients(),
1176            "positive=false must be byte-identical to the default fit"
1177        );
1178        assert_eq!(default_fit.intercept(), explicit_false.intercept());
1179    }
1180
1181    // ---- n_iter_ / dual_gap_ (REQ-12) ----
1182
1183    /// Centered fixture for the dual-gap oracle (R-CHAR-3):
1184    /// `X = [[1,2],[2,1],[3,4],[4,3],[5,5]]`, `y = [3,2.5,7.1,6,11.2]`,
1185    /// centered by column mean / target mean (the design the CD solves under
1186    /// `fit_intercept`).
1187    fn centered_dual_gap_fixture() -> Option<(Array2<f64>, Array1<f64>)> {
1188        let x: Array2<f64> = array![[1.0, 2.0], [2.0, 1.0], [3.0, 4.0], [4.0, 3.0], [5.0, 5.0],];
1189        let y: Array1<f64> = array![3.0, 2.5, 7.1, 6.0, 11.2];
1190        let x_mean = x.mean_axis(Axis(0))?;
1191        let y_mean = y.mean()?;
1192        Some((&x - &x_mean, &y - y_mean))
1193    }
1194
1195    fn raw_dual_gap_fixture() -> (Array2<f64>, Array1<f64>) {
1196        let x: Array2<f64> = array![[1.0, 2.0], [2.0, 1.0], [3.0, 4.0], [4.0, 3.0], [5.0, 5.0],];
1197        let y: Array1<f64> = array![3.0, 2.5, 7.1, 6.0, 11.2];
1198        (x, y)
1199    }
1200
1201    #[test]
1202    fn enet_dual_gap_formula_matches_numpy() {
1203        // numpy/sklearn-computed oracle points (NOT from ferrolearn),
1204        // alpha=0.3, l1_ratio=0.5:
1205        //   gap(w=[0.5,1.0])                     = 0.6369296296 (far-from-optimum)
1206        //   gap(w=[0.77323348,1.35480299])       = 0.0001057556 (the optimum)
1207        let (xc, yc) = match centered_dual_gap_fixture() {
1208            Some(f) => f,
1209            None => return,
1210        };
1211
1212        let far = enet_dual_gap(&xc, &yc, &array![0.5, 1.0], 0.3, 0.5);
1213        assert_relative_eq!(far, 0.6369296296, epsilon = 1e-5);
1214
1215        let opt = enet_dual_gap(&xc, &yc, &array![0.77323348, 1.35480299], 0.3, 0.5);
1216        assert_relative_eq!(opt, 0.0001057556, epsilon = 1e-7);
1217    }
1218
1219    #[test]
1220    fn enet_fitted_dual_gap_and_n_iter() {
1221        // ElasticNet(alpha=0.3, l1_ratio=0.5) on the same fixture: dual_gap_
1222        // converged near sklearn's 0.000106; n_iter_ within [1, max_iter].
1223        let (x, y) = raw_dual_gap_fixture();
1224
1225        let fit_res = ElasticNet::<f64>::new()
1226            .with_alpha(0.3)
1227            .with_l1_ratio(0.5)
1228            .fit(&x, &y);
1229        assert!(fit_res.is_ok(), "fit should succeed");
1230        let fitted = match fit_res {
1231            Ok(f) => f,
1232            Err(_) => return,
1233        };
1234
1235        let gap = fitted.dual_gap();
1236        assert!(gap >= 0.0, "dual_gap should be non-negative, got {gap}");
1237        assert!(gap < 1e-3, "dual_gap should be converged-small, got {gap}");
1238
1239        let n_iter = fitted.n_iter();
1240        assert!(n_iter >= 1, "n_iter should be at least 1, got {n_iter}");
1241        assert!(n_iter <= 1000, "n_iter should be <= max_iter, got {n_iter}");
1242    }
1243
1244    #[test]
1245    fn enet_fields_dont_change_coef() {
1246        // Regression guard: the additive n_iter_/dual_gap_ fields must not
1247        // perturb coef_/intercept_. Compared against sklearn's converged
1248        // coef_ = [0.77323348, 1.35480299]: with REQ-13's dual-gap stopping
1249        // criterion the stop point is identical to sklearn, so the comparison
1250        // is tight (1e-7) — matching sklearn BETTER, never loosened.
1251        let (x, y) = raw_dual_gap_fixture();
1252
1253        let fit_res = ElasticNet::<f64>::new()
1254            .with_alpha(0.3)
1255            .with_l1_ratio(0.5)
1256            .fit(&x, &y);
1257        assert!(fit_res.is_ok(), "fit should succeed");
1258        let fitted = match fit_res {
1259            Ok(f) => f,
1260            Err(_) => return,
1261        };
1262
1263        assert_relative_eq!(fitted.coefficients()[0], 0.77323348, epsilon = 1e-7);
1264        assert_relative_eq!(fitted.coefficients()[1], 1.35480299, epsilon = 1e-7);
1265    }
1266
1267    #[test]
1268    fn enet_dual_gap_stopping_matches_sklearn_coef_and_niter() {
1269        // REQ-13: sklearn's relative-change + dual-gap stopping criterion.
1270        // Live sklearn 1.5.2 oracle (R-CHAR-3):
1271        //   X=[[1,2],[2,1],[3,4],[4,3],[5,5]], y=[3,2.5,7.1,6,11.2]
1272        //   ElasticNet(alpha=0.3, l1_ratio=0.5).fit(X,y)
1273        //     -> coef_=[0.77323348, 1.35480299], n_iter_=16,
1274        //        dual_gap_=0.00010575563
1275        let (x, y) = raw_dual_gap_fixture();
1276
1277        let fit_res = ElasticNet::<f64>::new()
1278            .with_alpha(0.3)
1279            .with_l1_ratio(0.5)
1280            .fit(&x, &y);
1281        assert!(fit_res.is_ok(), "fit should succeed");
1282        let fitted = match fit_res {
1283            Ok(f) => f,
1284            Err(_) => return,
1285        };
1286
1287        // Coef matches sklearn TIGHTLY now that the stopping point is identical.
1288        assert_relative_eq!(fitted.coefficients()[0], 0.77323348, epsilon = 1e-7);
1289        assert_relative_eq!(fitted.coefficients()[1], 1.35480299, epsilon = 1e-7);
1290
1291        // n_iter_ matches sklearn's 1-based dual-gap iteration count exactly.
1292        assert_eq!(fitted.n_iter(), 16, "n_iter_ must match sklearn's 16");
1293
1294        // dual_gap_ (the /n attribute) stays the REQ-12 value.
1295        assert_relative_eq!(fitted.dual_gap(), 0.00010575563, epsilon = 1e-7);
1296    }
1297
1298    #[test]
1299    fn enet_dual_gap_stopping_second_alpha() {
1300        // Generalization check at alpha=0.1 (live sklearn 1.5.2 oracle):
1301        //   ElasticNet(alpha=0.1, l1_ratio=0.5).fit(X,y)
1302        //     -> coef_=[0.76514609, 1.47598354], n_iter_=19,
1303        //        dual_gap_=9.422349e-05
1304        let (x, y) = raw_dual_gap_fixture();
1305
1306        let fit_res = ElasticNet::<f64>::new()
1307            .with_alpha(0.1)
1308            .with_l1_ratio(0.5)
1309            .fit(&x, &y);
1310        assert!(fit_res.is_ok(), "fit should succeed");
1311        let fitted = match fit_res {
1312            Ok(f) => f,
1313            Err(_) => return,
1314        };
1315
1316        assert_relative_eq!(fitted.coefficients()[0], 0.76514609, epsilon = 1e-7);
1317        assert_relative_eq!(fitted.coefficients()[1], 1.47598354, epsilon = 1e-7);
1318        assert_eq!(fitted.n_iter(), 19, "n_iter_ must match sklearn's 19");
1319        assert_relative_eq!(fitted.dual_gap(), 9.422349e-05, epsilon = 1e-7);
1320    }
1321
1322    // ---- selection='random' + random_state (REQ-10) ----
1323
1324    /// Oracle fixture for the selection tests (R-CHAR-3, live sklearn 1.5.2):
1325    /// `X = [[1,2],[2,1],[3,4],[4,3],[5,5]]`, `y = [3,2.5,7.1,6,11.2]`,
1326    /// `alpha=0.3`, `l1_ratio=0.5`.
1327    fn selection_fixture() -> (Array2<f64>, Array1<f64>) {
1328        let x: Array2<f64> = array![[1.0, 2.0], [2.0, 1.0], [3.0, 4.0], [4.0, 3.0], [5.0, 5.0],];
1329        let y: Array1<f64> = array![3.0, 2.5, 7.1, 6.0, 11.2];
1330        (x, y)
1331    }
1332
1333    #[test]
1334    fn enet_selection_cyclic_default_unchanged() {
1335        // Default ElasticNet selection is Cyclic; coef must stay byte-identical
1336        // to the prior cyclic path. Live sklearn 1.5.2 oracle (R-CHAR-3):
1337        //   ElasticNet(alpha=0.3, l1_ratio=0.5, selection='cyclic')
1338        //     -> coef_ [0.77323348, 1.35480299].
1339        let (x, y) = selection_fixture();
1340
1341        // Default selection is Cyclic.
1342        assert_eq!(ElasticNet::<f64>::new().selection, CoordSelection::Cyclic);
1343
1344        let default_res = ElasticNet::<f64>::new()
1345            .with_alpha(0.3)
1346            .with_l1_ratio(0.5)
1347            .fit(&x, &y);
1348        assert!(default_res.is_ok(), "default fit should succeed");
1349        let default_fit = match default_res {
1350            Ok(f) => f,
1351            Err(_) => return,
1352        };
1353
1354        // Matches sklearn's cyclic oracle tightly.
1355        assert_relative_eq!(default_fit.coefficients()[0], 0.77323348, epsilon = 1e-7);
1356        assert_relative_eq!(default_fit.coefficients()[1], 1.35480299, epsilon = 1e-7);
1357
1358        // Explicitly-constructed Cyclic is byte-identical to the default.
1359        let explicit_res = ElasticNet::<f64>::new()
1360            .with_alpha(0.3)
1361            .with_l1_ratio(0.5)
1362            .with_selection(CoordSelection::Cyclic)
1363            .fit(&x, &y);
1364        assert!(explicit_res.is_ok(), "explicit cyclic fit should succeed");
1365        let explicit_cyclic = match explicit_res {
1366            Ok(f) => f,
1367            Err(_) => return,
1368        };
1369        assert_eq!(
1370            default_fit.coefficients(),
1371            explicit_cyclic.coefficients(),
1372            "explicit Cyclic must be byte-identical to the default"
1373        );
1374        assert_eq!(default_fit.intercept(), explicit_cyclic.intercept());
1375    }
1376
1377    // HONEST CAVEAT: exact bit-match to sklearn's `selection='random'` is
1378    // numpy-MT19937-RNG-blocked (Rust `StdRng` != numpy MT19937), so the random
1379    // path below verifies convergence-to-the-unique-optimum, NOT bitwise sklearn
1380    // parity. The cyclic default IS bit-exact to sklearn (test above).
1381    #[test]
1382    fn enet_selection_random_converges_to_optimum() {
1383        // Live sklearn 1.5.2 oracle (R-CHAR-3):
1384        //   ElasticNet(alpha=0.3, l1_ratio=0.5, selection='random',
1385        //              random_state=0)
1386        //     -> coef_ [0.77289352, 1.35505598]  (same unique optimum,
1387        //        ~3e-4 from cyclic [0.77323348, 1.35480299] due to
1388        //        stopping-within-tol; NOT bit-identical to cyclic).
1389        let (x, y) = selection_fixture();
1390
1391        let fit_res = ElasticNet::<f64>::new()
1392            .with_alpha(0.3)
1393            .with_l1_ratio(0.5)
1394            .with_selection(CoordSelection::Random)
1395            .with_random_state(0)
1396            .fit(&x, &y);
1397        assert!(fit_res.is_ok(), "random-selection fit should succeed");
1398        let fitted = match fit_res {
1399            Ok(f) => f,
1400            Err(_) => return,
1401        };
1402
1403        let coef = fitted.coefficients();
1404
1405        // Every coefficient finite.
1406        for &c in coef.iter() {
1407            assert!(c.is_finite(), "coefficient {c} must be finite");
1408        }
1409
1410        // Converges to the unique cyclic optimum within tol.
1411        let cyclic = [0.77323348_f64, 1.35480299_f64];
1412        assert!(
1413            (coef[0] - cyclic[0]).abs() < 1e-2,
1414            "coef[0]={} should be within 1e-2 of cyclic {}",
1415            coef[0],
1416            cyclic[0]
1417        );
1418        assert!(
1419            (coef[1] - cyclic[1]).abs() < 1e-2,
1420            "coef[1]={} should be within 1e-2 of cyclic {}",
1421            coef[1],
1422            cyclic[1]
1423        );
1424
1425        // Support set matches: both coefficients strictly positive.
1426        assert!(coef[0] > 0.0, "coef[0] should be in the support");
1427        assert!(coef[1] > 0.0, "coef[1] should be in the support");
1428    }
1429
1430    // ---- precompute / Gram path (REQ-11) ----
1431
1432    #[test]
1433    fn enet_precompute_matches_sklearn() -> Result<(), FerroError> {
1434        // REQ-11: Gram (precompute=True) coordinate-descent path.
1435        // Live sklearn 1.5.2 oracle (R-CHAR-3):
1436        //   X=[[1,2],[2,1],[3,4],[4,3],[5,5]], y=[3,2.5,7.1,6,11.2]
1437        //   ElasticNet(alpha=0.3, l1_ratio=0.5, precompute=True).fit(X,y)
1438        //     -> coef_=[0.7732334821, 1.3548029901], n_iter_=16
1439        //   (same optimum as precompute=False to ~1e-10).
1440        let (x, y) = raw_dual_gap_fixture();
1441
1442        let fitted = ElasticNet::<f64>::new()
1443            .with_alpha(0.3)
1444            .with_l1_ratio(0.5)
1445            .with_precompute(true)
1446            .fit(&x, &y)?;
1447
1448        assert_relative_eq!(fitted.coefficients()[0], 0.7732334821, epsilon = 1e-7);
1449        assert_relative_eq!(fitted.coefficients()[1], 1.3548029901, epsilon = 1e-7);
1450        assert_eq!(fitted.n_iter(), 16, "n_iter_ must match sklearn's 16");
1451        Ok(())
1452    }
1453
1454    #[test]
1455    fn enet_precompute_default_false_unchanged() -> Result<(), FerroError> {
1456        // Default `precompute` is `false`; the default fit must be byte-identical
1457        // to an explicitly-direct (precompute=false) fit (no perturbation).
1458        assert!(
1459            !ElasticNet::<f64>::new().precompute,
1460            "default precompute is false"
1461        );
1462
1463        let (x, y) = raw_dual_gap_fixture();
1464
1465        let default_fit = ElasticNet::<f64>::new()
1466            .with_alpha(0.3)
1467            .with_l1_ratio(0.5)
1468            .fit(&x, &y)?;
1469        let explicit_direct = ElasticNet::<f64>::new()
1470            .with_alpha(0.3)
1471            .with_l1_ratio(0.5)
1472            .with_precompute(false)
1473            .fit(&x, &y)?;
1474
1475        assert_eq!(
1476            default_fit.coefficients(),
1477            explicit_direct.coefficients(),
1478            "explicit precompute=false must be byte-identical to the default"
1479        );
1480        assert_eq!(default_fit.intercept(), explicit_direct.intercept());
1481        Ok(())
1482    }
1483
1484    #[test]
1485    fn enet_precompute_equals_direct() -> Result<(), FerroError> {
1486        // The Gram path reaches the SAME unique optimum as the direct path,
1487        // via different (reassociated) arithmetic — coef within 1e-6.
1488        let (x, y) = raw_dual_gap_fixture();
1489
1490        let direct = ElasticNet::<f64>::new()
1491            .with_alpha(0.3)
1492            .with_l1_ratio(0.5)
1493            .with_precompute(false)
1494            .fit(&x, &y)?;
1495        let gram = ElasticNet::<f64>::new()
1496            .with_alpha(0.3)
1497            .with_l1_ratio(0.5)
1498            .with_precompute(true)
1499            .fit(&x, &y)?;
1500
1501        assert_relative_eq!(
1502            gram.coefficients()[0],
1503            direct.coefficients()[0],
1504            epsilon = 1e-6
1505        );
1506        assert_relative_eq!(
1507            gram.coefficients()[1],
1508            direct.coefficients()[1],
1509            epsilon = 1e-6
1510        );
1511        Ok(())
1512    }
1513
1514    // ---- warm_start (REQ-9) ----
1515
1516    /// Oracle fixture for the warm-start tests (R-CHAR-3, live sklearn 1.5.2):
1517    /// `X = [[1,2],[2,1],[3,4],[4,3],[5,5]]`, `y = [3,2.5,7.1,6,11.2]`,
1518    /// `alpha=0.5`, `l1_ratio=0.5`.
1519    fn warm_start_fixture() -> (Array2<f64>, Array1<f64>) {
1520        let x: Array2<f64> = array![[1.0, 2.0], [2.0, 1.0], [3.0, 4.0], [4.0, 3.0], [5.0, 5.0],];
1521        let y: Array1<f64> = array![3.0, 2.5, 7.1, 6.0, 11.2];
1522        (x, y)
1523    }
1524
1525    #[test]
1526    fn enet_warm_start_from_converged_matches_sklearn() -> Result<(), FerroError> {
1527        // REQ-9: warm-start coordinate descent from the prior converged coef.
1528        // Live sklearn 1.5.2 oracle (R-CHAR-3):
1529        //   ElasticNet(alpha=0.5, l1_ratio=0.5).fit(X,y)
1530        //     -> coef_=[0.7643620892, 1.2564536255], n_iter_=14   (cold)
1531        //   refit ElasticNet(alpha=0.5, l1_ratio=0.5, warm_start=True) from the
1532        //   converged coef_
1533        //     -> coef_=[0.7642996441, 1.2564980309], n_iter_=1     (warm)
1534        let (x, y) = warm_start_fixture();
1535
1536        let cold = ElasticNet::<f64>::new()
1537            .with_alpha(0.5)
1538            .with_l1_ratio(0.5)
1539            .fit(&x, &y)?;
1540        assert_relative_eq!(cold.coefficients()[0], 0.7643620892, epsilon = 1e-6);
1541        assert_relative_eq!(cold.coefficients()[1], 1.2564536255, epsilon = 1e-6);
1542        assert_eq!(cold.n_iter(), 14, "cold n_iter_ must match sklearn's 14");
1543
1544        let warm = ElasticNet::<f64>::new()
1545            .with_alpha(0.5)
1546            .with_l1_ratio(0.5)
1547            .with_warm_start(true)
1548            .with_coef_init(cold.coefficients().to_owned())
1549            .fit(&x, &y)?;
1550
1551        assert_relative_eq!(warm.coefficients()[0], 0.7642996441, epsilon = 1e-6);
1552        assert_relative_eq!(warm.coefficients()[1], 1.2564980309, epsilon = 1e-6);
1553        assert_eq!(warm.n_iter(), 1, "warm n_iter_ must match sklearn's 1");
1554        Ok(())
1555    }
1556
1557    #[test]
1558    fn enet_warm_start_default_unchanged() -> Result<(), FerroError> {
1559        // Default `warm_start=false`/`coef_init=None`; the default fit must be
1560        // byte-identical to before (the cold zeros-init path is untouched).
1561        assert!(
1562            !ElasticNet::<f64>::new().warm_start,
1563            "default warm_start is false"
1564        );
1565        assert!(
1566            ElasticNet::<f64>::new().coef_init.is_none(),
1567            "default coef_init is None"
1568        );
1569
1570        let (x, y) = warm_start_fixture();
1571
1572        let default_fit = ElasticNet::<f64>::new()
1573            .with_alpha(0.5)
1574            .with_l1_ratio(0.5)
1575            .fit(&x, &y)?;
1576        let explicit_cold = ElasticNet::<f64>::new()
1577            .with_alpha(0.5)
1578            .with_l1_ratio(0.5)
1579            .with_warm_start(false)
1580            .fit(&x, &y)?;
1581
1582        // Bit-identical: same coordinate-descent start point (zeros).
1583        assert_eq!(
1584            default_fit.coefficients()[0].to_bits(),
1585            explicit_cold.coefficients()[0].to_bits()
1586        );
1587        assert_eq!(
1588            default_fit.coefficients()[1].to_bits(),
1589            explicit_cold.coefficients()[1].to_bits()
1590        );
1591        assert_eq!(
1592            default_fit.intercept().to_bits(),
1593            explicit_cold.intercept().to_bits()
1594        );
1595        assert_eq!(default_fit.n_iter(), explicit_cold.n_iter());
1596        Ok(())
1597    }
1598
1599    #[test]
1600    fn enet_warm_start_none_coef_init_equals_cold() -> Result<(), FerroError> {
1601        // `warm_start=true` but no `coef_init` falls back to the zeros init,
1602        // byte-identical to a plain cold fit (warm_start gates only whether
1603        // `coef_init`, when present, is used).
1604        let (x, y) = warm_start_fixture();
1605
1606        let cold = ElasticNet::<f64>::new()
1607            .with_alpha(0.5)
1608            .with_l1_ratio(0.5)
1609            .fit(&x, &y)?;
1610        let warm_no_init = ElasticNet::<f64>::new()
1611            .with_alpha(0.5)
1612            .with_l1_ratio(0.5)
1613            .with_warm_start(true)
1614            .fit(&x, &y)?;
1615
1616        assert_eq!(
1617            cold.coefficients()[0].to_bits(),
1618            warm_no_init.coefficients()[0].to_bits()
1619        );
1620        assert_eq!(
1621            cold.coefficients()[1].to_bits(),
1622            warm_no_init.coefficients()[1].to_bits()
1623        );
1624        assert_eq!(cold.n_iter(), warm_no_init.n_iter());
1625        Ok(())
1626    }
1627
1628    #[test]
1629    fn enet_warm_start_coef_init_wrong_len_errors() {
1630        // `coef_init` length (1) != n_features (2) must raise ShapeMismatch.
1631        let (x, y) = warm_start_fixture();
1632
1633        let result = ElasticNet::<f64>::new()
1634            .with_alpha(0.5)
1635            .with_l1_ratio(0.5)
1636            .with_warm_start(true)
1637            .with_coef_init(array![0.0])
1638            .fit(&x, &y);
1639
1640        assert!(
1641            matches!(result, Err(FerroError::ShapeMismatch { .. })),
1642            "wrong-length coef_init must return ShapeMismatch, got {result:?}"
1643        );
1644    }
1645}