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}