Skip to main content

ferrolearn_linear/
multi_task_elastic_net_cv.rs

1//! Multi-task ElasticNet regression with built-in cross-validation for
2//! `(alpha, l1_ratio)` selection.
3//!
4//! This module provides [`MultiTaskElasticNetCV`], the multi-output analog of
5//! [`crate::ElasticNetCV`]: for each candidate `l1_ratio` it builds a log-spaced
6//! alpha grid (the multi-task L21 dual-norm `alpha_max` down to `alpha_max * eps`),
7//! runs k-fold cross-validation fitting a [`crate::MultiTaskElasticNet`] per
8//! `(alpha, l1_ratio)` fold, selects the pair minimizing mean CV mean-squared
9//! error (averaged over held-out samples and tasks), and refits on the full data.
10//!
11//! Mirrors `sklearn.linear_model.MultiTaskElasticNetCV`
12//! (`sklearn/linear_model/_coordinate_descent.py:2806`,
13//! `class MultiTaskElasticNetCV(RegressorMixin, LinearModelCV)`). The CV
14//! machinery is the shared `LinearModelCV.fit` (`:1552-1837`): `_alpha_grid`
15//! (`:96-185`) per `l1_ratio`, `_path_residuals` per fold over the alpha path
16//! (`:1450-1482`), `mean_mse = np.mean(mse_paths, axis=1)`, `argmin` select
17//! (`:1791-1799`), refit (`:1828-1834`). The single difference vs the single-task
18//! [`crate::ElasticNetCV`] is the multi-output branch of `_alpha_grid`: `Xy =
19//! Xcᵀ Yc` is 2-D `(n_features, n_tasks)`, so `alpha_max =
20//! sqrt(sum(Xy**2, axis=1)).max() / (n_samples * l1_ratio)` (`:178`) — the L21
21//! dual norm `max_j ||Xc[:,j]ᵀ Yc||_2 / (n·l1_ratio)`. The inner solver is the
22//! multi-output [`crate::MultiTaskElasticNet`].
23//!
24//! ## REQ status (per `.design/linear/elastic_net_cv.md`, mirrors `sklearn/linear_model/_coordinate_descent.py` @ 1.5.2)
25//!
26//! Scope mirrors the single-task `ElasticNetCV` REQ-1/2 precedent: the CORE CV
27//! path (per-l1_ratio L21 alpha grid + contiguous k-fold + MSE-select + refit)
28//! SHIPPED; the path-introspection / advanced attrs NOT-STARTED.
29//!
30//! | REQ | Status | Evidence |
31//! |---|---|---|
32//! | REQ-1 (per-l1_ratio L21 alpha grid) | SHIPPED | `compute_alpha_max_mtenet` centers X/Y when `fit_intercept`, forms `Xy = Xcᵀ Yc` `(p,t)`, then `max_j ||Xy[j,:]||_2 / (n·l1_ratio)` — the multi-output `_alpha_grid` branch `alpha_max = sqrt(sum(Xy**2,axis=1)).max()/(n*l1_ratio)` (`_coordinate_descent.py:178`). `logspace` produces the `np.geomspace(alpha_max, alpha_max*eps, n_alphas)` grid (`:185`). Defaults `n_alphas=100`, `eps=1e-3` match the ctor (`:2994`, `:2993`). Oracle (R-CHAR-3): on the 12×2 fixture, l1_ratio=0.5 → `alphas_[0]=10.51284973948842`, ratio 1e-3 to last. Non-test consumer: `pub use … MultiTaskElasticNetCV` in `lib.rs` (boundary API). |
33//! | REQ-2 ((alpha,l1_ratio) CV select + refit) | SHIPPED | `Fit for MultiTaskElasticNetCV`: contiguous `kfold_indices` (sklearn non-shuffled `KFold`, `_split.py:521-534`); for each `(l1_ratio, alpha)` fits `MultiTaskElasticNet` per train fold, accumulates held-out MSE (`mean_mse` over samples+tasks, `_coordinate_descent.py:1478`,`:1482`), `argmin` mean-CV-MSE select (`:1791-1799`), refits on full data (`:1828-1834`). `alpha_`/`l1_ratio_` match the live oracle EXACTLY; `coef_`/`intercept_` within the CD-stopping tol (~1e-4, shared #412). Non-test consumer: `pub use … MultiTaskElasticNetCV`. |
34//! | REQ-3 (explicit l1_ratios grid) | SHIPPED | `with_l1_ratios`; `fit` validates non-empty + each in `[0,1]` (`_parameter_constraints["l1_ratio"]`, `:2982`). |
35//! | REQ-4 (predict / fit_intercept) | SHIPPED | `Predict for FittedMultiTaskElasticNetCV` = `X·coefᵀ + intercept` → `(n,t)`; `with_fit_intercept` threads into grid + every fold fit. |
36//! | REQ-5 (contiguous KFold) | SHIPPED | `kfold_indices` = contiguous blocks (sklearn `check_cv(None)→KFold(5)` non-shuffled), so the selected `(alpha_,l1_ratio_)` matches sklearn (mirrors the single-task #431 fix). |
37//! | REQ-6 (default l1_ratio=0.5) | SHIPPED | `new()` defaults `l1_ratios=[0.5]`, matching the sklearn ctor `l1_ratio=0.5` (`:2992`). |
38//! | REQ-7 (l1_ratio=0 auto-grid raises) | SHIPPED | auto-grid `l1_ratio=0` → `InvalidParameter`, mirroring `_alpha_grid` `ValueError` (`:140-146`). |
39//! | REQ-8..14 NOT-STARTED | `mse_path_`/`alphas_`/`dual_gap_`/`n_iter_` path attrs (shared single-task #433/#434), `eps` param (#435), `n_jobs`/sparse, `random_state`/`selection` (#438), ferray substrate (#439), exact `coef_` parity gated by shared CD-stopping #412 — mirroring `elastic_net_cv.rs` REQ-7..14. Two states only (R-DEFER-2). |
40//!
41//! acto-builder: the L21 alpha grid + contiguous folds match the single-task
42//! verified precedent, so `alpha_`/`l1_ratio_` match the live oracle exactly;
43//! `coef_` residual is the tracked CD-stopping #412.
44//!
45//! # Examples
46//!
47//! ```
48//! use ferrolearn_linear::MultiTaskElasticNetCV;
49//! use ferrolearn_core::{Fit, Predict};
50//! use ndarray::{array, Array2};
51//!
52//! let model = MultiTaskElasticNetCV::<f64>::new().with_n_alphas(5).with_cv(3);
53//! let x: Array2<f64> = array![
54//!     [1.0, 2.0], [2.0, 1.0], [3.0, 4.0], [4.0, 3.0], [5.0, 5.0], [2.0, 3.0],
55//!     [6.0, 1.0], [3.0, 3.0], [7.0, 2.0], [1.0, 5.0], [4.0, 6.0], [5.0, 2.0],
56//! ];
57//! let y: Array2<f64> = array![
58//!     [3.0, 1.0], [2.5, 2.0], [7.1, 3.5], [6.0, 4.2], [11.2, 6.0], [5.0, 3.0],
59//!     [9.0, 2.0], [6.5, 3.3], [12.0, 3.5], [3.0, 5.5], [8.5, 7.0], [9.5, 3.2],
60//! ];
61//!
62//! let fitted = model.fit(&x, &y).unwrap();
63//! let preds = fitted.predict(&x).unwrap();
64//! assert_eq!(preds.dim(), (12, 2));
65//! ```
66
67use ferrolearn_core::error::FerroError;
68use ferrolearn_core::traits::{Fit, Predict};
69use ndarray::{Array1, Array2, Axis, ScalarOperand};
70use num_traits::{Float, FromPrimitive};
71
72use crate::MultiTaskElasticNet;
73
74/// Multi-task ElasticNet regression with built-in cross-validation for joint
75/// `(alpha, l1_ratio)` selection.
76///
77/// For each candidate `l1_ratio`, generates a log-spaced alpha grid (from the
78/// L21 dual-norm `alpha_max` down to `alpha_max * 1e-3`), runs k-fold CV fitting
79/// a [`MultiTaskElasticNet`] per fold, and selects the combination minimizing
80/// the mean held-out MSE (averaged over samples and tasks).
81///
82/// Mirrors `sklearn.linear_model.MultiTaskElasticNetCV`
83/// (`_coordinate_descent.py:2806`).
84///
85/// # Type Parameters
86///
87/// - `F`: The floating-point type (`f32` or `f64`).
88#[derive(Debug, Clone)]
89pub struct MultiTaskElasticNetCV<F> {
90    /// Candidate L1/L2 mixing ratios. Each value must be in `[0, 1]`.
91    l1_ratios: Vec<F>,
92    /// Number of alphas generated per `l1_ratio` when no explicit grid is
93    /// supplied.
94    n_alphas: usize,
95    /// Number of cross-validation folds.
96    cv: usize,
97    /// Maximum block-coordinate-descent iterations per inner fit.
98    max_iter: usize,
99    /// Convergence tolerance for block coordinate descent.
100    tol: F,
101    /// Whether to fit per-task intercept terms.
102    fit_intercept: bool,
103}
104
105impl<F: Float + FromPrimitive> MultiTaskElasticNetCV<F> {
106    /// Create a new `MultiTaskElasticNetCV` with default settings.
107    ///
108    /// Defaults mirror `sklearn.linear_model.MultiTaskElasticNetCV.__init__`
109    /// (`_coordinate_descent.py:2989-3018`), which fixes a single
110    /// `l1_ratio=0.5`:
111    /// - `l1_ratios = [0.5]`
112    /// - `n_alphas = 100`
113    /// - `cv = 5`
114    /// - `max_iter = 1000`
115    /// - `tol = 1e-4`
116    /// - `fit_intercept = true`
117    ///
118    /// Use [`with_l1_ratios`](Self::with_l1_ratios) to search a grid of
119    /// mixing ratios.
120    #[must_use]
121    pub fn new() -> Self {
122        // sklearn `MultiTaskElasticNetCV` defaults `l1_ratio=0.5` (a single
123        // value), not a grid (`_coordinate_descent.py:2992`). 0.5 = `1 / (1 + 1)`
124        // so no fallible float conversion is needed.
125        let half = F::one() / (F::one() + F::one());
126        Self {
127            l1_ratios: vec![half],
128            n_alphas: 100,
129            cv: 5,
130            max_iter: 1000,
131            tol: F::from(1e-4).unwrap_or_else(F::epsilon),
132            fit_intercept: true,
133        }
134    }
135
136    /// Set the candidate L1/L2 mixing ratios. Each value must be in `[0, 1]`.
137    #[must_use]
138    pub fn with_l1_ratios(mut self, l1_ratios: Vec<F>) -> Self {
139        self.l1_ratios = l1_ratios;
140        self
141    }
142
143    /// Set the number of alphas generated per `l1_ratio`.
144    #[must_use]
145    pub fn with_n_alphas(mut self, n_alphas: usize) -> Self {
146        self.n_alphas = n_alphas;
147        self
148    }
149
150    /// Set the number of cross-validation folds (must be at least 2).
151    #[must_use]
152    pub fn with_cv(mut self, cv: usize) -> Self {
153        self.cv = cv;
154        self
155    }
156
157    /// Set the maximum number of block-coordinate-descent iterations.
158    #[must_use]
159    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
160        self.max_iter = max_iter;
161        self
162    }
163
164    /// Set the convergence tolerance.
165    #[must_use]
166    pub fn with_tol(mut self, tol: F) -> Self {
167        self.tol = tol;
168        self
169    }
170
171    /// Set whether to fit per-task intercept terms.
172    #[must_use]
173    pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
174        self.fit_intercept = fit_intercept;
175        self
176    }
177}
178
179impl<F: Float + FromPrimitive> Default for MultiTaskElasticNetCV<F> {
180    fn default() -> Self {
181        Self::new()
182    }
183}
184
185/// Fitted multi-task ElasticNet model with cross-validated `(alpha, l1_ratio)`.
186///
187/// Stores the selected hyperparameters, learned coefficient matrix (shape
188/// `(n_tasks, n_features)`, the sklearn `coef_` layout), and per-task intercept
189/// vector.
190#[derive(Debug, Clone)]
191pub struct FittedMultiTaskElasticNetCV<F> {
192    /// The alpha that achieved the lowest CV error (sklearn `alpha_`).
193    alpha: F,
194    /// The l1_ratio that achieved the lowest CV error (sklearn `l1_ratio_`).
195    l1_ratio: F,
196    /// Learned coefficients, shape `(n_tasks, n_features)` (sklearn `coef_`).
197    coefficients: Array2<F>,
198    /// Per-task intercept vector, length `n_tasks` (sklearn `intercept_`).
199    intercepts: Array1<F>,
200}
201
202impl<F: Float> FittedMultiTaskElasticNetCV<F> {
203    /// Returns the alpha value selected by cross-validation (sklearn `alpha_`).
204    #[must_use]
205    pub fn alpha(&self) -> F {
206        self.alpha
207    }
208
209    /// Returns the l1_ratio selected by cross-validation (sklearn `l1_ratio_`).
210    #[must_use]
211    pub fn l1_ratio(&self) -> F {
212        self.l1_ratio
213    }
214
215    /// Borrow the learned coefficient matrix, shape `(n_tasks, n_features)`
216    /// (sklearn `coef_`).
217    #[must_use]
218    pub fn coef(&self) -> &Array2<F> {
219        &self.coefficients
220    }
221
222    /// Borrow the per-task intercept vector, length `n_tasks` (sklearn
223    /// `intercept_`).
224    #[must_use]
225    pub fn intercept(&self) -> &Array1<F> {
226        &self.intercepts
227    }
228}
229
230/// Split sample indices into `k` contiguous folds, mirroring scikit-learn's
231/// non-shuffled `KFold._iter_test_indices` (`sklearn/model_selection/_split.py:521-534`).
232///
233/// Fold sizes are `n_samples / k`, with the first `n_samples % k` folds
234/// receiving one extra sample; folds are sequential index blocks. For
235/// `n_samples = 12, k = 3` this yields `[0,1,2,3], [4,5,6,7], [8,9,10,11]`.
236fn kfold_indices(n_samples: usize, k: usize) -> Vec<Vec<usize>> {
237    let base = n_samples / k;
238    let remainder = n_samples % k;
239    let mut folds: Vec<Vec<usize>> = Vec::with_capacity(k);
240    let mut current = 0;
241    for fold in 0..k {
242        let fold_size = if fold < remainder { base + 1 } else { base };
243        let stop = current + fold_size;
244        folds.push((current..stop).collect());
245        current = stop;
246    }
247    folds
248}
249
250/// Mean squared error between two `(n_samples, n_tasks)` arrays, averaged over
251/// BOTH samples and tasks.
252///
253/// Mirrors sklearn `_path_residuals` which returns `this_mse.mean(axis=0)`
254/// (mean over samples, `_coordinate_descent.py:1478`) and the fold MSE used in
255/// selection is `mean_mse` over folds; the per-fold value entering the mean is
256/// itself the per-task MSE averaged across tasks (`this_mse.mean(axis=0)` is a
257/// per-task vector, then summed into the scalar fold loss via the final
258/// `.mean(axis=0)` at `:1482`).
259fn mse_multitask<F: Float + FromPrimitive + 'static>(y_true: &Array2<F>, y_pred: &Array2<F>) -> F {
260    let total = F::from(y_true.len()).unwrap_or_else(F::one);
261    let diff = y_true - y_pred;
262    let sq = (&diff * &diff).sum();
263    sq / total
264}
265
266/// Gather rows from a 2-D array by index.
267fn select_rows<F: Float>(x: &Array2<F>, indices: &[usize]) -> Array2<F> {
268    let ncols = x.ncols();
269    let mut out = Array2::<F>::zeros((indices.len(), ncols));
270    for (out_row, &idx) in indices.iter().enumerate() {
271        out.row_mut(out_row).assign(&x.row(idx));
272    }
273    out
274}
275
276/// Compute the multi-task L21 dual-norm `alpha_max` for a given `l1_ratio`.
277///
278/// `Xy = Xcᵀ Yc` is `(n_features, n_tasks)` (centered when `fit_intercept`);
279/// `alpha_max = max_j ||Xy[j, :]||_2 / (n_samples * l1_ratio)` — the multi-output
280/// branch of sklearn `_alpha_grid`, `alpha_max =
281/// np.sqrt(np.sum(Xy**2, axis=1)).max() / (n_samples * l1_ratio)`
282/// (`_coordinate_descent.py:178`). The single-task case (one task) collapses to
283/// `max|Xᵀy| / (n·l1_ratio)`.
284fn compute_alpha_max_mtenet<F: Float + FromPrimitive + ScalarOperand>(
285    x: &Array2<F>,
286    y: &Array2<F>,
287    l1_ratio: F,
288    fit_intercept: bool,
289) -> F {
290    let n = F::from(x.nrows()).unwrap_or_else(F::one);
291
292    let (xc, yc) = if fit_intercept {
293        let x_mean = x
294            .mean_axis(Axis(0))
295            .unwrap_or_else(|| Array1::zeros(x.ncols()));
296        let y_mean = y
297            .mean_axis(Axis(0))
298            .unwrap_or_else(|| Array1::zeros(y.ncols()));
299        (x - &x_mean, y - &y_mean)
300    } else {
301        (x.clone(), y.clone())
302    };
303
304    // Xy = Xcᵀ Yc, shape (n_features, n_tasks).
305    let xy = xc.t().dot(&yc);
306
307    // max_j ||Xy[j, :]||_2.
308    let mut max_norm = F::zero();
309    for j in 0..xy.nrows() {
310        let row = xy.row(j);
311        let nrm = row.dot(&row).sqrt();
312        if nrm > max_norm {
313            max_norm = nrm;
314        }
315    }
316
317    if l1_ratio > F::zero() {
318        max_norm / (n * l1_ratio)
319    } else {
320        max_norm / n
321    }
322}
323
324/// Generate `n` log-spaced values from `high` down to `high * eps_ratio`,
325/// mirroring `np.geomspace(alpha_max, alpha_max * eps, num=n_alphas)`
326/// (`_coordinate_descent.py:185`).
327fn logspace<F: Float + FromPrimitive>(high: F, eps_ratio: F, n: usize) -> Vec<F> {
328    if n == 0 {
329        return Vec::new();
330    }
331    if n == 1 {
332        return vec![high];
333    }
334
335    let log_high = high.ln();
336    let log_low = (high * eps_ratio).ln();
337    let step = (log_low - log_high) / F::from(n - 1).unwrap_or_else(F::one);
338
339    (0..n)
340        .map(|i| (log_high + step * F::from(i).unwrap_or_else(F::zero)).exp())
341        .collect()
342}
343
344/// The F-appropriate `numpy.finfo(float).resolution` — the degenerate-`alpha_max`
345/// threshold and fill value sklearn uses (`_coordinate_descent.py:180-182`).
346///
347/// numpy defines `resolution = 10 ** -precision` where
348/// `precision = floor(mantissa_bits * log10(2))`, with `mantissa_bits` the
349/// number of *stored* fraction bits (52 for f64, 23 for f32). `num_traits::Float`
350/// does not expose `MANTISSA_DIGITS`, so we recover the stored mantissa bits from
351/// machine epsilon: `eps = 2^-mantissa_bits` ⇒ `mantissa_bits = -log2(eps)`
352/// (52.0 for f64, 23.0 for f32 — rounded to absorb log round-off). This yields
353/// `1e-15` for f64 (`floor(52·0.30103) = 15`) and `1e-6` for f32
354/// (`floor(23·0.30103) = 6`), matching `np.finfo(np.float64).resolution` and
355/// `np.finfo(np.float32).resolution` exactly (dtype-dependent per R-CODE-5).
356fn finfo_resolution<F: Float + FromPrimitive>() -> F {
357    let eps: f64 = F::epsilon().to_f64().unwrap_or(f64::EPSILON);
358    let mantissa_bits = (-eps.log2()).round();
359    let precision = (mantissa_bits * 2.0_f64.log10()).floor() as i32;
360    F::from(10.0)
361        .unwrap_or_else(|| F::one() + F::one())
362        .powi(-precision)
363}
364
365impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + 'static> Fit<Array2<F>, Array2<F>>
366    for MultiTaskElasticNetCV<F>
367{
368    type Fitted = FittedMultiTaskElasticNetCV<F>;
369    type Error = FerroError;
370
371    /// Fit the `MultiTaskElasticNetCV` model.
372    ///
373    /// For each candidate `l1_ratio`, generates the L21 alpha grid, runs k-fold
374    /// CV for every `(alpha, l1_ratio)` pair (fitting a [`MultiTaskElasticNet`]
375    /// per train fold and scoring held-out MSE), then refits on the full data
376    /// using the best combination. Mirrors `LinearModelCV.fit`
377    /// (`_coordinate_descent.py:1552-1837`).
378    ///
379    /// # Errors
380    ///
381    /// - [`FerroError::ShapeMismatch`] if `Y.nrows()` differs from the number of
382    ///   samples in `X`.
383    /// - [`FerroError::InvalidParameter`] if `l1_ratios` is empty, any ratio is
384    ///   outside `[0, 1]`, an auto-grid `l1_ratio` is `0`, `cv < 2`, `n_alphas == 0`,
385    ///   or X/Y contains NaN/infinity.
386    /// - [`FerroError::InsufficientSamples`] if `n_samples < cv`.
387    fn fit(
388        &self,
389        x: &Array2<F>,
390        y: &Array2<F>,
391    ) -> Result<FittedMultiTaskElasticNetCV<F>, FerroError> {
392        let (n_samples, _n_features) = x.dim();
393
394        if n_samples != y.nrows() {
395            return Err(FerroError::ShapeMismatch {
396                expected: vec![n_samples],
397                actual: vec![y.nrows()],
398                context: "Y rows must match number of samples in X".into(),
399            });
400        }
401
402        if self.l1_ratios.is_empty() {
403            return Err(FerroError::InvalidParameter {
404                name: "l1_ratios".into(),
405                reason: "must contain at least one candidate".into(),
406            });
407        }
408
409        for &r in &self.l1_ratios {
410            if r < F::zero() || r > F::one() {
411                return Err(FerroError::InvalidParameter {
412                    name: "l1_ratios".into(),
413                    reason: "all l1_ratio values must be in [0, 1]".into(),
414                });
415            }
416        }
417
418        if self.cv < 2 {
419            return Err(FerroError::InvalidParameter {
420                name: "cv".into(),
421                reason: "number of folds must be at least 2".into(),
422            });
423        }
424
425        if n_samples < self.cv {
426            return Err(FerroError::InsufficientSamples {
427                required: self.cv,
428                actual: n_samples,
429                context: "MultiTaskElasticNetCV requires at least as many samples as folds".into(),
430            });
431        }
432
433        if self.n_alphas == 0 {
434            return Err(FerroError::InvalidParameter {
435                name: "n_alphas".into(),
436                reason: "must be at least 1".into(),
437            });
438        }
439
440        // sklearn `LinearModelCV.fit` -> `self._validate_data(...,
441        // force_all_finite=True)` (`_coordinate_descent.py:1619`): any NaN/±inf
442        // in X or Y is rejected BEFORE the search (same crate idiom as the base
443        // MultiTask estimators).
444        if x.iter().any(|v| !v.is_finite()) {
445            return Err(FerroError::InvalidParameter {
446                name: "X".into(),
447                reason: "Input X contains NaN or infinity.".into(),
448            });
449        }
450        if y.iter().any(|v| !v.is_finite()) {
451            return Err(FerroError::InvalidParameter {
452                name: "y".into(),
453                reason: "Input y contains NaN or infinity.".into(),
454            });
455        }
456
457        let folds = kfold_indices(n_samples, self.cv);
458
459        let mut best_alpha = F::one();
460        let mut best_l1_ratio = self.l1_ratios[0];
461        let mut best_mse = F::infinity();
462
463        for &l1_ratio in &self.l1_ratios {
464            // sklearn `_alpha_grid` raises ValueError for auto-generation with
465            // l1_ratio=0 ("Automatic alpha grid generation is not supported for
466            // l1_ratio=0", `_coordinate_descent.py:140-146`); alpha_max divides
467            // by `n * l1_ratio`.
468            if l1_ratio == F::zero() {
469                return Err(FerroError::InvalidParameter {
470                    name: "l1_ratio".into(),
471                    reason: "Automatic alpha grid generation is not supported for \
472                             l1_ratio=0; supply an explicit alphas grid"
473                        .into(),
474                });
475            }
476
477            let alpha_max = compute_alpha_max_mtenet(x, y, l1_ratio, self.fit_intercept);
478            // Degenerate alpha_max (constant Y → centered Xy = 0 → alpha_max = 0):
479            // sklearn fills the whole grid with `np.finfo(float).resolution`
480            // (dtype-dependent: 1e-15 for f64, 1e-6 for f32) whenever
481            // `alpha_max <= np.finfo(float).resolution` (`_coordinate_descent.py:180-182`).
482            let resolution = finfo_resolution::<F>();
483            let alpha_grid = if alpha_max <= resolution {
484                vec![resolution; self.n_alphas]
485            } else {
486                logspace(
487                    alpha_max,
488                    F::from(1e-3).unwrap_or_else(F::epsilon),
489                    self.n_alphas,
490                )
491            };
492
493            for &alpha in &alpha_grid {
494                let mut total_mse = F::zero();
495
496                for fold_idx in 0..self.cv {
497                    let test_indices = &folds[fold_idx];
498                    let train_indices: Vec<usize> = folds
499                        .iter()
500                        .enumerate()
501                        .filter(|&(i, _)| i != fold_idx)
502                        .flat_map(|(_, v)| v.iter().copied())
503                        .collect();
504
505                    let x_train = select_rows(x, &train_indices);
506                    let y_train = select_rows(y, &train_indices);
507                    let x_test = select_rows(x, test_indices);
508                    let y_test = select_rows(y, test_indices);
509
510                    let model = MultiTaskElasticNet::<F>::new()
511                        .with_alpha(alpha)
512                        .with_l1_ratio(l1_ratio)
513                        .with_max_iter(self.max_iter)
514                        .with_tol(self.tol)
515                        .with_fit_intercept(self.fit_intercept);
516
517                    let fitted = model.fit(&x_train, &y_train)?;
518                    let preds = fitted.predict(&x_test)?;
519                    total_mse = total_mse + mse_multitask(&y_test, &preds);
520                }
521
522                let avg_mse = total_mse / F::from(self.cv).unwrap_or_else(F::one);
523
524                if avg_mse < best_mse {
525                    best_mse = avg_mse;
526                    best_alpha = alpha;
527                    best_l1_ratio = l1_ratio;
528                }
529            }
530        }
531
532        // Refit on full data with the best hyperparameters.
533        let final_model = MultiTaskElasticNet::<F>::new()
534            .with_alpha(best_alpha)
535            .with_l1_ratio(best_l1_ratio)
536            .with_max_iter(self.max_iter)
537            .with_tol(self.tol)
538            .with_fit_intercept(self.fit_intercept);
539        let final_fitted = final_model.fit(x, y)?;
540
541        Ok(FittedMultiTaskElasticNetCV {
542            alpha: best_alpha,
543            l1_ratio: best_l1_ratio,
544            coefficients: final_fitted.coefficients().clone(),
545            intercepts: final_fitted.intercepts().clone(),
546        })
547    }
548}
549
550impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>>
551    for FittedMultiTaskElasticNetCV<F>
552{
553    type Output = Array2<F>;
554    type Error = FerroError;
555
556    /// Predict target values for the given feature matrix.
557    ///
558    /// Computes `X · coefᵀ + intercept` (broadcast per task column), returning
559    /// an `(n_samples, n_tasks)` array.
560    ///
561    /// # Errors
562    ///
563    /// Returns [`FerroError::ShapeMismatch`] if the number of features does not
564    /// match the fitted model.
565    fn predict(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
566        let n_features = x.ncols();
567        if n_features != self.coefficients.ncols() {
568            return Err(FerroError::ShapeMismatch {
569                expected: vec![self.coefficients.ncols()],
570                actual: vec![n_features],
571                context: "number of features must match fitted model".into(),
572            });
573        }
574
575        let mut preds = x.dot(&self.coefficients.t());
576        for (k, &b) in self.intercepts.iter().enumerate() {
577            let mut col = preds.column_mut(k);
578            col.mapv_inplace(|v| v + b);
579        }
580        Ok(preds)
581    }
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587    use ndarray::array;
588
589    #[test]
590    fn test_finfo_resolution_matches_numpy() {
591        // numpy: np.finfo(np.float64).resolution == 1e-15,
592        //        np.finfo(np.float32).resolution == 1e-6 (dtype-dependent).
593        assert_eq!(finfo_resolution::<f64>(), 1e-15);
594        assert_eq!(finfo_resolution::<f32>(), 1e-6);
595    }
596
597    #[test]
598    fn test_mtenet_cv_default_builder() {
599        let m = MultiTaskElasticNetCV::<f64>::new();
600        // sklearn `MultiTaskElasticNetCV()` defaults to a single `l1_ratio=0.5`
601        // (`_coordinate_descent.py:2992`).
602        assert_eq!(m.l1_ratios.len(), 1);
603        assert_eq!(m.l1_ratios[0], 0.5);
604        assert_eq!(m.n_alphas, 100);
605        assert_eq!(m.cv, 5);
606        assert_eq!(m.max_iter, 1000);
607        assert!(m.fit_intercept);
608    }
609
610    #[test]
611    fn test_mtenet_cv_builder_setters() {
612        let m = MultiTaskElasticNetCV::<f64>::new()
613            .with_l1_ratios(vec![0.3, 0.7])
614            .with_n_alphas(5)
615            .with_cv(3)
616            .with_max_iter(500)
617            .with_tol(1e-6)
618            .with_fit_intercept(false);
619        assert_eq!(m.l1_ratios.len(), 2);
620        assert_eq!(m.n_alphas, 5);
621        assert_eq!(m.cv, 3);
622        assert_eq!(m.max_iter, 500);
623        assert!(!m.fit_intercept);
624    }
625
626    #[test]
627    fn test_mtenet_cv_empty_l1_ratios_error() {
628        let x: Array2<f64> = array![[1.0, 2.0], [2.0, 1.0], [3.0, 4.0], [4.0, 3.0]];
629        let y: Array2<f64> = array![[3.0, 1.0], [2.5, 2.0], [7.1, 3.5], [6.0, 4.2]];
630        let res = MultiTaskElasticNetCV::<f64>::new()
631            .with_l1_ratios(vec![])
632            .fit(&x, &y);
633        assert!(matches!(res, Err(FerroError::InvalidParameter { .. })));
634    }
635
636    #[test]
637    fn test_mtenet_cv_shape_mismatch_error() {
638        let x: Array2<f64> = array![[1.0, 2.0], [2.0, 1.0], [3.0, 4.0]];
639        let y: Array2<f64> = array![[3.0, 1.0], [2.5, 2.0]];
640        let res = MultiTaskElasticNetCV::<f64>::new().fit(&x, &y);
641        assert!(matches!(res, Err(FerroError::ShapeMismatch { .. })));
642    }
643}