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}