Skip to main content

ferrolearn_linear/
multi_task_lasso.rs

1//! Multi-task Lasso regression (joint multi-output L21 block coordinate descent).
2//!
3//! This module provides [`MultiTaskLasso`], the multi-output linear model that
4//! fits all target columns jointly under an L2,1 (group-Lasso) penalty,
5//! minimizing
6//!
7//! ```text
8//! (1 / (2 * n_samples)) * ||Y - X W||_F^2 + alpha * ||W||_21
9//! ```
10//!
11//! where `||W||_21 = sum_j sqrt(sum_k W[j,k]^2)` is the sum over features of the
12//! L2 norm of each feature's coefficient ROW across tasks. The mixed L2,1 norm
13//! couples a feature's coefficients across all tasks: a feature is either active
14//! for ALL tasks (a non-zero row) or inactive for all of them (an all-zero row),
15//! so `MultiTaskLasso` performs joint feature selection across outputs.
16//!
17//! Mirrors `sklearn.linear_model.MultiTaskLasso`
18//! (`sklearn/linear_model/_coordinate_descent.py:2663`, `class
19//! MultiTaskLasso(MultiTaskElasticNet)`); the production solver is the Cython
20//! `enet_coordinate_descent_multi_task` in `_cd_fast.pyx:740` (objective at
21//! `:756`, `0.5 * norm(Y - X W.T)^2 + l1_reg ||W.T||_21 + 0.5 * l2_reg
22//! norm(W.T)^2`). `MultiTaskLasso` is `MultiTaskElasticNet(l1_ratio=1.0)`, i.e.
23//! `l2_reg = 0`, `l1_reg = alpha * n_samples`. ferrolearn implements the dense
24//! block-coordinate-descent core directly.
25//!
26//! ## REQ status (per `.design/linear/lasso.md`, mirrors `sklearn/linear_model/_coordinate_descent.py` @ 1.5.2)
27//!
28//! | REQ | Status | Evidence |
29//! |---|---|---|
30//! | REQ-13 (MultiTaskLasso, multi-output L21 block CD) | SHIPPED | `MultiTaskLasso<F>` / `FittedMultiTaskLasso<F>` in this module: `impl Fit<Array2<F>, Array2<F>>` runs block coordinate descent porting `_cd_fast.pyx::enet_coordinate_descent_multi_task` (`:740-959`, `l2_reg=0`): `l1_reg = alpha*n`, per-feature block update `W[j,:] = tmp * max(1 - l1_reg/||tmp||, 0) / norm_cols_X[j]`, residual rank-1 maintenance, and the two-level relative-change + dual-gap stop (`:903-950`, `tol_scaled = tol*||Y||_F^2`). `coef_` is stored `(n_tasks, n_features)` matching sklearn `coef_`. The `dual_gap_` fitted attribute is now exposed via the `dual_gap: F` field + `#[must_use] pub fn dual_gap()` getter: `Fit::fit` captures the final block-CD duality gap (the value deciding convergence) into `final_gap` and stores it scaled `final_gap / n_samples`, mirroring `self.dual_gap_ /= n_samples` (`_coordinate_descent.py:2652`, unpacked at `:2636`). Verified against the live sklearn 1.5.2 oracle (R-CHAR-3): `MultiTaskLasso(alpha=0.3)` -> `dual_gap_=0.00021539018133829302`, `alpha=0.1` -> `0.00016093048471601534`, `alpha=1.0` -> `0.0001449879028545098` (`tests/divergence_multi_task_lasso.rs::mtl_dual_gap_matches_sklearn`, tol 1e-9). Verified against the live sklearn 1.5.2 oracle (R-CHAR-3): on `X=[[1,2],[2,1],[3,4],[4,3],[5,5]]`, `Y=[[3,1],[2.5,2],[7.1,3.5],[6,4.2],[11.2,6]]`, `MultiTaskLasso(alpha=0.3)` -> `coef_=[[0.7874471321,1.3745821226],[0.8341004367,0.3460953631]]`, `intercept_=[-0.5260877641,-0.2005873993]`, `n_iter_=19`. Input validation matches sklearn's `_validate_data(force_all_finite=True)` (`_coordinate_descent.py:2602`): any NaN/+/-inf in X or Y is rejected with `FerroError::InvalidParameter` BEFORE the solver (#2, `tests/divergence_multi_task_lasso_nonfinite.rs::mtl_rejects_non_finite_input_like_sklearn`). Tests `multi_task_lasso_matches_sklearn`, `multi_task_lasso_no_intercept_matches_sklearn`, `multi_task_lasso_group_sparsity`, `multi_task_lasso_predict_matches_sklearn`, `multi_task_lasso_shape_mismatch_errors`. Non-test consumer: `MultiTaskLasso` is the public estimator boundary API re-exported at the crate root (`ferrolearn_linear::MultiTaskLasso`, grandfathered boundary per goal.md S5). |
31//!
32//! # Examples
33//!
34//! ```
35//! use ferrolearn_linear::MultiTaskLasso;
36//! use ferrolearn_core::{Fit, Predict};
37//! use ndarray::{array, Array2};
38//!
39//! let model = MultiTaskLasso::<f64>::new().with_alpha(0.3);
40//! 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]];
41//! 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]];
42//!
43//! let fitted = model.fit(&x, &y).unwrap();
44//! let preds = fitted.predict(&x).unwrap();
45//! ```
46
47use ferrolearn_core::error::FerroError;
48use ferrolearn_core::traits::{Fit, Predict};
49use ndarray::{Array1, Array2, Axis, ScalarOperand};
50use num_traits::{Float, FromPrimitive};
51
52/// Multi-task Lasso regression (joint multi-output L2,1-regularized least
53/// squares).
54///
55/// Fits all target columns jointly under a group-Lasso (L2,1) penalty, so each
56/// feature is either active for all tasks or zero for all of them. Mirrors
57/// `sklearn.linear_model.MultiTaskLasso`
58/// (`_coordinate_descent.py:2663`).
59///
60/// # Type Parameters
61///
62/// - `F`: The floating-point type (`f32` or `f64`).
63#[derive(Debug, Clone)]
64pub struct MultiTaskLasso<F> {
65    /// Regularization strength on the L2,1 penalty. Larger values specify
66    /// stronger regularization and zero out more whole feature rows.
67    pub alpha: F,
68    /// Whether to fit a per-task intercept (bias) term.
69    pub fit_intercept: bool,
70    /// Maximum number of block-coordinate-descent iterations.
71    pub max_iter: usize,
72    /// Convergence tolerance on the relative coefficient change / dual gap.
73    pub tol: F,
74}
75
76impl<F: Float> MultiTaskLasso<F> {
77    /// Create a new `MultiTaskLasso` with default settings.
78    ///
79    /// Defaults: `alpha = 1.0`, `fit_intercept = true`, `max_iter = 1000`,
80    /// `tol = 1e-4` — mirroring sklearn's ctor defaults
81    /// `MultiTaskLasso(alpha=1.0, fit_intercept=True, max_iter=1000, tol=1e-4)`
82    /// (`_coordinate_descent.py:2663`).
83    #[must_use]
84    pub fn new() -> Self {
85        Self {
86            alpha: F::one(),
87            fit_intercept: true,
88            max_iter: 1000,
89            tol: F::from(1e-4).unwrap_or_else(F::epsilon),
90        }
91    }
92
93    /// Set the regularization strength.
94    #[must_use]
95    pub fn with_alpha(mut self, alpha: F) -> Self {
96        self.alpha = alpha;
97        self
98    }
99
100    /// Set whether to fit per-task intercept terms.
101    #[must_use]
102    pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
103        self.fit_intercept = fit_intercept;
104        self
105    }
106
107    /// Set the maximum number of iterations.
108    #[must_use]
109    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
110        self.max_iter = max_iter;
111        self
112    }
113
114    /// Set the convergence tolerance.
115    #[must_use]
116    pub fn with_tol(mut self, tol: F) -> Self {
117        self.tol = tol;
118        self
119    }
120}
121
122impl<F: Float> Default for MultiTaskLasso<F> {
123    fn default() -> Self {
124        Self::new()
125    }
126}
127
128/// Fitted multi-task Lasso regression model.
129///
130/// Stores the learned coefficient matrix (shape `(n_tasks, n_features)`, the
131/// sklearn `coef_` layout), the per-task intercept vector, and the number of
132/// block-coordinate-descent sweeps run. Implements [`Predict`].
133#[derive(Debug, Clone)]
134pub struct FittedMultiTaskLasso<F> {
135    /// Learned coefficients, shape `(n_tasks, n_features)` — matches sklearn's
136    /// `MultiTaskLasso.coef_` layout exactly. Whole feature columns (across the
137    /// task rows) are jointly zero or jointly non-zero.
138    coefficients: Array2<F>,
139    /// Per-task intercept vector, length `n_tasks`. Filled with zeros when
140    /// `fit_intercept = false`.
141    intercepts: Array1<F>,
142    /// Number of block-coordinate-descent sweeps run by the solver (1-based;
143    /// mirrors sklearn `MultiTaskLasso.n_iter_`).
144    n_iter: usize,
145    /// Duality gap at the returned solution, on the `(1 / (2 * n_samples))`-scaled
146    /// objective (mirrors sklearn `MultiTaskLasso.dual_gap_`).
147    dual_gap: F,
148}
149
150impl<F: Float> FittedMultiTaskLasso<F> {
151    /// Borrow the learned coefficient matrix, shape `(n_tasks, n_features)`
152    /// (sklearn `coef_` layout).
153    #[must_use]
154    pub fn coefficients(&self) -> &Array2<F> {
155        &self.coefficients
156    }
157
158    /// Borrow the per-task intercept vector, length `n_tasks` (sklearn
159    /// `intercept_`).
160    #[must_use]
161    pub fn intercepts(&self) -> &Array1<F> {
162        &self.intercepts
163    }
164
165    /// Number of block-coordinate-descent sweeps run by the solver (sklearn
166    /// `n_iter_`).
167    #[must_use]
168    pub fn n_iter(&self) -> usize {
169        self.n_iter
170    }
171
172    /// Duality gap at the returned solution, on the `(1 / (2 * n_samples))`-scaled
173    /// objective.
174    ///
175    /// Mirrors sklearn's `MultiTaskLasso.dual_gap_` attribute
176    /// (`_coordinate_descent.py:2636` — unpacked from
177    /// `enet_coordinate_descent_multi_task` — then `:2652`
178    /// `self.dual_gap_ /= n_samples`, the final objective-scaling). This is the
179    /// final block-CD duality gap (the value that decided convergence), scaled by
180    /// `1 / n_samples` so the exposed value matches sklearn's `dual_gap_`.
181    #[must_use]
182    pub fn dual_gap(&self) -> F {
183        self.dual_gap
184    }
185}
186
187impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + 'static> Fit<Array2<F>, Array2<F>>
188    for MultiTaskLasso<F>
189{
190    type Fitted = FittedMultiTaskLasso<F>;
191    type Error = FerroError;
192
193    /// Fit the multi-task Lasso model using block coordinate descent.
194    ///
195    /// Ports sklearn's `enet_coordinate_descent_multi_task`
196    /// (`_cd_fast.pyx:740`) with `l2_reg = 0` and `l1_reg = alpha * n_samples`.
197    ///
198    /// # Errors
199    ///
200    /// Returns [`FerroError::ShapeMismatch`] if `Y.nrows()` differs from the
201    /// number of samples in `X`.
202    /// Returns [`FerroError::InvalidParameter`] if `alpha` is negative.
203    /// Returns [`FerroError::InsufficientSamples`] if there are no samples.
204    /// Returns [`FerroError::NumericalInstability`] if a required float constant
205    /// or column mean cannot be formed.
206    fn fit(&self, x: &Array2<F>, y: &Array2<F>) -> Result<FittedMultiTaskLasso<F>, FerroError> {
207        let (n_samples, n_features) = x.dim();
208
209        if n_samples != y.nrows() {
210            return Err(FerroError::ShapeMismatch {
211                expected: vec![n_samples],
212                actual: vec![y.nrows()],
213                context: "Y rows must match number of samples in X".into(),
214            });
215        }
216
217        if self.alpha < F::zero() {
218            return Err(FerroError::InvalidParameter {
219                name: "alpha".into(),
220                reason: "must be non-negative".into(),
221            });
222        }
223
224        if n_samples == 0 {
225            return Err(FerroError::InsufficientSamples {
226                required: 1,
227                actual: 0,
228                context: "MultiTaskLasso requires at least one sample".into(),
229            });
230        }
231
232        // sklearn `MultiTaskElasticNet.fit` -> `self._validate_data(X, y,
233        // validate_separately=(check_X_params, check_y_params))`
234        // (`_coordinate_descent.py:2602`); both param dicts (`:2595`,`:2601`)
235        // inherit the default `force_all_finite=True`, so `check_array` rejects
236        // any NaN or +/-inf in X OR Y with a `ValueError` BEFORE the solver runs.
237        // `.iter().any(|v| !v.is_finite())` rejects both NaN and Inf, matching
238        // the crate idiom (e.g. `one_hot_encoder.rs`). (#2)
239        if x.iter().any(|v| !v.is_finite()) {
240            return Err(FerroError::InvalidParameter {
241                name: "X".into(),
242                reason: "Input X contains NaN or infinity.".into(),
243            });
244        }
245        if y.iter().any(|v| !v.is_finite()) {
246            return Err(FerroError::InvalidParameter {
247                name: "y".into(),
248                reason: "Input y contains NaN or infinity.".into(),
249            });
250        }
251
252        let n = n_samples;
253        let p = n_features;
254        let t = y.ncols();
255
256        let n_f = F::from(n).ok_or_else(|| FerroError::NumericalInstability {
257            message: "failed to convert n_samples to float".into(),
258        })?;
259        let half = F::from(0.5).ok_or_else(|| FerroError::NumericalInstability {
260            message: "failed to convert 0.5 to float".into(),
261        })?;
262
263        // Center the data when fitting per-task intercepts (sklearn
264        // `_preprocess_data`): `x_mean` is the column mean of X (len p),
265        // `y_mean` the column mean of Y (len t). When `!fit_intercept`, work on
266        // the raw design with zero means.
267        let (xc, yc, x_mean, y_mean) = if self.fit_intercept {
268            let x_mean = x
269                .mean_axis(Axis(0))
270                .ok_or_else(|| FerroError::NumericalInstability {
271                    message: "failed to compute column means of X".into(),
272                })?;
273            let y_mean = y
274                .mean_axis(Axis(0))
275                .ok_or_else(|| FerroError::NumericalInstability {
276                    message: "failed to compute column means of Y".into(),
277                })?;
278            let xc = x - &x_mean;
279            let yc = y - &y_mean;
280            (xc, yc, x_mean, y_mean)
281        } else {
282            (
283                x.clone(),
284                y.clone(),
285                Array1::<F>::zeros(p),
286                Array1::<F>::zeros(t),
287            )
288        };
289
290        // Internal working coefficient matrix is (n_features, n_tasks); the
291        // stored `coefficients` is its transpose to match sklearn's
292        // `(n_tasks, n_features)` `coef_`. Initialized to zeros.
293        let mut w_mat = Array2::<F>::zeros((p, t));
294
295        // Residual R = Yc - Xc.dot(W) = Yc initially (W == 0).
296        let mut r = yc.clone();
297
298        // norm_cols_x[j] = sum_i Xc[i,j]^2.
299        let norm_cols_x: Vec<F> = (0..p)
300            .map(|j| {
301                let col = xc.column(j);
302                col.dot(&col)
303            })
304            .collect();
305
306        // l1_reg = alpha * n (MultiTaskLasso has l2_reg = 0).
307        let l1_reg = self.alpha * n_f;
308
309        // tol_scaled = tol * ||Yc||_F^2 (sklearn `:832`, `tol *= norm(Y)^2`).
310        let y_norm2 = yc.iter().fold(F::zero(), |s, &v| s + v * v);
311        let tol_scaled = self.tol * y_norm2;
312        let d_w_tol = self.tol;
313
314        let mut n_iter_done = 0usize;
315        // Final duality gap (un-normalized objective, like sklearn's
316        // `enet_coordinate_descent_multi_task` return); scaled by `1/n` on store
317        // to mirror `dual_gap_ /= n_samples` (`_coordinate_descent.py:2652`).
318        let mut final_gap = F::zero();
319
320        for it in 0..self.max_iter {
321            n_iter_done = it + 1; // 1-based, mirroring sklearn `n_iter_`.
322            let mut w_max = F::zero();
323            let mut d_w_max = F::zero();
324
325            for j in 0..p {
326                if norm_cols_x[j] == F::zero() {
327                    continue;
328                }
329
330                // Store previous block W[j, :] (length t).
331                let w_j_old = w_mat.row(j).to_owned();
332
333                // tmp = norm_cols_x[j] * w_j_old + Xc[:, j]^T R.
334                let mut tmp = xc.column(j).dot(&r);
335                tmp = &tmp + &(&w_j_old * norm_cols_x[j]);
336
337                // nn = ||tmp||_2.
338                let nn = tmp.dot(&tmp).sqrt();
339
340                // scaling = max(1 - l1_reg/nn, 0) / norm_cols_x[j] (0 if nn==0).
341                let scaling = if nn == F::zero() {
342                    F::zero()
343                } else {
344                    (F::one() - l1_reg / nn).max(F::zero()) / norm_cols_x[j]
345                };
346
347                let w_j_new = &tmp * scaling;
348
349                // R -= Xc[:, j] outer (w_j_new - w_j_old).
350                let delta = &w_j_new - &w_j_old;
351                for i in 0..n {
352                    let xij = xc[[i, j]];
353                    for k in 0..t {
354                        r[[i, k]] = r[[i, k]] - xij * delta[k];
355                    }
356                }
357
358                w_mat.row_mut(j).assign(&w_j_new);
359
360                // Track the largest coordinate update and coefficient magnitude
361                // this sweep (`:894-901`).
362                let d_w_j = delta.iter().fold(F::zero(), |m, &v| m.max(v.abs()));
363                if d_w_j > d_w_max {
364                    d_w_max = d_w_j;
365                }
366                let w_j_abs = w_j_new.iter().fold(F::zero(), |m, &v| m.max(v.abs()));
367                if w_j_abs > w_max {
368                    w_max = w_j_abs;
369                }
370            }
371
372            // sklearn's two-level stop (`:903-952`): the relative-change gate
373            // opens the (expensive) dual-gap check; break only when the gap
374            // clears `tol * ||Y||_F^2`.
375            let last_iter = it == self.max_iter - 1;
376            if w_max == F::zero() || d_w_max / w_max < d_w_tol || last_iter {
377                // XtA = Xc^T R (l2_reg = 0), shape (p, t).
378                let xta = xc.t().dot(&r);
379
380                // dual_norm_XtA = max_j ||XtA[j, :]||_2.
381                let mut dual_norm = F::zero();
382                for j in 0..p {
383                    let row = xta.row(j);
384                    let rn = row.dot(&row).sqrt();
385                    if rn > dual_norm {
386                        dual_norm = rn;
387                    }
388                }
389
390                let r_norm2 = r.iter().fold(F::zero(), |s, &v| s + v * v);
391
392                let const_factor;
393                let mut gap;
394                if dual_norm > l1_reg {
395                    let c = l1_reg / dual_norm;
396                    let a_norm2 = r_norm2 * c * c;
397                    gap = half * (r_norm2 + a_norm2);
398                    const_factor = c;
399                } else {
400                    const_factor = F::one();
401                    gap = r_norm2;
402                }
403
404                // l21 norm of W = sum_j ||W[j, :]||_2.
405                let mut w21 = F::zero();
406                for j in 0..p {
407                    let row = w_mat.row(j);
408                    w21 = w21 + row.dot(&row).sqrt();
409                }
410
411                // ry = sum elementwise R * Yc over all entries.
412                let ry = (&r * &yc).sum();
413
414                gap = gap + l1_reg * w21 - const_factor * ry;
415
416                // Record the most recent gap; whichever iteration last runs the
417                // dual-gap check (the convergence break or the final sweep) is the
418                // value sklearn returns as `dual_gap_` (pre `/n_samples`).
419                final_gap = gap;
420
421                if gap < tol_scaled {
422                    break;
423                }
424            }
425        }
426
427        // Store coef_ as (n_tasks, n_features) = W^T.
428        let coefficients = w_mat.t().to_owned();
429
430        // intercept[k] = y_mean[k] - x_mean · W[:, k] (zeros when !fit_intercept,
431        // since both means are zero).
432        let mut intercepts = Array1::<F>::zeros(t);
433        if self.fit_intercept {
434            for k in 0..t {
435                intercepts[k] = y_mean[k] - x_mean.dot(&w_mat.column(k));
436            }
437        }
438
439        Ok(FittedMultiTaskLasso {
440            coefficients,
441            intercepts,
442            n_iter: n_iter_done,
443            // sklearn `_coordinate_descent.py:2652`: `self.dual_gap_ /= n_samples`
444            // maps the solver's un-normalized gap to the `(1/2n)`-scaled objective.
445            dual_gap: final_gap / n_f,
446        })
447    }
448}
449
450impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>>
451    for FittedMultiTaskLasso<F>
452{
453    type Output = Array2<F>;
454    type Error = FerroError;
455
456    /// Predict target values for the given feature matrix.
457    ///
458    /// Computes `pred[i, k] = sum_j X[i, j] * coef[k, j] + intercept[k]`, i.e.
459    /// `X.dot(coef^T) + intercepts` (broadcast per task column), returning an
460    /// `(n_samples, n_tasks)` array.
461    ///
462    /// # Errors
463    ///
464    /// Returns [`FerroError::ShapeMismatch`] if the number of features does not
465    /// match the fitted model.
466    fn predict(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
467        let n_features = x.ncols();
468        // coefficients is (n_tasks, n_features); columns == features.
469        if n_features != self.coefficients.ncols() {
470            return Err(FerroError::ShapeMismatch {
471                expected: vec![self.coefficients.ncols()],
472                actual: vec![n_features],
473                context: "number of features must match fitted model".into(),
474            });
475        }
476
477        // pred = X · coef^T, shape (n_samples, n_tasks).
478        let mut preds = x.dot(&self.coefficients.t());
479        // Broadcast-add per-task intercepts.
480        for (k, &b) in self.intercepts.iter().enumerate() {
481            let mut col = preds.column_mut(k);
482            col.mapv_inplace(|v| v + b);
483        }
484        Ok(preds)
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use approx::assert_relative_eq;
492    use ndarray::{array, s};
493
494    /// Shared oracle fixture (R-CHAR-3).
495    fn fixture() -> (Array2<f64>, Array2<f64>) {
496        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],];
497        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],];
498        (x, y)
499    }
500
501    #[test]
502    fn multi_task_lasso_matches_sklearn() -> Result<(), FerroError> {
503        // Live sklearn 1.5.2 oracle (R-CHAR-3):
504        //   from sklearn.linear_model import MultiTaskLasso; import numpy as np
505        //   X=np.array([[1,2],[2,1],[3,4],[4,3],[5,5]],float)
506        //   Y=np.array([[3,1],[2.5,2],[7.1,3.5],[6,4.2],[11.2,6]])
507        //   m=MultiTaskLasso(alpha=0.3).fit(X,Y)
508        //   m.coef_  -> [[0.7874471321,1.3745821226],[0.8341004367,0.3460953631]]
509        //   m.intercept_ -> [-0.5260877641,-0.2005873993]
510        //   m.n_iter_ -> 19
511        let (x, y) = fixture();
512        let fitted = MultiTaskLasso::<f64>::new().with_alpha(0.3).fit(&x, &y)?;
513
514        let coef = fitted.coefficients();
515        assert_eq!(coef.dim(), (2, 2));
516        assert_relative_eq!(coef[[0, 0]], 0.787_447_132_1, epsilon = 1e-6);
517        assert_relative_eq!(coef[[0, 1]], 1.374_582_122_6, epsilon = 1e-6);
518        assert_relative_eq!(coef[[1, 0]], 0.834_100_436_7, epsilon = 1e-6);
519        assert_relative_eq!(coef[[1, 1]], 0.346_095_363_1, epsilon = 1e-6);
520
521        let intercepts = fitted.intercepts();
522        assert_relative_eq!(intercepts[0], -0.526_087_764_1, epsilon = 1e-6);
523        assert_relative_eq!(intercepts[1], -0.200_587_399_3, epsilon = 1e-6);
524
525        assert_eq!(fitted.n_iter(), 19);
526        Ok(())
527    }
528
529    #[test]
530    fn multi_task_lasso_no_intercept_matches_sklearn() -> Result<(), FerroError> {
531        // Live sklearn 1.5.2 oracle (R-CHAR-3):
532        //   m=MultiTaskLasso(alpha=0.3, fit_intercept=False).fit(X,Y)
533        //   m.coef_ -> [[0.7223086317,1.2938631723],[0.8006773177,0.3236384717]]
534        //   m.intercept_ -> [0.,0.]
535        //   m.n_iter_ -> 85
536        let (x, y) = fixture();
537        let fitted = MultiTaskLasso::<f64>::new()
538            .with_alpha(0.3)
539            .with_fit_intercept(false)
540            .fit(&x, &y)?;
541
542        let coef = fitted.coefficients();
543        assert_relative_eq!(coef[[0, 0]], 0.722_308_631_7, epsilon = 1e-6);
544        assert_relative_eq!(coef[[0, 1]], 1.293_863_172_3, epsilon = 1e-6);
545        assert_relative_eq!(coef[[1, 0]], 0.800_677_317_7, epsilon = 1e-6);
546        assert_relative_eq!(coef[[1, 1]], 0.323_638_471_7, epsilon = 1e-6);
547
548        let intercepts = fitted.intercepts();
549        assert_eq!(intercepts[0], 0.0);
550        assert_eq!(intercepts[1], 0.0);
551
552        assert_eq!(fitted.n_iter(), 85);
553        Ok(())
554    }
555
556    #[test]
557    fn multi_task_lasso_group_sparsity() -> Result<(), FerroError> {
558        // With a large alpha the L21 penalty zeros WHOLE feature rows jointly:
559        // every coefficient is driven to (bit-near) zero.
560        let (x, y) = fixture();
561        let fitted = MultiTaskLasso::<f64>::new().with_alpha(5.0).fit(&x, &y)?;
562
563        let coef = fitted.coefficients();
564        for &c in coef.iter() {
565            assert_relative_eq!(c, 0.0, epsilon = 1e-9);
566        }
567        Ok(())
568    }
569
570    #[test]
571    fn multi_task_lasso_predict_matches_sklearn() -> Result<(), FerroError> {
572        // Live sklearn 1.5.2 oracle (R-CHAR-3):
573        //   m=MultiTaskLasso(alpha=0.3).fit(X,Y); m.predict(X[:2])
574        //   -> [[3.01052361,1.32570376],[2.42338862,1.81370884]]
575        let (x, y) = fixture();
576        let fitted = MultiTaskLasso::<f64>::new().with_alpha(0.3).fit(&x, &y)?;
577
578        let x_head = x.slice(s![0..2, ..]).to_owned();
579        let preds = fitted.predict(&x_head)?;
580
581        assert_eq!(preds.dim(), (2, 2));
582        assert_relative_eq!(preds[[0, 0]], 3.010_523_61, epsilon = 1e-6);
583        assert_relative_eq!(preds[[0, 1]], 1.325_703_76, epsilon = 1e-6);
584        assert_relative_eq!(preds[[1, 0]], 2.423_388_62, epsilon = 1e-6);
585        assert_relative_eq!(preds[[1, 1]], 1.813_708_84, epsilon = 1e-6);
586        Ok(())
587    }
588
589    #[test]
590    fn multi_task_lasso_shape_mismatch_errors() {
591        // Y rows != X rows -> ShapeMismatch.
592        let x: Array2<f64> = array![[1.0, 2.0], [2.0, 1.0], [3.0, 4.0]];
593        let y_bad: Array2<f64> = array![[3.0, 1.0], [2.5, 2.0]];
594        let res = MultiTaskLasso::<f64>::new().fit(&x, &y_bad);
595        assert!(matches!(res, Err(FerroError::ShapeMismatch { .. })));
596
597        // Negative alpha -> InvalidParameter.
598        let y_ok: Array2<f64> = array![[3.0, 1.0], [2.5, 2.0], [7.1, 3.5]];
599        let res2 = MultiTaskLasso::<f64>::new().with_alpha(-1.0).fit(&x, &y_ok);
600        assert!(matches!(res2, Err(FerroError::InvalidParameter { .. })));
601    }
602}