//! Multi-task Lasso regression with built-in cross-validation for alpha
//! selection.
//!
//! This module provides [`MultiTaskLassoCV`], the `l1_ratio = 1.0`
//! specialization of [`crate::MultiTaskElasticNetCV`]: it auto-generates the
//! multi-task L21 alpha grid, runs k-fold cross-validation fitting a
//! [`crate::MultiTaskLasso`] per fold, selects the alpha minimizing mean CV MSE,
//! and refits on the full data. Unlike `MultiTaskElasticNetCV` it has NO
//! `l1_ratio` parameter (fixed at `1.0`) and exposes no `l1_ratio_` attribute.
//!
//! Mirrors `sklearn.linear_model.MultiTaskLassoCV`
//! (`sklearn/linear_model/_coordinate_descent.py:3061`,
//! `class MultiTaskLassoCV(RegressorMixin, LinearModelCV)`), whose `__init__`
//! (`:3228-3256`) drops `l1_ratio` entirely and whose `_get_estimator`
//! (`:3258`) returns a `MultiTaskLasso()`. The shared `LinearModelCV.fit`
//! machinery is the same path used by `MultiTaskElasticNetCV`; with the L1/L2
//! mixing fixed to `1.0` the inner solver reduces to the pure L21 (group-Lasso)
//! `MultiTaskLasso`. This implementation DELEGATES to
//! [`crate::MultiTaskElasticNetCV`] with `l1_ratios = [1.0]` (so the CV core is
//! written once), matching the sklearn pattern where `MultiTaskLassoCV` is the
//! `l1_ratio=1` specialization of the same `LinearModelCV` base.
//!
//! ## REQ status (per `.design/linear/lasso_cv.md`, mirrors `sklearn/linear_model/_coordinate_descent.py` @ 1.5.2)
//!
//! Scope mirrors the single-task `LassoCV` REQ-1/2 precedent and the
//! `MultiTaskElasticNetCV` core: the CORE CV path (L21 alpha grid + contiguous
//! k-fold + MSE-select + refit, fixed l1_ratio=1.0) SHIPPED; advanced attrs
//! NOT-STARTED.
//!
//! | REQ | Status | Evidence |
//! |---|---|---|
//! | 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`. |
//! | 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`. |
//! | REQ-3 (predict / fit_intercept) | SHIPPED | `Predict for FittedMultiTaskLassoCV` = `X·coefᵀ + intercept` → `(n,t)`; `with_fit_intercept` threads into the delegate. |
//! | REQ-4 (contiguous KFold) | SHIPPED | inherited from the delegate's `kfold_indices` (sklearn non-shuffled `KFold`), so the selected `alpha_` matches sklearn. |
//! | 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). |
//!
//! # Examples
//!
//! ```
//! use ferrolearn_linear::MultiTaskLassoCV;
//! use ferrolearn_core::{Fit, Predict};
//! use ndarray::{array, Array2};
//!
//! let model = MultiTaskLassoCV::<f64>::new().with_n_alphas(5).with_cv(3);
//! 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], [2.0, 3.0],
//! [6.0, 1.0], [3.0, 3.0], [7.0, 2.0], [1.0, 5.0], [4.0, 6.0], [5.0, 2.0],
//! ];
//! let y: Array2<f64> = array![
//! [3.0, 1.0], [2.5, 2.0], [7.1, 3.5], [6.0, 4.2], [11.2, 6.0], [5.0, 3.0],
//! [9.0, 2.0], [6.5, 3.3], [12.0, 3.5], [3.0, 5.5], [8.5, 7.0], [9.5, 3.2],
//! ];
//!
//! let fitted = model.fit(&x, &y).unwrap();
//! let preds = fitted.predict(&x).unwrap();
//! assert_eq!(preds.dim(), (12, 2));
//! ```
use ferrolearn_core::error::FerroError;
use ferrolearn_core::traits::{Fit, Predict};
use ndarray::{Array1, Array2, ScalarOperand};
use num_traits::{Float, FromPrimitive};
use crate::MultiTaskElasticNetCV;
use crate::multi_task_elastic_net_cv::FittedMultiTaskElasticNetCV;
/// Multi-task Lasso regression with built-in cross-validation for alpha
/// selection.
///
/// The `l1_ratio = 1.0` specialization of [`MultiTaskElasticNetCV`]: it
/// auto-generates the L21 alpha grid, runs k-fold CV fitting a
/// [`crate::MultiTaskLasso`] per fold, selects the alpha minimizing mean CV MSE,
/// and refits on the full data. Has NO `l1_ratio` parameter.
///
/// Mirrors `sklearn.linear_model.MultiTaskLassoCV`
/// (`_coordinate_descent.py:3061`).
///
/// # Type Parameters
///
/// - `F`: The floating-point type (`f32` or `f64`).
#[derive(Debug, Clone)]
pub struct MultiTaskLassoCV<F> {
/// Number of alphas generated when no explicit grid is supplied.
n_alphas: usize,
/// Number of cross-validation folds.
cv: usize,
/// Maximum block-coordinate-descent iterations per inner fit.
max_iter: usize,
/// Convergence tolerance for block coordinate descent.
tol: F,
/// Whether to fit per-task intercept terms.
fit_intercept: bool,
}
impl<F: Float + FromPrimitive> MultiTaskLassoCV<F> {
/// Create a new `MultiTaskLassoCV` with default settings.
///
/// Defaults mirror `sklearn.linear_model.MultiTaskLassoCV.__init__`
/// (`_coordinate_descent.py:3228-3256`):
/// - `n_alphas = 100`
/// - `cv = 5`
/// - `max_iter = 1000`
/// - `tol = 1e-4`
/// - `fit_intercept = true`
///
/// There is NO `l1_ratio` parameter (fixed at `1.0`).
#[must_use]
pub fn new() -> Self {
Self {
n_alphas: 100,
cv: 5,
max_iter: 1000,
tol: F::from(1e-4).unwrap_or_else(F::epsilon),
fit_intercept: true,
}
}
/// Set the number of alphas generated for the path.
#[must_use]
pub fn with_n_alphas(mut self, n_alphas: usize) -> Self {
self.n_alphas = n_alphas;
self
}
/// Set the number of cross-validation folds (must be at least 2).
#[must_use]
pub fn with_cv(mut self, cv: usize) -> Self {
self.cv = cv;
self
}
/// Set the maximum number of block-coordinate-descent iterations.
#[must_use]
pub fn with_max_iter(mut self, max_iter: usize) -> Self {
self.max_iter = max_iter;
self
}
/// Set the convergence tolerance.
#[must_use]
pub fn with_tol(mut self, tol: F) -> Self {
self.tol = tol;
self
}
/// Set whether to fit per-task intercept terms.
#[must_use]
pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
self.fit_intercept = fit_intercept;
self
}
}
impl<F: Float + FromPrimitive> Default for MultiTaskLassoCV<F> {
fn default() -> Self {
Self::new()
}
}
/// Fitted multi-task Lasso model with cross-validated alpha.
///
/// Stores the selected alpha, learned coefficient matrix (shape
/// `(n_tasks, n_features)`, the sklearn `coef_` layout), and per-task intercept
/// vector. Has NO `l1_ratio_` (fixed `l1_ratio = 1.0`).
#[derive(Debug, Clone)]
pub struct FittedMultiTaskLassoCV<F> {
/// The alpha that achieved the lowest CV error (sklearn `alpha_`).
alpha: F,
/// Learned coefficients, shape `(n_tasks, n_features)` (sklearn `coef_`).
coefficients: Array2<F>,
/// Per-task intercept vector, length `n_tasks` (sklearn `intercept_`).
intercepts: Array1<F>,
}
impl<F: Float + Clone> FittedMultiTaskLassoCV<F> {
/// Build from a fitted `MultiTaskElasticNetCV` (the delegate). Drops the
/// `l1_ratio_` (sklearn `MultiTaskLassoCV` deletes it, `:1831-1832`).
fn from_enet_cv(inner: &FittedMultiTaskElasticNetCV<F>) -> Self {
Self {
alpha: inner.alpha(),
coefficients: inner.coef().clone(),
intercepts: inner.intercept().clone(),
}
}
/// Returns the alpha value selected by cross-validation (sklearn `alpha_`).
#[must_use]
pub fn alpha(&self) -> F {
self.alpha
}
/// Borrow the learned coefficient matrix, shape `(n_tasks, n_features)`
/// (sklearn `coef_`).
#[must_use]
pub fn coef(&self) -> &Array2<F> {
&self.coefficients
}
/// Borrow the per-task intercept vector, length `n_tasks` (sklearn
/// `intercept_`).
#[must_use]
pub fn intercept(&self) -> &Array1<F> {
&self.intercepts
}
}
impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + 'static> Fit<Array2<F>, Array2<F>>
for MultiTaskLassoCV<F>
{
type Fitted = FittedMultiTaskLassoCV<F>;
type Error = FerroError;
/// Fit the `MultiTaskLassoCV` model by delegating to
/// [`MultiTaskElasticNetCV`] with `l1_ratio` fixed to `1.0`.
///
/// # Errors
///
/// Forwards every error from the delegated `MultiTaskElasticNetCV::fit`
/// (shape mismatch, invalid parameters, insufficient samples, non-finite
/// input).
fn fit(&self, x: &Array2<F>, y: &Array2<F>) -> Result<FittedMultiTaskLassoCV<F>, FerroError> {
// sklearn `MultiTaskLassoCV` IS the `l1_ratio=1` specialization of the
// same `LinearModelCV` base; `_get_estimator` returns `MultiTaskLasso()`
// (`_coordinate_descent.py:3258`), and `MultiTaskLasso ==
// MultiTaskElasticNet(l1_ratio=1.0)`. We delegate to the ENet-CV core so
// the CV machinery (L21 alpha grid + contiguous folds + MSE-select +
// refit) is written once.
let one = F::one();
let enet_cv = MultiTaskElasticNetCV::<F>::new()
.with_l1_ratios(vec![one])
.with_n_alphas(self.n_alphas)
.with_cv(self.cv)
.with_max_iter(self.max_iter)
.with_tol(self.tol)
.with_fit_intercept(self.fit_intercept);
let inner = enet_cv.fit(x, y)?;
Ok(FittedMultiTaskLassoCV::from_enet_cv(&inner))
}
}
impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>>
for FittedMultiTaskLassoCV<F>
{
type Output = Array2<F>;
type Error = FerroError;
/// Predict target values for the given feature matrix.
///
/// Computes `X · coefᵀ + intercept` (broadcast per task column), returning
/// an `(n_samples, n_tasks)` array.
///
/// # Errors
///
/// Returns [`FerroError::ShapeMismatch`] if the number of features does not
/// match the fitted model.
fn predict(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
let n_features = x.ncols();
if n_features != self.coefficients.ncols() {
return Err(FerroError::ShapeMismatch {
expected: vec![self.coefficients.ncols()],
actual: vec![n_features],
context: "number of features must match fitted model".into(),
});
}
let mut preds = x.dot(&self.coefficients.t());
for (k, &b) in self.intercepts.iter().enumerate() {
let mut col = preds.column_mut(k);
col.mapv_inplace(|v| v + b);
}
Ok(preds)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::array;
#[test]
fn test_mtlasso_cv_default_builder() {
let m = MultiTaskLassoCV::<f64>::new();
assert_eq!(m.n_alphas, 100);
assert_eq!(m.cv, 5);
assert_eq!(m.max_iter, 1000);
assert!(m.fit_intercept);
}
#[test]
fn test_mtlasso_cv_builder_setters() {
let m = MultiTaskLassoCV::<f64>::new()
.with_n_alphas(5)
.with_cv(3)
.with_max_iter(500)
.with_tol(1e-6)
.with_fit_intercept(false);
assert_eq!(m.n_alphas, 5);
assert_eq!(m.cv, 3);
assert_eq!(m.max_iter, 500);
assert!(!m.fit_intercept);
}
#[test]
fn test_mtlasso_cv_shape_mismatch_error() {
let x: Array2<f64> = array![[1.0, 2.0], [2.0, 1.0], [3.0, 4.0]];
let y: Array2<f64> = array![[3.0, 1.0], [2.5, 2.0]];
let res = MultiTaskLassoCV::<f64>::new().fit(&x, &y);
assert!(matches!(res, Err(FerroError::ShapeMismatch { .. })));
}
}