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}