Skip to main content

ferrolearn_linear/
linear_regression.rs

1//! Ordinary Least Squares linear regression.
2//!
3//! This module provides [`LinearRegression`], which fits a linear model by
4//! solving the least squares problem via a single SVD (the LAPACK-`gelsd`
5//! minimum-norm path, through `ferray::linalg::lstsq`):
6//!
7//! ```text
8//! minimize ||X @ w - y||^2
9//! ```
10//!
11//! ## REQ status (per `.design/linear/linear_regression.md`, mirrors `sklearn/linear_model/_base.py` @ 1.5.2)
12//!
13//! Mirrors `sklearn.linear_model.LinearRegression` (`_base.py:465`). Full-rank,
14//! rank-deficient, and underdetermined OLS all match the live sklearn oracle to
15//! 1e-8: the solve routes through `crate::linalg::solve_lstsq` →
16//! `ferray::linalg::lstsq` (single-SVD, LAPACK-`gelsd`-equivalent min-norm),
17//! mirroring sklearn's `linalg.lstsq(X, y)` (`_base.py:687`).
18//!
19//! | REQ | Status | Evidence |
20//! |---|---|---|
21//! | REQ-1 (full-rank OLS coef_/intercept_) | SHIPPED | `Fit for LinearRegression` (centering + `linalg::solve_lstsq` via `ferray::linalg::lstsq`); full-rank coef/intercept match oracle to 1e-8. Consumer: `RsLinearRegression` in `ferrolearn-python/src/regressors.rs`. Mirrors `_base.py:582`, intercept `_base.py:308`. |
22//! | REQ-2 (predict = X·coef + intercept) | SHIPPED | `Predict for FittedLinearRegression`. Mirrors `_base.py:282`. |
23//! | REQ-3 (fit_intercept incl. false) | SHIPPED | `with_fit_intercept`; `fit_intercept=false` forces intercept 0. Mirrors `_base.py:571`. |
24//! | REQ-4 (HasCoefficients introspection) | SHIPPED | `HasCoefficients for FittedLinearRegression`. Mirrors fitted attrs `_base.py:499/511`. |
25//! | REQ-5 (min-norm for rank-deficient / underdetermined X) | SHIPPED | `Fit for LinearRegression` calls `crate::linalg::solve_lstsq` → `ferray::linalg::lstsq` (`ferray-linalg/src/solve.rs:208`), the single-SVD gelsd-equivalent min-norm solver mirroring `_base.py:687`. Closes #376 (rank-deficient min-norm) + #377 (underdetermined accepted). Tests now passing (`#[ignore]` removed): `divergence_rank_deficient_no_intercept_min_norm`, `divergence_rank_deficient_with_intercept_min_norm`, `divergence_underdetermined_accepted_min_norm` in `tests/divergence_linreg_minnorm.rs`. |
26//! | REQ-6 (positive=True / NNLS) | SHIPPED | `LinearRegression<F>` adds `pub positive: bool` (default `false`, `_base.py:574`) + `with_positive(bool)` builder. `fit_with_sample_weight`'s coefficient solve routes through `solve_coef`, which calls `crate::linalg::nnls` (Lawson-Hanson active-set NNLS solving the passive-set unconstrained LS via `solve_lstsq` on the passive columns) instead of `solve_lstsq` when `self.positive`, on the SAME centered-and-`√w`-rescaled design — mirroring sklearn's `self.coef_ = optimize.nnls(X, y)[0]` (`_base.py:647`) after `_preprocess_data`/`_rescale_data`. Intercept recovered identically (`y_off − x_off·coef` when fit_intercept, else 0; `_set_intercept`, `_base.py:692`). `rank_`/`singular_` are still taken from the `solve_lstsq` SVD of the design (sklearn leaves them unset on the positive path; ferrolearn reports the design's SVD as a documented analog). `positive=false` (default) is byte-identical to the unconstrained OLS path. Oracle tests: `linreg_positive_matches_sklearn` (coef `[2.03571429, 0.0]`, intercept `-1.46428571`, all ≥ 0, differs from unconstrained `[2.25, -0.75]`), `linreg_positive_no_intercept_matches_sklearn` (raw `nnls(X,y)` `[1.34210526, 0.0]`), `linreg_positive_false_unchanged` (byte-identical guard); `nnls_matches_scipy`/`nnls_equals_ols_when_unconstrained_nonneg` in `linalg.rs`. Closes #371. |
27//! | REQ-7 (multi-output 2-D Y → 2-D coef_) | SHIPPED | Additive `Fit<Array2<F>, Array2<F>>` arm (does NOT touch the 1-D `Fit`/`FittedLinearRegression`/`Predict`) producing `FittedMultiOutputLinearRegression<F>` — `coefficients` shape `(n_targets, n_features)` (sklearn `coef_` orientation, `coef_.T` of the lstsq solution, `_base.py:688`), `intercepts` `(n_targets,)`, `rank_`/`singular_`. Solves all targets in one SVD via `linalg::solve_lstsq_multi` → `ferray::linalg::lstsq` with a 2-D `b` (mirrors `linalg.lstsq(X, Y)`, `_base.py:687`); shared X-centering + per-target y-offset, `intercepts = y_off − coefficients · x_off` (`_set_intercept`, `_base.py:322`); `fit_intercept=false` → raw solve, intercepts 0. `Predict<Array2<F>, Output=Array2<F>>` returns `X · coef_.T + intercepts` shape `(n_samples, n_targets)` (`_base.py:290`). Oracle tests `linreg_multioutput_coef_intercept_match_sklearn` (coef `[[2.06666667,-0.06666667],[0.86666667,0.23333333]]`, intercept `[-0.06666667,0.13333333]`), `linreg_multioutput_predict_shape_and_values` (`predict(X[:2]) = [[2.0,1.0],[4.0,2.1]]`), `linreg_multioutput_no_intercept` (coef `[[2.0195121951,-0.0097560976],[0.9609756098,0.1195121951]]`, intercepts 0), `linreg_single_output_unchanged` (1-D path byte-identical). Closes #372. |
28//! | REQ-8 (sample_weight in fit) | SHIPPED | `LinearRegression::fit_with_sample_weight` solves WEIGHTED least squares `min Σᵢ wᵢ(yᵢ−xᵢ·w)²`: weighted offsets `x_off[j]=Σwᵢx[i,j]/Σwᵢ`, `y_off=Σwᵢyᵢ/Σwᵢ` (mirrors `_average(...,weights=sample_weight)`, `_base.py:193`/`:198`), centering, then `√wᵢ` row-rescaling (`_rescale_data`, `_base.py:641`), `linalg::solve_lstsq` on the rescaled design, `intercept = y_off − x_off·coef` (`_set_intercept`, `_base.py:320`); `fit_intercept=false` skips centering, intercept 0. `Fit::fit` delegates `fit_with_sample_weight(x, y, None)` (None path byte-identical to the historic OLS body). Oracle tests `linreg_fit_sample_weight_with_intercept_matches_sklearn` (coef 2.0935828877, intercept −0.2326203209), `linreg_fit_sample_weight_no_intercept_matches_sklearn` (coef 2.0350877193, intercept 0), `linreg_fit_none_sample_weight_equals_unweighted`. Mirrors `fit(..., sample_weight=None)` (`_base.py:582`). Closes #373. |
29//! | REQ-9 (rank_/singular_/copy_X/n_jobs) | SHIPPED | `FittedLinearRegression` stores `rank_`/`singular_` (captured from `linalg::solve_lstsq` on the matrix actually solved — centered `X` when `fit_intercept`, raw `X` otherwise, matching sklearn `_base.py:687`), exposed via `rank()`/`singular_values()`; `LinearRegression` adds `copy_x` (default `true`) + `n_jobs` (default `None`) fields with `with_copy_x`/`with_n_jobs` builders, mirroring `_parameter_constraints` (`_base.py:561`) and the ctor (`_base.py:572-573`). `copy_x` is ABI-only (fit never mutates `x`); `n_jobs` stored-but-ignored (single-threaded). Oracle tests `linreg_rank_singular_match_sklearn_with_intercept` (rank 2, singular `[1.61803399, 0.61803399]` on centered X), `linreg_singular_no_intercept_matches_raw_x` (singular `[5.25371017, 0.63129192]` on raw X), `linreg_copy_x_default_and_builder`. Closes #374. |
30//! | REQ-10 (ferray substrate) | NOT-STARTED | blocker #375 — OLS solve now on `ferray::linalg::lstsq`, but `LinearRegression`'s coef storage is still `ndarray` (coef return type tied to #359); fully on-substrate when the boundary `ndarray` types migrate. |
31//! | REQ-11 (non-finite input rejected) | SHIPPED | `fit_with_sample_weight` (the shared entry `Fit::fit` delegates to) rejects any NaN/+/-inf in X or y BEFORE centering/solve with `FerroError::InvalidParameter`, mirroring sklearn's `_validate_data(force_all_finite=True)` (`_base.py:609`, default `force_all_finite=True` → `check_array` raises `ValueError("Input X contains NaN.")` / `"... contains infinity ..."`). `.iter().any(|v| !v.is_finite())` catches both NaN and Inf; the finite path is byte-identical (the guard never fires on finite input). Verified vs the live sklearn 1.5.2 oracle (R-CHAR-3): `LinearRegression().fit` raises `ValueError` for NaN/+inf/-inf in X and NaN/inf in y (`tests/divergence_linear_nonfinite.rs::linreg_*`). Non-test consumer: the existing `Fit::fit` / `RsLinearRegression` consumers. (#2256) |
32//!
33//! Two states only per goal.md R-DEFER-2. The OLS min-norm contract (#376/#377)
34//! is fixed in `linalg.rs` via the ferray substrate.
35//!
36//! # Examples
37//!
38//! ```
39//! use ferrolearn_linear::LinearRegression;
40//! use ferrolearn_core::{Fit, Predict};
41//! use ndarray::{array, Array1, Array2};
42//!
43//! let model = LinearRegression::<f64>::new();
44//! let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
45//! let y = array![2.0, 4.0, 6.0, 8.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::introspection::HasCoefficients;
53use ferrolearn_core::pipeline::{FittedPipelineEstimator, PipelineEstimator};
54use ferrolearn_core::traits::{Fit, Predict};
55use ndarray::{Array1, Array2, Axis, ScalarOperand};
56use num_traits::{Float, FromPrimitive};
57
58use crate::linalg;
59
60/// Ordinary least squares linear regression.
61///
62/// Solves the least-squares problem via a single SVD (minimum-norm,
63/// LAPACK-`gelsd`-equivalent, through `ferray::linalg::lstsq`). The
64/// `fit_intercept` option controls whether a bias (intercept) term is
65/// included.
66///
67/// # Type Parameters
68///
69/// - `F`: The floating-point type (`f32` or `f64`).
70#[derive(Debug, Clone)]
71pub struct LinearRegression<F> {
72    /// Whether to fit an intercept (bias) term.
73    pub fit_intercept: bool,
74    /// Whether `X` may be overwritten during fit (sklearn `copy_X`,
75    /// `_base.py:480`). ferrolearn's `fit` never mutates `x` (it reads via
76    /// `.iter()`/`.mean_axis()`), so the observable non-mutation contract
77    /// holds for either value; the field is exposed for ABI parity. Default
78    /// `true`, matching sklearn (`_base.py:572`).
79    pub copy_x: bool,
80    /// Number of jobs for the computation (sklearn `n_jobs`, `_base.py:483`).
81    /// ferrolearn's dense OLS solve is single-threaded, so this is stored but
82    /// ignored — parallelism is a no-op here and behaviour matches sklearn's
83    /// `n_jobs=None` single-job default. Default `None` (`_base.py:573`).
84    pub n_jobs: Option<usize>,
85    /// When `true`, constrains the fitted coefficients to be non-negative via
86    /// non-negative least squares (sklearn `positive`, `_base.py:574`). sklearn
87    /// solves the (centered, `√w`-rescaled) coefficient system with
88    /// `scipy.optimize.nnls` instead of `linalg.lstsq` when `positive=True`
89    /// (`_base.py:645-647`). Default `false`, matching sklearn's
90    /// `positive=False` (`_base.py:574`); when `false`, the fit is
91    /// byte-identical to the unconstrained OLS path.
92    pub positive: bool,
93    _marker: std::marker::PhantomData<F>,
94}
95
96impl<
97    F: Float
98        + Send
99        + Sync
100        + ScalarOperand
101        + num_traits::FromPrimitive
102        + ferray::linalg::LinalgFloat
103        + 'static,
104> LinearRegression<F>
105{
106    /// Fit the linear regression model with optional per-sample weights.
107    ///
108    /// Mirrors scikit-learn's `LinearRegression.fit(X, y, sample_weight=None)`
109    /// (`sklearn/linear_model/_base.py:582`). When `sample_weight` is `Some(w)`,
110    /// this solves the WEIGHTED least-squares problem `min Σᵢ wᵢ (yᵢ − xᵢ·w)²`:
111    ///
112    /// - `fit_intercept=true`: offsets are the WEIGHTED means
113    ///   `x_off[j] = Σᵢ wᵢ·x[i,j] / Σwᵢ`, `y_off = Σᵢ wᵢ·yᵢ / Σwᵢ`
114    ///   (sklearn `_preprocess_data` → `_average(..., weights=sample_weight)`,
115    ///   `_base.py:193`/`:198`). `X` and `y` are centered by those offsets, each
116    ///   row is then rescaled by `√wᵢ` (sklearn `_rescale_data`, `_base.py:641`),
117    ///   `linalg.lstsq` solves for `coef`, and
118    ///   `intercept = y_off − x_off · coef` (`_set_intercept`, `_base.py:320`).
119    /// - `fit_intercept=false`: no centering; each row is rescaled by `√wᵢ`, the
120    ///   solve runs on the rescaled `X`, and `intercept = 0`.
121    ///
122    /// `sample_weight=None` is BYTE-IDENTICAL to [`Fit::fit`] (the unweighted
123    /// centering + `solve_lstsq` path), which delegates here.
124    ///
125    /// `rank_`/`singular_` are captured from `solve_lstsq` on the matrix actually
126    /// solved (centered-and-rescaled `X` when `fit_intercept`, rescaled `X`
127    /// otherwise), matching sklearn's `linalg.lstsq` operands (`_base.py:687`).
128    ///
129    /// # Errors
130    ///
131    /// Returns [`FerroError::ShapeMismatch`] if the number of samples in `x` and
132    /// `y` (or, when provided, `sample_weight`) differ.
133    /// Returns [`FerroError::InsufficientSamples`] if there are no samples.
134    /// Returns [`FerroError::NumericalInstability`] if the system is singular or
135    /// the weighted-offset denominator (`Σwᵢ`) cannot be formed.
136    /// Solve the coefficient system on the (already centered / `√w`-rescaled)
137    /// design `a` and target `b`, returning `(coef, rank_, singular_)`.
138    ///
139    /// `rank_`/`singular_` always come from the unconstrained `linalg.lstsq`
140    /// SVD of the design `a` (matching sklearn's `linalg.lstsq(X, y)` operands,
141    /// `_base.py:687`). When `self.positive`, the COEFFICIENTS are overridden by
142    /// the non-negative least-squares solution (`scipy.optimize.nnls`,
143    /// `_base.py:647`) on the same design; otherwise the lstsq coefficients are
144    /// returned unchanged, keeping the `positive=false` path byte-identical to
145    /// the unconstrained OLS solve.
146    fn solve_coef(
147        &self,
148        a: &Array2<F>,
149        b: &Array1<F>,
150    ) -> Result<(Array1<F>, usize, Array1<F>), FerroError> {
151        let (coef, rank, singular) = linalg::solve_lstsq(a, b)?;
152        if self.positive {
153            let coef_pos = linalg::nnls(a, b)?;
154            Ok((coef_pos, rank, singular))
155        } else {
156            Ok((coef, rank, singular))
157        }
158    }
159
160    pub fn fit_with_sample_weight(
161        &self,
162        x: &Array2<F>,
163        y: &Array1<F>,
164        sample_weight: Option<&Array1<F>>,
165    ) -> Result<FittedLinearRegression<F>, FerroError> {
166        let (n_samples, n_features) = x.dim();
167
168        // Validate input shapes.
169        if n_samples != y.len() {
170            return Err(FerroError::ShapeMismatch {
171                expected: vec![n_samples],
172                actual: vec![y.len()],
173                context: "y length must match number of samples in X".into(),
174            });
175        }
176
177        if n_samples == 0 {
178            return Err(FerroError::InsufficientSamples {
179                required: 1,
180                actual: 0,
181                context: "LinearRegression requires at least one sample".into(),
182            });
183        }
184
185        if let Some(w) = sample_weight
186            && w.len() != n_samples
187        {
188            return Err(FerroError::ShapeMismatch {
189                expected: vec![n_samples],
190                actual: vec![w.len()],
191                context: "sample_weight length must match number of samples in X".into(),
192            });
193        }
194
195        // sklearn `LinearRegression.fit` -> `self._validate_data(X, y, ...)`
196        // (`_base.py:609`); the call keeps the default `force_all_finite=True`,
197        // so `check_array` rejects any NaN or +/-inf in X OR y with a
198        // `ValueError` BEFORE the solve. `.iter().any(|v| !v.is_finite())`
199        // rejects both NaN and Inf (bounds-safe, no panic, R-CODE-2), matching
200        // the crate idiom (`multi_task_lasso.rs`). (#2256)
201        if x.iter().any(|v| !v.is_finite()) {
202            return Err(FerroError::InvalidParameter {
203                name: "X".into(),
204                reason: "Input X contains NaN or infinity.".into(),
205            });
206        }
207        if y.iter().any(|v| !v.is_finite()) {
208            return Err(FerroError::InvalidParameter {
209                name: "y".into(),
210                reason: "Input y contains NaN or infinity.".into(),
211            });
212        }
213
214        // sklearn validates `sample_weight` via `_check_sample_weight` ->
215        // `check_array(..., input_name="sample_weight")`
216        // (`sklearn/utils/validation.py:2043-2050`), keeping the default
217        // `force_all_finite=True`, so any NaN or +/-inf weight raises a
218        // `ValueError` BEFORE the weighted centering / √w rescaling. Mirror it
219        // with the same NaN+Inf-rejecting idiom as X/y above (#2258).
220        if let Some(w) = sample_weight
221            && w.iter().any(|v| !v.is_finite())
222        {
223            return Err(FerroError::InvalidParameter {
224                name: "sample_weight".into(),
225                reason: "Input sample_weight contains NaN or infinity.".into(),
226            });
227        }
228
229        match sample_weight {
230            None => {
231                // Unweighted path — identical to the original `Fit::fit` body.
232                if self.fit_intercept {
233                    // Centering trick: center X and y, solve the (uncentered)
234                    // OLS problem on the centered design, then recover the
235                    // intercept as y_mean - x_mean . w. sklearn centers
236                    // identically before its `linalg.lstsq` call (`_base.py`
237                    // `_preprocess_data` + `:687`).
238                    let n = <F as num_traits::NumCast>::from(n_samples).ok_or_else(|| {
239                        FerroError::NumericalInstability {
240                            message: "could not represent n_samples as the float type".into(),
241                        }
242                    })?;
243                    let x_mean =
244                        x.mean_axis(Axis(0))
245                            .ok_or_else(|| FerroError::InsufficientSamples {
246                                required: 1,
247                                actual: 0,
248                                context: "cannot compute feature means of an empty design".into(),
249                            })?;
250                    let y_mean = y.sum() / n;
251
252                    let x_centered = x - &x_mean;
253                    let y_centered = y - y_mean;
254
255                    let (w, rank, singular) = self.solve_coef(&x_centered, &y_centered)?;
256
257                    let intercept = y_mean - x_mean.dot(&w);
258
259                    Ok(FittedLinearRegression {
260                        coefficients: w,
261                        intercept,
262                        rank_: rank,
263                        singular_: singular,
264                    })
265                } else {
266                    let (w, rank, singular) = self.solve_coef(x, y)?;
267
268                    Ok(FittedLinearRegression {
269                        coefficients: w,
270                        intercept: <F as num_traits::Zero>::zero(),
271                        rank_: rank,
272                        singular_: singular,
273                    })
274                }
275            }
276            Some(w) => {
277                // Per-row √w factor (sklearn `_rescale_data`, `_base.py:641`).
278                let w_sqrt = w.mapv(<F as Float>::sqrt);
279
280                if self.fit_intercept {
281                    // WEIGHTED centering: offsets are the weighted means
282                    // x_off[j] = Σ wᵢ x[i,j] / Σ wᵢ, y_off = Σ wᵢ yᵢ / Σ wᵢ
283                    // (sklearn `_average(..., weights=sample_weight)`,
284                    // `_base.py:193`/`:198`).
285                    let w_sum = w.sum();
286                    if w_sum <= <F as num_traits::Zero>::zero() {
287                        return Err(FerroError::NumericalInstability {
288                            message: "sum of sample_weight must be positive to center".into(),
289                        });
290                    }
291
292                    let mut x_off = Array1::<F>::zeros(n_features);
293                    for (i, row) in x.outer_iter().enumerate() {
294                        let wi = w[i];
295                        x_off = &x_off + &row.mapv(|v| v * wi);
296                    }
297                    x_off.mapv_inplace(|v| v / w_sum);
298
299                    let y_off = y
300                        .iter()
301                        .zip(w.iter())
302                        .fold(<F as num_traits::Zero>::zero(), |acc, (&yi, &wi)| {
303                            acc + wi * yi
304                        })
305                        / w_sum;
306
307                    // Center, then row-rescale by √w.
308                    let x_centered = x - &x_off;
309                    let y_centered = y - y_off;
310                    let x_scaled = &x_centered * &w_sqrt.view().insert_axis(Axis(1));
311                    let y_scaled = &y_centered * &w_sqrt;
312
313                    let (coef, rank, singular) = self.solve_coef(&x_scaled, &y_scaled)?;
314
315                    let intercept = y_off - x_off.dot(&coef);
316
317                    Ok(FittedLinearRegression {
318                        coefficients: coef,
319                        intercept,
320                        rank_: rank,
321                        singular_: singular,
322                    })
323                } else {
324                    // No centering; just √w row-rescaling, intercept 0.
325                    let x_scaled = x * &w_sqrt.view().insert_axis(Axis(1));
326                    let y_scaled = y * &w_sqrt;
327
328                    let (coef, rank, singular) = self.solve_coef(&x_scaled, &y_scaled)?;
329
330                    Ok(FittedLinearRegression {
331                        coefficients: coef,
332                        intercept: <F as num_traits::Zero>::zero(),
333                        rank_: rank,
334                        singular_: singular,
335                    })
336                }
337            }
338        }
339    }
340}
341
342impl<F: Float> LinearRegression<F> {
343    /// Create a new `LinearRegression` with default settings.
344    ///
345    /// Defaults: `fit_intercept = true`, `copy_x = true`, `n_jobs = None`,
346    /// `positive = false` (mirroring sklearn's ctor defaults,
347    /// `_base.py:571-574`).
348    #[must_use]
349    pub fn new() -> Self {
350        Self {
351            fit_intercept: true,
352            copy_x: true,
353            n_jobs: None,
354            positive: false,
355            _marker: std::marker::PhantomData,
356        }
357    }
358
359    /// Set whether to fit an intercept term.
360    #[must_use]
361    pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
362        self.fit_intercept = fit_intercept;
363        self
364    }
365
366    /// Set the `copy_X` flag (sklearn `copy_X`, `_base.py:480`).
367    ///
368    /// ferrolearn's fit never mutates `x`, so this is exposed for ABI parity
369    /// with sklearn and does not change the result.
370    #[must_use]
371    pub fn with_copy_x(mut self, copy_x: bool) -> Self {
372        self.copy_x = copy_x;
373        self
374    }
375
376    /// Set the `n_jobs` parameter (sklearn `n_jobs`, `_base.py:483`).
377    ///
378    /// The dense OLS solve is single-threaded; this is stored but ignored.
379    #[must_use]
380    pub fn with_n_jobs(mut self, n_jobs: Option<usize>) -> Self {
381        self.n_jobs = n_jobs;
382        self
383    }
384
385    /// Set the `positive` flag (sklearn `positive`, `_base.py:574`).
386    ///
387    /// When `true`, the fitted coefficients are constrained to be non-negative,
388    /// solved via non-negative least squares (`scipy.optimize.nnls`,
389    /// `_base.py:647`) instead of unconstrained OLS. Default `false`.
390    #[must_use]
391    pub fn with_positive(mut self, positive: bool) -> Self {
392        self.positive = positive;
393        self
394    }
395}
396
397impl<F: Float> Default for LinearRegression<F> {
398    fn default() -> Self {
399        Self::new()
400    }
401}
402
403/// Fitted ordinary least squares linear regression model.
404///
405/// Stores the learned coefficients and intercept. Implements [`Predict`]
406/// to generate predictions and [`HasCoefficients`] for introspection.
407#[derive(Debug, Clone)]
408pub struct FittedLinearRegression<F> {
409    /// Learned coefficient vector (one per feature).
410    coefficients: Array1<F>,
411    /// Learned intercept (bias) term.
412    intercept: F,
413    /// Effective rank of the design matrix actually solved (sklearn `rank_`,
414    /// `_base.py:505`/`:687`) — the centered `X` when `fit_intercept`, the
415    /// raw `X` otherwise.
416    rank_: usize,
417    /// Singular values of the design matrix actually solved (sklearn
418    /// `singular_`, `_base.py:508`/`:687`).
419    singular_: Array1<F>,
420}
421
422impl<
423    F: Float
424        + Send
425        + Sync
426        + ScalarOperand
427        + num_traits::FromPrimitive
428        + ferray::linalg::LinalgFloat
429        + 'static,
430> Fit<Array2<F>, Array1<F>> for LinearRegression<F>
431{
432    type Fitted = FittedLinearRegression<F>;
433    type Error = FerroError;
434
435    /// Fit the linear regression model.
436    ///
437    /// Solves the OLS least-squares problem via the SVD-based
438    /// minimum-norm solver [`crate::linalg::solve_lstsq`] (routed through
439    /// [`ferray::linalg::lstsq`], LAPACK-`gelsd`-equivalent), matching
440    /// scikit-learn's dense path `linalg.lstsq(X, y)`
441    /// (`sklearn/linear_model/_base.py:687`). When `fit_intercept` is true,
442    /// `X` and `y` are centered first and the intercept is recovered as
443    /// `y_mean - x_mean . w`.
444    ///
445    /// # Errors
446    ///
447    /// Returns [`FerroError::ShapeMismatch`] if the number of samples in `x`
448    /// and `y` differ.
449    /// Returns [`FerroError::InsufficientSamples`] if there are fewer samples
450    /// than features.
451    /// Returns [`FerroError::NumericalInstability`] if the system is singular.
452    fn fit(&self, x: &Array2<F>, y: &Array1<F>) -> Result<FittedLinearRegression<F>, FerroError> {
453        // Unweighted OLS is the `sample_weight=None` arm of the weighted fit;
454        // delegating keeps the None path byte-identical to the historic body
455        // (centering + `solve_lstsq`), mirroring sklearn's single `fit` entry
456        // (`_base.py:582`, `sample_weight=None` default).
457        self.fit_with_sample_weight(x, y, None)
458    }
459}
460
461/// Fitted multi-output ordinary least squares linear regression model.
462///
463/// The 2-D-target companion to [`FittedLinearRegression`]: produced by
464/// `Fit<Array2<F>, Array2<F>>` when fitting a 2-D `Y` of shape
465/// `(n_samples, n_targets)`. Mirrors scikit-learn's multi-output
466/// `LinearRegression` (`MultiOutputMixin`, `_base.py:465`), whose `coef_` is a
467/// 2-D array of shape `(n_targets, n_features)` and `intercept_` an array of
468/// shape `(n_targets,)` (`_base.py:499`/`:511`). Stored in sklearn's `coef_`
469/// orientation (target rows), so `coefficients()` maps directly onto
470/// `sklearn.coef_`.
471#[derive(Debug, Clone)]
472pub struct FittedMultiOutputLinearRegression<F> {
473    /// Learned coefficient matrix in sklearn `coef_` orientation: shape
474    /// `(n_targets, n_features)`, row `t` the coefficients for target `t`
475    /// (`_base.py:499`).
476    coefficients: Array2<F>,
477    /// Learned per-target intercepts, shape `(n_targets,)` (sklearn
478    /// `intercept_`, `_base.py:511`).
479    intercepts: Array1<F>,
480    /// Effective rank of the design matrix actually solved (sklearn `rank_`,
481    /// `_base.py:505`/`:687`) — the centered `X` when `fit_intercept`, the raw
482    /// `X` otherwise.
483    rank_: usize,
484    /// Singular values of the design matrix actually solved (sklearn
485    /// `singular_`, `_base.py:508`/`:687`).
486    singular_: Array1<F>,
487}
488
489impl<F: Float> FittedMultiOutputLinearRegression<F> {
490    /// Learned coefficient matrix, shape `(n_targets, n_features)` (sklearn
491    /// `coef_`, `_base.py:499`).
492    #[must_use]
493    pub fn coefficients(&self) -> &Array2<F> {
494        &self.coefficients
495    }
496
497    /// Learned per-target intercepts, shape `(n_targets,)` (sklearn
498    /// `intercept_`, `_base.py:511`).
499    #[must_use]
500    pub fn intercepts(&self) -> &Array1<F> {
501        &self.intercepts
502    }
503
504    /// Effective rank of the design matrix (sklearn `rank_`, `_base.py:505`).
505    #[must_use]
506    pub fn rank(&self) -> usize {
507        self.rank_
508    }
509
510    /// Singular values of the design matrix (sklearn `singular_`,
511    /// `_base.py:508`).
512    #[must_use]
513    pub fn singular_values(&self) -> &Array1<F> {
514        &self.singular_
515    }
516}
517
518impl<
519    F: Float
520        + Send
521        + Sync
522        + ScalarOperand
523        + num_traits::FromPrimitive
524        + ferray::linalg::LinalgFloat
525        + 'static,
526> Fit<Array2<F>, Array2<F>> for LinearRegression<F>
527{
528    type Fitted = FittedMultiOutputLinearRegression<F>;
529    type Error = FerroError;
530
531    /// Fit the multi-output linear regression model on a 2-D target `Y`.
532    ///
533    /// Mirrors scikit-learn's multi-output dense path: `linalg.lstsq(X, Y)`
534    /// with `Y` of shape `(n_samples, n_targets)` solves all targets in one
535    /// SVD, yielding `coef_` of shape `(n_targets, n_features)` and a per-target
536    /// `intercept_` of shape `(n_targets,)` (`sklearn/linear_model/_base.py:687`,
537    /// `coef_.T`; intercept `_set_intercept`, `_base.py:308`/`:322`). When
538    /// `fit_intercept` is true, `X` and each column of `Y` are centered by their
539    /// column means and the intercept is recovered as
540    /// `y_off − coefficients · x_off` per target; when false, the solve runs on
541    /// raw `X`/`Y` and the intercepts are all `0`.
542    ///
543    /// The 1-D `Fit<Array2<F>, Array1<F>>` impl is unaffected — this is an
544    /// additive 2-D arm.
545    ///
546    /// # Errors
547    ///
548    /// Returns [`FerroError::ShapeMismatch`] if the number of samples in `x`
549    /// and `y` differ.
550    /// Returns [`FerroError::InsufficientSamples`] if there are no samples.
551    /// Returns [`FerroError::NumericalInstability`] if the system is singular.
552    fn fit(
553        &self,
554        x: &Array2<F>,
555        y: &Array2<F>,
556    ) -> Result<FittedMultiOutputLinearRegression<F>, FerroError> {
557        let n_samples = x.nrows();
558        let n_targets = y.ncols();
559
560        if n_samples != y.nrows() {
561            return Err(FerroError::ShapeMismatch {
562                expected: vec![n_samples],
563                actual: vec![y.nrows()],
564                context: "Y rows must match number of samples in X".into(),
565            });
566        }
567
568        if n_samples == 0 {
569            return Err(FerroError::InsufficientSamples {
570                required: 1,
571                actual: 0,
572                context: "LinearRegression requires at least one sample".into(),
573            });
574        }
575
576        // sklearn `LinearRegression.fit` -> `self._validate_data(X, y, ...,
577        // multi_output=True, ...)` (`_base.py:609`) keeps the default
578        // `force_all_finite=True`, so `check_array` rejects any NaN or +/-inf in
579        // X OR the 2-D Y with a `ValueError` BEFORE the solve — regardless of
580        // output dimensionality. This separate multi-output arm does NOT route
581        // through `fit_with_sample_weight`, so it needs the SAME finite-check.
582        // `.iter().any(|v| !v.is_finite())` (Array2's element iterator) rejects
583        // both NaN and Inf (bounds-safe, no panic, R-CODE-2). (#2257)
584        if x.iter().any(|v| !v.is_finite()) {
585            return Err(FerroError::InvalidParameter {
586                name: "X".into(),
587                reason: "Input X contains NaN or infinity.".into(),
588            });
589        }
590        if y.iter().any(|v| !v.is_finite()) {
591            return Err(FerroError::InvalidParameter {
592                name: "y".into(),
593                reason: "Input y contains NaN or infinity.".into(),
594            });
595        }
596
597        if self.fit_intercept {
598            // Same column-centering as the 1-D fit, generalized to Y's columns
599            // (sklearn `_preprocess_data` centers X and every column of Y by
600            // their per-column means, `_base.py:193`/`:198`).
601            let x_off = x
602                .mean_axis(Axis(0))
603                .ok_or_else(|| FerroError::InsufficientSamples {
604                    required: 1,
605                    actual: 0,
606                    context: "cannot compute feature means of an empty design".into(),
607                })?;
608            let y_off = y
609                .mean_axis(Axis(0))
610                .ok_or_else(|| FerroError::InsufficientSamples {
611                    required: 1,
612                    actual: 0,
613                    context: "cannot compute target means of an empty Y".into(),
614                })?;
615
616            let x_centered = x - &x_off;
617            let y_centered = y - &y_off;
618
619            // coef_ft is (n_features, n_targets); store in sklearn `coef_`
620            // orientation (n_targets, n_features) via transpose.
621            let (coef_ft, rank, singular) = linalg::solve_lstsq_multi(&x_centered, &y_centered)?;
622            let coefficients = coef_ft.t().to_owned();
623
624            // intercept_[t] = y_off[t] − coefficients[t] · x_off
625            // (sklearn `_set_intercept`: `y_offset − X_offset @ coef_.T`,
626            // `_base.py:322`).
627            let intercepts = &y_off - &coefficients.dot(&x_off);
628
629            Ok(FittedMultiOutputLinearRegression {
630                coefficients,
631                intercepts,
632                rank_: rank,
633                singular_: singular,
634            })
635        } else {
636            let (coef_ft, rank, singular) = linalg::solve_lstsq_multi(x, y)?;
637            let coefficients = coef_ft.t().to_owned();
638            let intercepts = Array1::<F>::zeros(n_targets);
639
640            Ok(FittedMultiOutputLinearRegression {
641                coefficients,
642                intercepts,
643                rank_: rank,
644                singular_: singular,
645            })
646        }
647    }
648}
649
650impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>>
651    for FittedMultiOutputLinearRegression<F>
652{
653    type Output = Array2<F>;
654    type Error = FerroError;
655
656    /// Predict 2-D target values for the given feature matrix.
657    ///
658    /// Computes `X @ coefficients.T + intercepts` (broadcasting the per-target
659    /// intercepts over rows), shape `(n_samples, n_targets)`, mirroring sklearn's
660    /// 2-D `_decision_function` arm `X @ coef_.T + self.intercept_`
661    /// (`_base.py:290`).
662    ///
663    /// # Errors
664    ///
665    /// Returns [`FerroError::ShapeMismatch`] if the number of features does not
666    /// match the fitted model.
667    fn predict(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
668        let n_features = x.ncols();
669        if n_features != self.coefficients.ncols() {
670            return Err(FerroError::ShapeMismatch {
671                expected: vec![self.coefficients.ncols()],
672                actual: vec![n_features],
673                context: "number of features must match fitted model".into(),
674            });
675        }
676
677        // X (n_samples, n_features) @ coef_.T (n_features, n_targets) -> (n_samples, n_targets)
678        let preds = x.dot(&self.coefficients.t());
679        Ok(preds + &self.intercepts)
680    }
681}
682
683impl<F: Float> FittedLinearRegression<F> {
684    /// Effective rank of the design matrix (sklearn `rank_`, `_base.py:505`).
685    ///
686    /// The rank of the matrix actually solved by `linalg.lstsq` — the
687    /// centered `X` when `fit_intercept` is true, the raw `X` otherwise
688    /// (`_base.py:687`).
689    #[must_use]
690    pub fn rank(&self) -> usize {
691        self.rank_
692    }
693
694    /// Singular values of the design matrix (sklearn `singular_`,
695    /// `_base.py:508`).
696    ///
697    /// The singular values of the matrix actually solved by `linalg.lstsq`
698    /// — the centered `X` when `fit_intercept` is true, the raw `X`
699    /// otherwise (`_base.py:687`).
700    #[must_use]
701    pub fn singular_values(&self) -> &Array1<F> {
702        &self.singular_
703    }
704}
705
706impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>>
707    for FittedLinearRegression<F>
708{
709    type Output = Array1<F>;
710    type Error = FerroError;
711
712    /// Predict target values for the given feature matrix.
713    ///
714    /// Computes `X @ coefficients + intercept`.
715    ///
716    /// # Errors
717    ///
718    /// Returns [`FerroError::ShapeMismatch`] if the number of features
719    /// does not match the fitted model.
720    fn predict(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
721        let n_features = x.ncols();
722        if n_features != self.coefficients.len() {
723            return Err(FerroError::ShapeMismatch {
724                expected: vec![self.coefficients.len()],
725                actual: vec![n_features],
726                context: "number of features must match fitted model".into(),
727            });
728        }
729
730        let preds = x.dot(&self.coefficients) + self.intercept;
731        Ok(preds)
732    }
733}
734
735impl<F: Float + Send + Sync + ScalarOperand + 'static> HasCoefficients<F>
736    for FittedLinearRegression<F>
737{
738    fn coefficients(&self) -> &Array1<F> {
739        &self.coefficients
740    }
741
742    fn intercept(&self) -> F {
743        self.intercept
744    }
745}
746
747// Pipeline integration.
748impl<F> PipelineEstimator<F> for LinearRegression<F>
749where
750    F: Float + FromPrimitive + ScalarOperand + ferray::linalg::LinalgFloat + Send + Sync + 'static,
751{
752    fn fit_pipeline(
753        &self,
754        x: &Array2<F>,
755        y: &Array1<F>,
756    ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
757        let fitted = self.fit(x, y)?;
758        Ok(Box::new(fitted))
759    }
760}
761
762impl<F> FittedPipelineEstimator<F> for FittedLinearRegression<F>
763where
764    F: Float + ScalarOperand + Send + Sync + 'static,
765{
766    fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
767        self.predict(x)
768    }
769}
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774    use approx::assert_relative_eq;
775    use ndarray::array;
776
777    #[test]
778    fn test_simple_linear_regression() {
779        // y = 2*x + 1
780        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
781        let y = array![3.0, 5.0, 7.0, 9.0, 11.0];
782
783        let model = LinearRegression::<f64>::new();
784        let fitted = model.fit(&x, &y).unwrap();
785
786        assert_relative_eq!(fitted.coefficients()[0], 2.0, epsilon = 1e-10);
787        assert_relative_eq!(fitted.intercept(), 1.0, epsilon = 1e-10);
788
789        let preds = fitted.predict(&x).unwrap();
790        for (p, &actual) in preds.iter().zip(y.iter()) {
791            assert_relative_eq!(*p, actual, epsilon = 1e-10);
792        }
793    }
794
795    #[test]
796    fn test_multiple_linear_regression() {
797        // y = 1*x1 + 2*x2 + 3
798        let x =
799            Array2::from_shape_vec((4, 2), vec![1.0, 1.0, 2.0, 1.0, 3.0, 2.0, 4.0, 2.0]).unwrap();
800        let y = array![6.0, 7.0, 10.0, 11.0];
801
802        let model = LinearRegression::<f64>::new();
803        let fitted = model.fit(&x, &y).unwrap();
804
805        assert_relative_eq!(fitted.coefficients()[0], 1.0, epsilon = 1e-10);
806        assert_relative_eq!(fitted.coefficients()[1], 2.0, epsilon = 1e-10);
807        assert_relative_eq!(fitted.intercept(), 3.0, epsilon = 1e-10);
808    }
809
810    #[test]
811    fn test_no_intercept() {
812        // y = 2*x (through origin)
813        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
814        let y = array![2.0, 4.0, 6.0, 8.0];
815
816        let model = LinearRegression::<f64>::new().with_fit_intercept(false);
817        let fitted = model.fit(&x, &y).unwrap();
818
819        assert_relative_eq!(fitted.coefficients()[0], 2.0, epsilon = 1e-10);
820        assert_relative_eq!(fitted.intercept(), 0.0, epsilon = 1e-10);
821    }
822
823    #[test]
824    fn test_shape_mismatch_fit() {
825        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
826        let y = array![1.0, 2.0]; // Wrong length
827
828        let model = LinearRegression::<f64>::new();
829        let result = model.fit(&x, &y);
830        assert!(result.is_err());
831    }
832
833    #[test]
834    fn test_shape_mismatch_predict() {
835        let x = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
836        let y = array![1.0, 2.0, 3.0];
837
838        let model = LinearRegression::<f64>::new();
839        let fitted = model.fit(&x, &y).unwrap();
840
841        // Wrong number of features
842        let x_bad = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
843        let result = fitted.predict(&x_bad);
844        assert!(result.is_err());
845    }
846
847    #[test]
848    fn test_has_coefficients() {
849        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
850        let y = array![2.0, 4.0, 6.0];
851
852        let model = LinearRegression::<f64>::new();
853        let fitted = model.fit(&x, &y).unwrap();
854
855        assert_eq!(fitted.coefficients().len(), 1);
856    }
857
858    #[test]
859    fn test_pipeline_integration() {
860        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
861        let y = array![3.0, 5.0, 7.0, 9.0];
862
863        let model = LinearRegression::<f64>::new();
864        let fitted = model.fit_pipeline(&x, &y).unwrap();
865        let preds = fitted.predict_pipeline(&x).unwrap();
866        assert_eq!(preds.len(), 4);
867    }
868
869    #[test]
870    fn linreg_rank_singular_match_sklearn_with_intercept() {
871        // Live sklearn 1.5.2 oracle (fit_intercept=True centers X before
872        // linalg.lstsq, so singular_ are the singular values of CENTERED X):
873        //   cd /tmp && python3 -c "import numpy as np; \
874        //     from sklearn.linear_model import LinearRegression; \
875        //     X=np.array([[1.,1.],[1.,2.],[2.,2.],[2.,3.]]); \
876        //     y=np.array([6.,8.,9.,11.]); m=LinearRegression().fit(X,y); \
877        //     print(m.rank_, [round(s,8) for s in m.singular_])"
878        //   -> 2 [1.61803399, 0.61803399]
879        let x =
880            Array2::from_shape_vec((4, 2), vec![1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 2.0, 3.0]).unwrap();
881        let y = array![6.0, 8.0, 9.0, 11.0];
882
883        let model = LinearRegression::<f64>::new();
884        let fitted = model.fit(&x, &y).unwrap();
885
886        assert_eq!(fitted.rank(), 2);
887        let sv = fitted.singular_values();
888        assert_eq!(sv.len(), 2);
889        assert_relative_eq!(sv[0], 1.618_033_99, epsilon = 1e-6);
890        assert_relative_eq!(sv[1], 0.618_033_99, epsilon = 1e-6);
891    }
892
893    #[test]
894    fn linreg_singular_no_intercept_matches_raw_x() {
895        // Live sklearn 1.5.2 oracle (fit_intercept=False → singular_ are the
896        // singular values of the RAW X):
897        //   cd /tmp && python3 -c "import numpy as np; \
898        //     from sklearn.linear_model import LinearRegression; \
899        //     X=np.array([[1.,1.],[1.,2.],[2.,2.],[2.,3.]]); \
900        //     y=np.array([6.,8.,9.,11.]); \
901        //     m=LinearRegression(fit_intercept=False).fit(X,y); \
902        //     print(m.rank_, [round(s,8) for s in m.singular_])"
903        //   -> 2 [5.25371017, 0.63129192]
904        let x =
905            Array2::from_shape_vec((4, 2), vec![1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 2.0, 3.0]).unwrap();
906        let y = array![6.0, 8.0, 9.0, 11.0];
907
908        let model = LinearRegression::<f64>::new().with_fit_intercept(false);
909        let fitted = model.fit(&x, &y).unwrap();
910
911        assert_eq!(fitted.rank(), 2);
912        let sv = fitted.singular_values();
913        assert_eq!(sv.len(), 2);
914        assert_relative_eq!(sv[0], 5.253_710_17, epsilon = 1e-6);
915        assert_relative_eq!(sv[1], 0.631_291_92, epsilon = 1e-6);
916    }
917
918    #[test]
919    fn linreg_copy_x_default_and_builder() {
920        // copy_X default is true (sklearn `_base.py:572`); the builder flips
921        // it; n_jobs builder stores Some(4); and fit produces identical coef_
922        // regardless of copy_x (no behaviour change — fit never mutates X).
923        assert!(LinearRegression::<f64>::new().copy_x);
924        assert!(!LinearRegression::<f64>::new().with_copy_x(false).copy_x);
925        assert_eq!(
926            LinearRegression::<f64>::new().with_n_jobs(Some(4)).n_jobs,
927            Some(4)
928        );
929
930        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
931        let y = array![3.0, 5.0, 7.0, 9.0];
932
933        let fitted_copy = LinearRegression::<f64>::new()
934            .with_copy_x(true)
935            .fit(&x, &y)
936            .unwrap();
937        let fitted_nocopy = LinearRegression::<f64>::new()
938            .with_copy_x(false)
939            .fit(&x, &y)
940            .unwrap();
941
942        assert_relative_eq!(
943            fitted_copy.coefficients()[0],
944            fitted_nocopy.coefficients()[0],
945            epsilon = 1e-12
946        );
947        assert_relative_eq!(
948            fitted_copy.intercept(),
949            fitted_nocopy.intercept(),
950            epsilon = 1e-12
951        );
952    }
953
954    #[test]
955    fn linreg_fit_sample_weight_with_intercept_matches_sklearn() {
956        // Live sklearn 1.5.2 oracle (WEIGHTED OLS, fit_intercept=True):
957        //   cd /tmp && python3 -c "import numpy as np; \
958        //     from sklearn.linear_model import LinearRegression; \
959        //     X=np.array([[1.],[2.],[3.],[4.],[5.]]); \
960        //     y=np.array([2.1,3.9,6.2,7.7,10.3]); w=np.array([1.,5.,1.,1.,5.]); \
961        //     m=LinearRegression().fit(X,y,sample_weight=w); \
962        //     print(round(m.coef_[0],10), round(m.intercept_,10))"
963        //   -> 2.0935828877 -0.2326203209
964        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
965        let y = array![2.1, 3.9, 6.2, 7.7, 10.3];
966        let w = array![1.0, 5.0, 1.0, 1.0, 5.0];
967
968        let model = LinearRegression::<f64>::new();
969        let fitted = model.fit_with_sample_weight(&x, &y, Some(&w)).unwrap();
970
971        assert_relative_eq!(fitted.coefficients()[0], 2.093_582_887_7, epsilon = 1e-7);
972        assert_relative_eq!(fitted.intercept(), -0.232_620_320_9, epsilon = 1e-7);
973
974        // Non-tautological: the weighted result MUST differ from the unweighted
975        // fit (oracle unweighted coef_ 2.02, intercept_ -0.02).
976        let unweighted = model.fit(&x, &y).unwrap();
977        assert_relative_eq!(unweighted.coefficients()[0], 2.02, epsilon = 1e-7);
978        assert!((fitted.coefficients()[0] - unweighted.coefficients()[0]).abs() > 1e-3);
979        assert!((fitted.intercept() - unweighted.intercept()).abs() > 1e-3);
980    }
981
982    #[test]
983    fn linreg_fit_sample_weight_no_intercept_matches_sklearn() {
984        // Live sklearn 1.5.2 oracle (WEIGHTED OLS, fit_intercept=False):
985        //   cd /tmp && python3 -c "import numpy as np; \
986        //     from sklearn.linear_model import LinearRegression; \
987        //     X=np.array([[1.],[2.],[3.],[4.],[5.]]); \
988        //     y=np.array([2.1,3.9,6.2,7.7,10.3]); w=np.array([1.,5.,1.,1.,5.]); \
989        //     m=LinearRegression(fit_intercept=False).fit(X,y,sample_weight=w); \
990        //     print(round(m.coef_[0],10))"
991        //   -> 2.0350877193
992        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
993        let y = array![2.1, 3.9, 6.2, 7.7, 10.3];
994        let w = array![1.0, 5.0, 1.0, 1.0, 5.0];
995
996        let model = LinearRegression::<f64>::new().with_fit_intercept(false);
997        let fitted = model.fit_with_sample_weight(&x, &y, Some(&w)).unwrap();
998
999        assert_relative_eq!(fitted.coefficients()[0], 2.035_087_719_3, epsilon = 1e-7);
1000        assert_eq!(fitted.intercept(), 0.0);
1001    }
1002
1003    #[test]
1004    fn linreg_fit_none_sample_weight_equals_unweighted() {
1005        // Regression guard: the `None` path is BYTE-IDENTICAL to `fit`.
1006        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
1007        let y = array![2.1, 3.9, 6.2, 7.7, 10.3];
1008
1009        let model = LinearRegression::<f64>::new();
1010        let via_fit = model.fit(&x, &y).unwrap();
1011        let via_none = model.fit_with_sample_weight(&x, &y, None).unwrap();
1012
1013        assert_eq!(
1014            via_fit.coefficients()[0].to_bits(),
1015            via_none.coefficients()[0].to_bits()
1016        );
1017        assert_eq!(
1018            via_fit.intercept().to_bits(),
1019            via_none.intercept().to_bits()
1020        );
1021
1022        // Same for fit_intercept=false.
1023        let model_ni = LinearRegression::<f64>::new().with_fit_intercept(false);
1024        let via_fit_ni = model_ni.fit(&x, &y).unwrap();
1025        let via_none_ni = model_ni.fit_with_sample_weight(&x, &y, None).unwrap();
1026        assert_eq!(
1027            via_fit_ni.coefficients()[0].to_bits(),
1028            via_none_ni.coefficients()[0].to_bits()
1029        );
1030        assert_eq!(
1031            via_fit_ni.intercept().to_bits(),
1032            via_none_ni.intercept().to_bits()
1033        );
1034    }
1035
1036    #[test]
1037    fn linreg_multioutput_coef_intercept_match_sklearn() {
1038        // Live sklearn 1.5.2 oracle (multi-output, fit_intercept=True):
1039        //   cd /tmp && python3 -c "import numpy as np; \
1040        //     from sklearn.linear_model import LinearRegression; \
1041        //     X=np.array([[1.,0.],[2.,1.],[3.,1.],[4.,2.],[5.,3.]]); \
1042        //     Y=np.array([[2.1,1.0],[3.9,2.1],[6.2,2.9],[7.7,4.2],[10.3,5.1]]); \
1043        //     m=LinearRegression().fit(X,Y); print(m.coef_.shape); \
1044        //     print([[round(v,8) for v in r] for r in m.coef_]); \
1045        //     print([round(v,8) for v in m.intercept_])"
1046        //   -> (2, 2)
1047        //      [[2.06666667, -0.06666667], [0.86666667, 0.23333333]]
1048        //      [-0.06666667, 0.13333333]
1049        let x = Array2::from_shape_vec(
1050            (5, 2),
1051            vec![1.0, 0.0, 2.0, 1.0, 3.0, 1.0, 4.0, 2.0, 5.0, 3.0],
1052        )
1053        .unwrap();
1054        let y = Array2::from_shape_vec(
1055            (5, 2),
1056            vec![2.1, 1.0, 3.9, 2.1, 6.2, 2.9, 7.7, 4.2, 10.3, 5.1],
1057        )
1058        .unwrap();
1059
1060        let model = LinearRegression::<f64>::new();
1061        let fitted = Fit::<Array2<f64>, Array2<f64>>::fit(&model, &x, &y).unwrap();
1062
1063        assert_eq!(fitted.coefficients().dim(), (2, 2));
1064        let c = fitted.coefficients();
1065        assert_relative_eq!(c[[0, 0]], 2.066_666_67, epsilon = 1e-7);
1066        assert_relative_eq!(c[[0, 1]], -0.066_666_67, epsilon = 1e-7);
1067        assert_relative_eq!(c[[1, 0]], 0.866_666_67, epsilon = 1e-7);
1068        assert_relative_eq!(c[[1, 1]], 0.233_333_33, epsilon = 1e-7);
1069
1070        let b = fitted.intercepts();
1071        assert_eq!(b.len(), 2);
1072        assert_relative_eq!(b[0], -0.066_666_67, epsilon = 1e-7);
1073        assert_relative_eq!(b[1], 0.133_333_33, epsilon = 1e-7);
1074    }
1075
1076    #[test]
1077    fn linreg_multioutput_predict_shape_and_values() {
1078        // Oracle (same model as above): predict(X).shape == (5, 2);
1079        //   m.predict(X[:2]) -> [[2.0, 1.0], [4.0, 2.1]]
1080        let x = Array2::from_shape_vec(
1081            (5, 2),
1082            vec![1.0, 0.0, 2.0, 1.0, 3.0, 1.0, 4.0, 2.0, 5.0, 3.0],
1083        )
1084        .unwrap();
1085        let y = Array2::from_shape_vec(
1086            (5, 2),
1087            vec![2.1, 1.0, 3.9, 2.1, 6.2, 2.9, 7.7, 4.2, 10.3, 5.1],
1088        )
1089        .unwrap();
1090
1091        let model = LinearRegression::<f64>::new();
1092        let fitted = Fit::<Array2<f64>, Array2<f64>>::fit(&model, &x, &y).unwrap();
1093
1094        let preds = fitted.predict(&x).unwrap();
1095        assert_eq!(preds.dim(), (5, 2));
1096
1097        let x2 = x.slice(ndarray::s![0..2, ..]).to_owned();
1098        let preds2 = fitted.predict(&x2).unwrap();
1099        assert_eq!(preds2.dim(), (2, 2));
1100        assert_relative_eq!(preds2[[0, 0]], 2.0, epsilon = 1e-6);
1101        assert_relative_eq!(preds2[[0, 1]], 1.0, epsilon = 1e-6);
1102        assert_relative_eq!(preds2[[1, 0]], 4.0, epsilon = 1e-6);
1103        assert_relative_eq!(preds2[[1, 1]], 2.1, epsilon = 1e-6);
1104    }
1105
1106    #[test]
1107    fn linreg_multioutput_no_intercept() {
1108        // Live sklearn 1.5.2 oracle (multi-output, fit_intercept=False):
1109        //   cd /tmp && python3 -c "import numpy as np; \
1110        //     from sklearn.linear_model import LinearRegression; \
1111        //     X=np.array([[1.,0.],[2.,1.],[3.,1.],[4.,2.],[5.,3.]]); \
1112        //     Y=np.array([[2.1,1.0],[3.9,2.1],[6.2,2.9],[7.7,4.2],[10.3,5.1]]); \
1113        //     m=LinearRegression(fit_intercept=False).fit(X,Y); \
1114        //     print([[round(v,10) for v in r] for r in m.coef_]); print(m.intercept_)"
1115        //   -> [[2.0195121951, -0.0097560976], [0.9609756098, 0.1195121951]]
1116        //      0.0
1117        let x = Array2::from_shape_vec(
1118            (5, 2),
1119            vec![1.0, 0.0, 2.0, 1.0, 3.0, 1.0, 4.0, 2.0, 5.0, 3.0],
1120        )
1121        .unwrap();
1122        let y = Array2::from_shape_vec(
1123            (5, 2),
1124            vec![2.1, 1.0, 3.9, 2.1, 6.2, 2.9, 7.7, 4.2, 10.3, 5.1],
1125        )
1126        .unwrap();
1127
1128        let model = LinearRegression::<f64>::new().with_fit_intercept(false);
1129        let fitted = Fit::<Array2<f64>, Array2<f64>>::fit(&model, &x, &y).unwrap();
1130
1131        let c = fitted.coefficients();
1132        assert_eq!(c.dim(), (2, 2));
1133        assert_relative_eq!(c[[0, 0]], 2.019_512_195_1, epsilon = 1e-7);
1134        assert_relative_eq!(c[[0, 1]], -0.009_756_097_6, epsilon = 1e-7);
1135        assert_relative_eq!(c[[1, 0]], 0.960_975_609_8, epsilon = 1e-7);
1136        assert_relative_eq!(c[[1, 1]], 0.119_512_195_1, epsilon = 1e-7);
1137
1138        let b = fitted.intercepts();
1139        assert_eq!(b.len(), 2);
1140        assert_eq!(b[0], 0.0);
1141        assert_eq!(b[1], 0.0);
1142    }
1143
1144    #[test]
1145    fn linreg_single_output_unchanged() {
1146        // Regression guard: the additive 2-D arm must not disturb the 1-D path.
1147        // y = 2*x + 1 (same fixture as `test_simple_linear_regression`).
1148        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
1149        let y1 = array![3.0, 5.0, 7.0, 9.0, 11.0];
1150
1151        let model = LinearRegression::<f64>::new();
1152        let fitted = model.fit(&x, &y1).unwrap();
1153
1154        assert_relative_eq!(fitted.coefficients()[0], 2.0, epsilon = 1e-10);
1155        assert_relative_eq!(fitted.intercept(), 1.0, epsilon = 1e-10);
1156    }
1157
1158    #[test]
1159    fn linreg_positive_matches_sklearn() {
1160        // Live sklearn 1.5.2 oracle (positive=True, fit_intercept=True →
1161        // centers, runs scipy.optimize.nnls on the centered design, recovers
1162        // intercept = y_off − X_off·coef):
1163        //   cd /tmp && python3 -c "import numpy as np; \
1164        //     from sklearn.linear_model import LinearRegression; \
1165        //     X=np.array([[1.,1.],[1.,2.],[2.,1.],[3.,2.],[2.,3.]]); \
1166        //     y=np.array([1.,0.5,3.,5.,1.5]); \
1167        //     m=LinearRegression(positive=True).fit(X,y); \
1168        //     print([round(c,8) for c in m.coef_], round(m.intercept_,8))"
1169        //   -> [2.03571429, 0.0] -1.46428571
1170        // The 2nd coef CLAMPS to 0; the unconstrained fit is [2.25, -0.75].
1171        let x = array![[1.0, 1.0], [1.0, 2.0], [2.0, 1.0], [3.0, 2.0], [2.0, 3.0]];
1172        let y = array![1.0, 0.5, 3.0, 5.0, 1.5];
1173
1174        let model = LinearRegression::<f64>::new().with_positive(true);
1175        let res = model.fit(&x, &y);
1176        let unc_res = LinearRegression::<f64>::new().fit(&x, &y);
1177        assert!(res.is_ok());
1178        assert!(unc_res.is_ok());
1179        if let (Ok(fitted), Ok(unconstrained)) = (res, unc_res) {
1180            assert_relative_eq!(fitted.coefficients()[0], 2.035_714_29, epsilon = 1e-6);
1181            assert_relative_eq!(fitted.coefficients()[1], 0.0, epsilon = 1e-6);
1182            assert_relative_eq!(fitted.intercept(), -1.464_285_71, epsilon = 1e-6);
1183
1184            // Non-negativity contract.
1185            assert!(fitted.coefficients().iter().all(|&c| c >= 0.0));
1186
1187            // Non-tautological: the constrained result MUST differ from the
1188            // unconstrained OLS fit (oracle coef_ [2.25, -0.75]).
1189            assert_relative_eq!(unconstrained.coefficients()[0], 2.25, epsilon = 1e-6);
1190            assert_relative_eq!(unconstrained.coefficients()[1], -0.75, epsilon = 1e-6);
1191            assert!((fitted.coefficients()[1] - unconstrained.coefficients()[1]).abs() > 0.5);
1192        }
1193    }
1194
1195    #[test]
1196    fn linreg_positive_false_unchanged() {
1197        // Regression guard: with_positive(false) (the default) is
1198        // BYTE-IDENTICAL to the historic unconstrained fit.
1199        let x = array![[1.0, 1.0], [1.0, 2.0], [2.0, 1.0], [3.0, 2.0], [2.0, 3.0]];
1200        let y = array![1.0, 0.5, 3.0, 5.0, 1.5];
1201
1202        let d_res = LinearRegression::<f64>::new().fit(&x, &y);
1203        let e_res = LinearRegression::<f64>::new()
1204            .with_positive(false)
1205            .fit(&x, &y);
1206        assert!(d_res.is_ok());
1207        assert!(e_res.is_ok());
1208        if let (Ok(default), Ok(explicit)) = (d_res, e_res) {
1209            assert_eq!(
1210                default.coefficients()[0].to_bits(),
1211                explicit.coefficients()[0].to_bits()
1212            );
1213            assert_eq!(
1214                default.coefficients()[1].to_bits(),
1215                explicit.coefficients()[1].to_bits()
1216            );
1217            assert_eq!(
1218                default.intercept().to_bits(),
1219                explicit.intercept().to_bits()
1220            );
1221
1222            // And matches the unconstrained oracle (coef_ [2.25, -0.75]).
1223            assert_relative_eq!(default.coefficients()[0], 2.25, epsilon = 1e-6);
1224            assert_relative_eq!(default.coefficients()[1], -0.75, epsilon = 1e-6);
1225        }
1226    }
1227
1228    #[test]
1229    fn linreg_positive_no_intercept_matches_sklearn() {
1230        // Live sklearn 1.5.2 oracle (positive=True, fit_intercept=False →
1231        // raw nnls(X, y), intercept 0):
1232        //   cd /tmp && python3 -c "import numpy as np; \
1233        //     from sklearn.linear_model import LinearRegression; \
1234        //     X=np.array([[1.,1.],[1.,2.],[2.,1.],[3.,2.],[2.,3.]]); \
1235        //     y=np.array([1.,0.5,3.,5.,1.5]); \
1236        //     m=LinearRegression(positive=True,fit_intercept=False).fit(X,y); \
1237        //     print([round(c,8) for c in m.coef_], round(m.intercept_,8))"
1238        //   -> [1.34210526, 0.0] 0.0  (== raw nnls(X, y))
1239        let x = array![[1.0, 1.0], [1.0, 2.0], [2.0, 1.0], [3.0, 2.0], [2.0, 3.0]];
1240        let y = array![1.0, 0.5, 3.0, 5.0, 1.5];
1241
1242        let res = LinearRegression::<f64>::new()
1243            .with_positive(true)
1244            .with_fit_intercept(false)
1245            .fit(&x, &y);
1246        assert!(res.is_ok());
1247        if let Ok(fitted) = res {
1248            assert_relative_eq!(fitted.coefficients()[0], 1.342_105_26, epsilon = 1e-6);
1249            assert_relative_eq!(fitted.coefficients()[1], 0.0, epsilon = 1e-6);
1250            assert_eq!(fitted.intercept(), 0.0);
1251            assert!(fitted.coefficients().iter().all(|&c| c >= 0.0));
1252        }
1253    }
1254
1255    #[test]
1256    fn test_f32_support() {
1257        let x = Array2::from_shape_vec((4, 1), vec![1.0f32, 2.0, 3.0, 4.0]).unwrap();
1258        let y = Array1::from_vec(vec![2.0f32, 4.0, 6.0, 8.0]);
1259
1260        let model = LinearRegression::<f32>::new();
1261        let fitted = model.fit(&x, &y).unwrap();
1262        let preds = fitted.predict(&x).unwrap();
1263        assert_eq!(preds.len(), 4);
1264    }
1265}