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}