Skip to main content

ferrolearn_linear/
multi_task_elastic_net.rs

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