Skip to main content

ferrolearn_linear/
multi_task_lasso_cv.rs

1//! Multi-task Lasso regression with built-in cross-validation for alpha
2//! selection.
3//!
4//! This module provides [`MultiTaskLassoCV`], the `l1_ratio = 1.0`
5//! specialization of [`crate::MultiTaskElasticNetCV`]: it auto-generates the
6//! multi-task L21 alpha grid, runs k-fold cross-validation fitting a
7//! [`crate::MultiTaskLasso`] per fold, selects the alpha minimizing mean CV MSE,
8//! and refits on the full data. Unlike `MultiTaskElasticNetCV` it has NO
9//! `l1_ratio` parameter (fixed at `1.0`) and exposes no `l1_ratio_` attribute.
10//!
11//! Mirrors `sklearn.linear_model.MultiTaskLassoCV`
12//! (`sklearn/linear_model/_coordinate_descent.py:3061`,
13//! `class MultiTaskLassoCV(RegressorMixin, LinearModelCV)`), whose `__init__`
14//! (`:3228-3256`) drops `l1_ratio` entirely and whose `_get_estimator`
15//! (`:3258`) returns a `MultiTaskLasso()`. The shared `LinearModelCV.fit`
16//! machinery is the same path used by `MultiTaskElasticNetCV`; with the L1/L2
17//! mixing fixed to `1.0` the inner solver reduces to the pure L21 (group-Lasso)
18//! `MultiTaskLasso`. This implementation DELEGATES to
19//! [`crate::MultiTaskElasticNetCV`] with `l1_ratios = [1.0]` (so the CV core is
20//! written once), matching the sklearn pattern where `MultiTaskLassoCV` is the
21//! `l1_ratio=1` specialization of the same `LinearModelCV` base.
22//!
23//! ## REQ status (per `.design/linear/lasso_cv.md`, mirrors `sklearn/linear_model/_coordinate_descent.py` @ 1.5.2)
24//!
25//! Scope mirrors the single-task `LassoCV` REQ-1/2 precedent and the
26//! `MultiTaskElasticNetCV` core: the CORE CV path (L21 alpha grid + contiguous
27//! k-fold + MSE-select + refit, fixed l1_ratio=1.0) SHIPPED; advanced attrs
28//! NOT-STARTED.
29//!
30//! | REQ | Status | Evidence |
31//! |---|---|---|
32//! | REQ-1 (L21 alpha grid, l1_ratio=1) | SHIPPED | delegates to `MultiTaskElasticNetCV::new().with_l1_ratios(vec![1.0])`, whose `compute_alpha_max_mtenet` computes `max_j ||Xcᵀ Yc[j,:]||_2 / (n·1)` — the multi-output `_alpha_grid` (`_coordinate_descent.py:178`) with `l1_ratio=1`. Defaults `n_alphas=100`, `eps=1e-3` (`:3230`,`:3231`). Oracle (R-CHAR-3): on the 12×2 fixture `n_alphas=5,cv=3` → `alpha_=0.00525642486974421`. Non-test consumer: `pub use … MultiTaskLassoCV` in `lib.rs`. |
33//! | REQ-2 (alpha CV select + refit) | SHIPPED | the delegated `Fit for MultiTaskElasticNetCV` runs contiguous k-fold CV over the single `l1_ratio=1` grid (inner `MultiTaskElasticNet(l1_ratio=1) == MultiTaskLasso`), `argmin` mean-CV-MSE select, full-data refit. `alpha_` matches the live oracle EXACTLY; `coef_`/`intercept_` within CD-stopping tol (~1e-4, shared #412). NO `l1_ratio_` exposed (sklearn `MultiTaskLassoCV` has no `l1_ratio_`, `_coordinate_descent.py:1831-1832` deletes it). Non-test consumer: `pub use … MultiTaskLassoCV`. |
34//! | REQ-3 (predict / fit_intercept) | SHIPPED | `Predict for FittedMultiTaskLassoCV` = `X·coefᵀ + intercept` → `(n,t)`; `with_fit_intercept` threads into the delegate. |
35//! | REQ-4 (contiguous KFold) | SHIPPED | inherited from the delegate's `kfold_indices` (sklearn non-shuffled `KFold`), so the selected `alpha_` matches sklearn. |
36//! | REQ-5..N 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 `lasso_cv.md` advanced-attr scope. Two states only (R-DEFER-2). |
37//!
38//! # Examples
39//!
40//! ```
41//! use ferrolearn_linear::MultiTaskLassoCV;
42//! use ferrolearn_core::{Fit, Predict};
43//! use ndarray::{array, Array2};
44//!
45//! let model = MultiTaskLassoCV::<f64>::new().with_n_alphas(5).with_cv(3);
46//! let x: Array2<f64> = array![
47//!     [1.0, 2.0], [2.0, 1.0], [3.0, 4.0], [4.0, 3.0], [5.0, 5.0], [2.0, 3.0],
48//!     [6.0, 1.0], [3.0, 3.0], [7.0, 2.0], [1.0, 5.0], [4.0, 6.0], [5.0, 2.0],
49//! ];
50//! let y: Array2<f64> = array![
51//!     [3.0, 1.0], [2.5, 2.0], [7.1, 3.5], [6.0, 4.2], [11.2, 6.0], [5.0, 3.0],
52//!     [9.0, 2.0], [6.5, 3.3], [12.0, 3.5], [3.0, 5.5], [8.5, 7.0], [9.5, 3.2],
53//! ];
54//!
55//! let fitted = model.fit(&x, &y).unwrap();
56//! let preds = fitted.predict(&x).unwrap();
57//! assert_eq!(preds.dim(), (12, 2));
58//! ```
59
60use ferrolearn_core::error::FerroError;
61use ferrolearn_core::traits::{Fit, Predict};
62use ndarray::{Array1, Array2, ScalarOperand};
63use num_traits::{Float, FromPrimitive};
64
65use crate::MultiTaskElasticNetCV;
66use crate::multi_task_elastic_net_cv::FittedMultiTaskElasticNetCV;
67
68/// Multi-task Lasso regression with built-in cross-validation for alpha
69/// selection.
70///
71/// The `l1_ratio = 1.0` specialization of [`MultiTaskElasticNetCV`]: it
72/// auto-generates the L21 alpha grid, runs k-fold CV fitting a
73/// [`crate::MultiTaskLasso`] per fold, selects the alpha minimizing mean CV MSE,
74/// and refits on the full data. Has NO `l1_ratio` parameter.
75///
76/// Mirrors `sklearn.linear_model.MultiTaskLassoCV`
77/// (`_coordinate_descent.py:3061`).
78///
79/// # Type Parameters
80///
81/// - `F`: The floating-point type (`f32` or `f64`).
82#[derive(Debug, Clone)]
83pub struct MultiTaskLassoCV<F> {
84    /// Number of alphas generated when no explicit grid is supplied.
85    n_alphas: usize,
86    /// Number of cross-validation folds.
87    cv: usize,
88    /// Maximum block-coordinate-descent iterations per inner fit.
89    max_iter: usize,
90    /// Convergence tolerance for block coordinate descent.
91    tol: F,
92    /// Whether to fit per-task intercept terms.
93    fit_intercept: bool,
94}
95
96impl<F: Float + FromPrimitive> MultiTaskLassoCV<F> {
97    /// Create a new `MultiTaskLassoCV` with default settings.
98    ///
99    /// Defaults mirror `sklearn.linear_model.MultiTaskLassoCV.__init__`
100    /// (`_coordinate_descent.py:3228-3256`):
101    /// - `n_alphas = 100`
102    /// - `cv = 5`
103    /// - `max_iter = 1000`
104    /// - `tol = 1e-4`
105    /// - `fit_intercept = true`
106    ///
107    /// There is NO `l1_ratio` parameter (fixed at `1.0`).
108    #[must_use]
109    pub fn new() -> Self {
110        Self {
111            n_alphas: 100,
112            cv: 5,
113            max_iter: 1000,
114            tol: F::from(1e-4).unwrap_or_else(F::epsilon),
115            fit_intercept: true,
116        }
117    }
118
119    /// Set the number of alphas generated for the path.
120    #[must_use]
121    pub fn with_n_alphas(mut self, n_alphas: usize) -> Self {
122        self.n_alphas = n_alphas;
123        self
124    }
125
126    /// Set the number of cross-validation folds (must be at least 2).
127    #[must_use]
128    pub fn with_cv(mut self, cv: usize) -> Self {
129        self.cv = cv;
130        self
131    }
132
133    /// Set the maximum number of block-coordinate-descent iterations.
134    #[must_use]
135    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
136        self.max_iter = max_iter;
137        self
138    }
139
140    /// Set the convergence tolerance.
141    #[must_use]
142    pub fn with_tol(mut self, tol: F) -> Self {
143        self.tol = tol;
144        self
145    }
146
147    /// Set whether to fit per-task intercept terms.
148    #[must_use]
149    pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
150        self.fit_intercept = fit_intercept;
151        self
152    }
153}
154
155impl<F: Float + FromPrimitive> Default for MultiTaskLassoCV<F> {
156    fn default() -> Self {
157        Self::new()
158    }
159}
160
161/// Fitted multi-task Lasso model with cross-validated alpha.
162///
163/// Stores the selected alpha, learned coefficient matrix (shape
164/// `(n_tasks, n_features)`, the sklearn `coef_` layout), and per-task intercept
165/// vector. Has NO `l1_ratio_` (fixed `l1_ratio = 1.0`).
166#[derive(Debug, Clone)]
167pub struct FittedMultiTaskLassoCV<F> {
168    /// The alpha that achieved the lowest CV error (sklearn `alpha_`).
169    alpha: F,
170    /// Learned coefficients, shape `(n_tasks, n_features)` (sklearn `coef_`).
171    coefficients: Array2<F>,
172    /// Per-task intercept vector, length `n_tasks` (sklearn `intercept_`).
173    intercepts: Array1<F>,
174}
175
176impl<F: Float + Clone> FittedMultiTaskLassoCV<F> {
177    /// Build from a fitted `MultiTaskElasticNetCV` (the delegate). Drops the
178    /// `l1_ratio_` (sklearn `MultiTaskLassoCV` deletes it, `:1831-1832`).
179    fn from_enet_cv(inner: &FittedMultiTaskElasticNetCV<F>) -> Self {
180        Self {
181            alpha: inner.alpha(),
182            coefficients: inner.coef().clone(),
183            intercepts: inner.intercept().clone(),
184        }
185    }
186
187    /// Returns the alpha value selected by cross-validation (sklearn `alpha_`).
188    #[must_use]
189    pub fn alpha(&self) -> F {
190        self.alpha
191    }
192
193    /// Borrow the learned coefficient matrix, shape `(n_tasks, n_features)`
194    /// (sklearn `coef_`).
195    #[must_use]
196    pub fn coef(&self) -> &Array2<F> {
197        &self.coefficients
198    }
199
200    /// Borrow the per-task intercept vector, length `n_tasks` (sklearn
201    /// `intercept_`).
202    #[must_use]
203    pub fn intercept(&self) -> &Array1<F> {
204        &self.intercepts
205    }
206}
207
208impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + 'static> Fit<Array2<F>, Array2<F>>
209    for MultiTaskLassoCV<F>
210{
211    type Fitted = FittedMultiTaskLassoCV<F>;
212    type Error = FerroError;
213
214    /// Fit the `MultiTaskLassoCV` model by delegating to
215    /// [`MultiTaskElasticNetCV`] with `l1_ratio` fixed to `1.0`.
216    ///
217    /// # Errors
218    ///
219    /// Forwards every error from the delegated `MultiTaskElasticNetCV::fit`
220    /// (shape mismatch, invalid parameters, insufficient samples, non-finite
221    /// input).
222    fn fit(&self, x: &Array2<F>, y: &Array2<F>) -> Result<FittedMultiTaskLassoCV<F>, FerroError> {
223        // sklearn `MultiTaskLassoCV` IS the `l1_ratio=1` specialization of the
224        // same `LinearModelCV` base; `_get_estimator` returns `MultiTaskLasso()`
225        // (`_coordinate_descent.py:3258`), and `MultiTaskLasso ==
226        // MultiTaskElasticNet(l1_ratio=1.0)`. We delegate to the ENet-CV core so
227        // the CV machinery (L21 alpha grid + contiguous folds + MSE-select +
228        // refit) is written once.
229        let one = F::one();
230        let enet_cv = MultiTaskElasticNetCV::<F>::new()
231            .with_l1_ratios(vec![one])
232            .with_n_alphas(self.n_alphas)
233            .with_cv(self.cv)
234            .with_max_iter(self.max_iter)
235            .with_tol(self.tol)
236            .with_fit_intercept(self.fit_intercept);
237
238        let inner = enet_cv.fit(x, y)?;
239        Ok(FittedMultiTaskLassoCV::from_enet_cv(&inner))
240    }
241}
242
243impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>>
244    for FittedMultiTaskLassoCV<F>
245{
246    type Output = Array2<F>;
247    type Error = FerroError;
248
249    /// Predict target values for the given feature matrix.
250    ///
251    /// Computes `X · coefᵀ + intercept` (broadcast per task column), returning
252    /// an `(n_samples, n_tasks)` array.
253    ///
254    /// # Errors
255    ///
256    /// Returns [`FerroError::ShapeMismatch`] if the number of features does not
257    /// match the fitted model.
258    fn predict(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
259        let n_features = x.ncols();
260        if n_features != self.coefficients.ncols() {
261            return Err(FerroError::ShapeMismatch {
262                expected: vec![self.coefficients.ncols()],
263                actual: vec![n_features],
264                context: "number of features must match fitted model".into(),
265            });
266        }
267
268        let mut preds = x.dot(&self.coefficients.t());
269        for (k, &b) in self.intercepts.iter().enumerate() {
270            let mut col = preds.column_mut(k);
271            col.mapv_inplace(|v| v + b);
272        }
273        Ok(preds)
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use ndarray::array;
281
282    #[test]
283    fn test_mtlasso_cv_default_builder() {
284        let m = MultiTaskLassoCV::<f64>::new();
285        assert_eq!(m.n_alphas, 100);
286        assert_eq!(m.cv, 5);
287        assert_eq!(m.max_iter, 1000);
288        assert!(m.fit_intercept);
289    }
290
291    #[test]
292    fn test_mtlasso_cv_builder_setters() {
293        let m = MultiTaskLassoCV::<f64>::new()
294            .with_n_alphas(5)
295            .with_cv(3)
296            .with_max_iter(500)
297            .with_tol(1e-6)
298            .with_fit_intercept(false);
299        assert_eq!(m.n_alphas, 5);
300        assert_eq!(m.cv, 3);
301        assert_eq!(m.max_iter, 500);
302        assert!(!m.fit_intercept);
303    }
304
305    #[test]
306    fn test_mtlasso_cv_shape_mismatch_error() {
307        let x: Array2<f64> = array![[1.0, 2.0], [2.0, 1.0], [3.0, 4.0]];
308        let y: Array2<f64> = array![[3.0, 1.0], [2.5, 2.0]];
309        let res = MultiTaskLassoCV::<f64>::new().fit(&x, &y);
310        assert!(matches!(res, Err(FerroError::ShapeMismatch { .. })));
311    }
312}