Skip to main content

ferrolearn_linear/
bayesian_ridge.rs

1//! Bayesian Ridge Regression.
2//!
3//! This module provides [`BayesianRidge`], which fits a Bayesian formulation of
4//! Ridge regression. Rather than using a fixed regularization strength, the
5//! model iteratively estimates two precision hyperparameters:
6//!
7//! - **`lambda`** — precision (inverse variance) of the weight prior.
8//! - **`alpha`** — noise precision (inverse of noise variance).
9//!
10//! Both are inferred from the data via evidence maximization (Type-II maximum
11//! likelihood / Empirical Bayes). This automatic relevance determination means
12//! the user does not need to tune the regularization parameter by hand.
13//!
14//! The objective is the Bayesian evidence (marginal likelihood) of the model:
15//!
16//! ```text
17//! p(y | X, alpha, lambda) ∝ N(y; 0, (1/alpha)*I + (1/lambda)*X X^T)
18//! ```
19//!
20//! After fitting, the model exposes the posterior mean (`coefficients`), the
21//! posterior covariance diagonal (`sigma`) and full matrix (`sigma_full`,
22//! sklearn `sigma_`), the noise precision (`alpha`), the weight precision
23//! (`lambda`), the EM iteration count (`n_iter`), and — when built with
24//! `with_compute_score(true)` — the per-iteration log-marginal-likelihood
25//! sequence (`scores`). `predict_with_std` returns the predictive mean and
26//! standard deviation (sklearn `predict(return_std=True)`).
27//!
28//! # Examples
29//!
30//! ```
31//! use ferrolearn_linear::BayesianRidge;
32//! use ferrolearn_core::{Fit, Predict};
33//! use ndarray::{array, Array1, Array2};
34//!
35//! let model = BayesianRidge::<f64>::new();
36//! let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
37//! let y = array![3.0, 5.0, 7.0, 9.0, 11.0];
38//!
39//! let fitted = model.fit(&x, &y).unwrap();
40//! let preds = fitted.predict(&x).unwrap();
41//! ```
42//!
43//! ## REQ status (per `.design/linear/bayesian_ridge.md`, mirrors `sklearn/linear_model/_bayes.py:26` @ 1.5.2)
44//!
45//! | REQ | Status | Evidence |
46//! |---|---|---|
47//! | REQ-1 (evidence-max fit w/ hyperpriors) | SHIPPED | `fn fit` for `BayesianRidge` runs the MacKay/Tipping loop (`_bayes.py:291-314`): exact `gamma = sum((alpha*eig)/(lambda+alpha*eig))` (`_bayes.py:305`), `lambda = (gamma+2*lambda_1)/(sum(coef^2)+2*lambda_2)` (`_bayes.py:306`), `alpha = (n-gamma+2*alpha_1)/(rmse+2*alpha_2)` (`_bayes.py:307`), converging on `sum|coef_old-coef|<tol` (`_bayes.py:310`). Consumer: `RsBayesianRidge` in `ferrolearn-python/src/extras.rs`. Verified by `divergence_bayesian_ridge_fit_coef_alpha_lambda` + 2 extra oracle cases vs live sklearn. |
48//! | REQ-2 (alpha_1/alpha_2/lambda_1/lambda_2 params) | SHIPPED | `struct BayesianRidge` fields `alpha_1, alpha_2, lambda_1, lambda_2` (default `1e-6`) with `with_alpha_1`/`with_alpha_2`/`with_lambda_1`/`with_lambda_2` setters, mirroring `_bayes.py:192-195` / `_parameter_constraints` (`_bayes.py:175-178`). Consumed in the M-step of `fn fit`. |
49//! | REQ-3 (alpha_init default = 1/Var(y)) | SHIPPED | `alpha_init: Option<F>` (default `None`), and `fn fit` sets `alpha = 1/(var(y)+eps)` when `None` (`_bayes.py:266-269`); `lambda_init: Option<F>` defaults to `1.0` (`_bayes.py:270-271`). |
50//! | REQ-4 (predict posterior mean) | SHIPPED | `fn predict` for `FittedBayesianRidge` computes `X·coef_ + intercept_` (`_bayes.py:365`). Consumer: `RsBayesianRidge` in `ferrolearn-python/src/extras.rs`. |
51//! | REQ-5 (fit_intercept / HasCoefficients) | SHIPPED | `fn fit` centers and recovers `intercept = y_offset - X_offset·coef_` (`_bayes.py:339`); `impl HasCoefficients` exposes `coef_`/`intercept_`. |
52//! | REQ-6 (compute_score / scores_) | SHIPPED | `with_compute_score` on `struct BayesianRidge` (default `false`, `_bayes.py:198`); when set, `fn fit_with_sample_weight` accumulates `fn log_marginal_likelihood` (the exact `_bayes.py:396-426` LML: Gamma-hyperprior terms + `0.5*(p·log λ + n·log α − α·rmse − λ·‖coef‖² + logdet_sigma − n·log 2π)`) per iteration plus once post-loop, stored as `scores` with getter `fn scores` (length `n_iter()+1`). Consumer: `RsBayesianRidge::scores_` getter in `ferrolearn-python/src/extras.rs` → `_extras.py::BayesianRidge.scores_`. Verified by `divergence_bayesian_ridge_scores_ac1`/`_30x5_final` (Rust) + `test_bayesian_ridge_scores_matches_sklearn` (pytest) vs live sklearn. |
53//! | REQ-7 (n_iter_) | SHIPPED | `FittedBayesianRidge.n_iter` set to `last_iter + 1` in `fn fit_with_sample_weight` (`_bayes.py:316` `self.n_iter_ = iter_ + 1`); getter `fn n_iter`. Consumer: `RsBayesianRidge::n_iter_` getter (`extras.rs`) → `_extras.py::BayesianRidge.n_iter_`. Verified by `divergence_bayesian_ridge_n_iter` (== 5, sklearn oracle) + `test_bayesian_ridge_n_iter_matches_sklearn` (pytest). |
54//! | REQ-8 (predict return_std / full sigma_) | SHIPPED | `FittedBayesianRidge.sigma_full` is the full `(n_features, n_features)` covariance `(1/α)·Vhᵀ·diag(1/(eig+λ/α))·Vh` (`_bayes.py:333-337`), getter `fn sigma_full`; `fn predict_with_std` returns `(mean, sqrt(diag(X·sigma_·Xᵀ)+1/α))` (`_bayes.py:367-371`). Consumer: `RsBayesianRidge::predict(return_std=True)` + `sigma_` getter (`extras.rs`) → `_extras.py::BayesianRidge.predict`/`sigma_`. Verified by `divergence_bayesian_ridge_return_std_ac1` (Rust) + `test_bayesian_ridge_return_std_matches_sklearn`/`_sigma_full_matches_sklearn` (pytest). |
55//! | REQ-9 (sample_weight) | SHIPPED | `fn fit_with_sample_weight(x, y, Option<&Array1<F>>)` rescales centered `(X, y)` by `sqrt(sample_weight)` via `fn rescale_data` (sklearn `_rescale_data`, `_bayes.py:254-256`) with weighted offsets via `fn weighted_means`; `Fit::fit` delegates `None` (byte-identical). Consumer: `RsBayesianRidge::fit(x, y, sample_weight=None)` (`extras.rs`) → `_extras.py::BayesianRidge.fit`. Verified by `divergence_bayesian_ridge_sample_weight` (Rust) + `test_bayesian_ridge_sample_weight_matches_sklearn` (pytest) vs live sklearn. |
56//! | REQ-10 (ferray substrate) | SHIPPED (SVD) | the SVD runs on `ferray::linalg::svd` (`ferray-linalg/src/decomp/svd.rs:40`), bridged ndarray↔ferray at the `fn fit` boundary (R-SUBSTRATE-4), mirroring sklearn `scipy.linalg.svd` (`_bayes.py:287`). Remaining `ndarray` array-type migration tracked by #471. |
57//! | REQ-11 (non-finite input rejected) | SHIPPED | `fn fit_with_sample_weight` (the shared entry `Fit::fit` delegates to) rejects any NaN/+/-inf in X, y, or `sample_weight` BEFORE centering/SVD with `FerroError::InvalidParameter`, mirroring sklearn's `_validate_data(force_all_finite=True)` (`_bayes.py:238-239`) + `_check_sample_weight` (default `force_all_finite=True`, `_bayes.py:244`) → `ValueError("Input X contains NaN.")` / `"... contains infinity ..."`. `.iter().any(|v| !v.is_finite())` catches both NaN and Inf; the finite path is byte-identical. Verified vs the live sklearn 1.5.2 oracle (R-CHAR-3): `BayesianRidge().fit` raises `ValueError` for NaN/+inf/-inf in X, NaN/inf in y, and NaN/inf in sample_weight (`tests/divergence_linear_nonfinite_batch3.rs::bayesian_ridge_*`). Non-test consumer: the existing `Fit::fit` / `RsBayesianRidge` consumers. (#2261) |
58
59use ferray::linalg::{LinalgFloat, svd};
60use ferray::{Array as FerrayArray, Ix2 as FerrayIx2};
61use ferrolearn_core::error::FerroError;
62use ferrolearn_core::introspection::HasCoefficients;
63use ferrolearn_core::pipeline::{FittedPipelineEstimator, PipelineEstimator};
64use ferrolearn_core::traits::{Fit, Predict};
65use ndarray::{Array1, Array2, Axis, ScalarOperand};
66use num_traits::{Float, FromPrimitive};
67
68/// Bayesian Ridge Regression with automatic regularization tuning.
69///
70/// Estimates weight precision (`lambda`) and noise precision (`alpha`)
71/// iteratively using evidence maximization. The intercept, if requested,
72/// is fit by centering.
73///
74/// # Type Parameters
75///
76/// - `F`: The floating-point type (`f32` or `f64`).
77#[derive(Debug, Clone)]
78pub struct BayesianRidge<F> {
79    /// Maximum number of EM (evidence-maximization) iterations.
80    pub max_iter: usize,
81    /// Convergence tolerance on `sum(|coef_old - coef|)` (sklearn `tol`).
82    pub tol: F,
83    /// Shape parameter of the Gamma prior over `alpha` (sklearn `alpha_1`,
84    /// default `1e-6`).
85    pub alpha_1: F,
86    /// Inverse-scale (rate) parameter of the Gamma prior over `alpha`
87    /// (sklearn `alpha_2`, default `1e-6`).
88    pub alpha_2: F,
89    /// Shape parameter of the Gamma prior over `lambda` (sklearn `lambda_1`,
90    /// default `1e-6`).
91    pub lambda_1: F,
92    /// Inverse-scale (rate) parameter of the Gamma prior over `lambda`
93    /// (sklearn `lambda_2`, default `1e-6`).
94    pub lambda_2: F,
95    /// Initial noise precision (alpha). `None` (the default) means
96    /// `1 / (Var(y) + eps)`, matching sklearn's `alpha_init=None`. Must be
97    /// positive when set.
98    pub alpha_init: Option<F>,
99    /// Initial weight precision (lambda). `None` (the default) means `1.0`,
100    /// matching sklearn's `lambda_init=None`. Must be positive when set.
101    pub lambda_init: Option<F>,
102    /// If `true`, accumulate the log marginal likelihood at each EM iteration
103    /// into `scores_` (sklearn `compute_score`, default `false`,
104    /// `_bayes.py:198`).
105    pub compute_score: bool,
106    /// Whether to fit an intercept (bias) term.
107    pub fit_intercept: bool,
108}
109
110impl<F: Float + FromPrimitive> BayesianRidge<F> {
111    /// Create a new `BayesianRidge` with default settings.
112    ///
113    /// Defaults mirror `sklearn.linear_model.BayesianRidge.__init__`
114    /// (`sklearn/linear_model/_bayes.py:187-202`): `max_iter = 300`,
115    /// `tol = 1e-3`, `alpha_1 = alpha_2 = lambda_1 = lambda_2 = 1e-6`,
116    /// `alpha_init = None` (⇒ `1/(Var(y)+eps)` at fit time),
117    /// `lambda_init = None` (⇒ `1.0`), `fit_intercept = true`.
118    #[must_use]
119    pub fn new() -> Self {
120        let eps6 = F::from(1e-6).unwrap_or_else(F::epsilon);
121        Self {
122            max_iter: 300,
123            tol: F::from(1e-3).unwrap_or_else(F::epsilon),
124            alpha_1: eps6,
125            alpha_2: eps6,
126            lambda_1: eps6,
127            lambda_2: eps6,
128            alpha_init: None,
129            lambda_init: None,
130            compute_score: false,
131            fit_intercept: true,
132        }
133    }
134
135    /// Set the maximum number of iterations.
136    #[must_use]
137    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
138        self.max_iter = max_iter;
139        self
140    }
141
142    /// Set the convergence tolerance.
143    #[must_use]
144    pub fn with_tol(mut self, tol: F) -> Self {
145        self.tol = tol;
146        self
147    }
148
149    /// Set the Gamma-prior shape parameter `alpha_1` over the noise precision.
150    #[must_use]
151    pub fn with_alpha_1(mut self, alpha_1: F) -> Self {
152        self.alpha_1 = alpha_1;
153        self
154    }
155
156    /// Set the Gamma-prior rate parameter `alpha_2` over the noise precision.
157    #[must_use]
158    pub fn with_alpha_2(mut self, alpha_2: F) -> Self {
159        self.alpha_2 = alpha_2;
160        self
161    }
162
163    /// Set the Gamma-prior shape parameter `lambda_1` over the weight precision.
164    #[must_use]
165    pub fn with_lambda_1(mut self, lambda_1: F) -> Self {
166        self.lambda_1 = lambda_1;
167        self
168    }
169
170    /// Set the Gamma-prior rate parameter `lambda_2` over the weight precision.
171    #[must_use]
172    pub fn with_lambda_2(mut self, lambda_2: F) -> Self {
173        self.lambda_2 = lambda_2;
174        self
175    }
176
177    /// Set the initial noise precision. `None` restores the `1/(Var(y)+eps)`
178    /// default.
179    #[must_use]
180    pub fn with_alpha_init(mut self, alpha_init: F) -> Self {
181        self.alpha_init = Some(alpha_init);
182        self
183    }
184
185    /// Set the initial weight precision. `None` restores the `1.0` default.
186    #[must_use]
187    pub fn with_lambda_init(mut self, lambda_init: F) -> Self {
188        self.lambda_init = Some(lambda_init);
189        self
190    }
191
192    /// Set whether to compute the log marginal likelihood at each iteration
193    /// (sklearn `compute_score`, `_bayes.py:198`). When `true`, the converged
194    /// model's [`FittedBayesianRidge::scores`] holds the per-iteration LML
195    /// sequence (length `n_iter_ + 1`); when `false` it is empty.
196    #[must_use]
197    pub fn with_compute_score(mut self, compute_score: bool) -> Self {
198        self.compute_score = compute_score;
199        self
200    }
201
202    /// Set whether to fit an intercept term.
203    #[must_use]
204    pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
205        self.fit_intercept = fit_intercept;
206        self
207    }
208}
209
210impl<F: Float + FromPrimitive> Default for BayesianRidge<F> {
211    fn default() -> Self {
212        Self::new()
213    }
214}
215
216/// Fitted Bayesian Ridge Regression model.
217///
218/// Stores the posterior mean coefficients, intercept, estimated noise
219/// precision (`alpha`), weight precision (`lambda`), the diagonal of the
220/// posterior covariance matrix (`sigma`), the full posterior covariance
221/// matrix (`sigma_full`, sklearn `sigma_`), the EM iteration count
222/// (`n_iter`), and the optional per-iteration log-marginal-likelihood
223/// sequence (`scores`).
224#[derive(Debug, Clone)]
225pub struct FittedBayesianRidge<F> {
226    /// Posterior mean coefficient vector.
227    coefficients: Array1<F>,
228    /// Intercept (bias) term.
229    intercept: F,
230    /// Estimated noise precision (1 / noise_variance).
231    alpha: F,
232    /// Estimated weight precision (1 / weight_variance).
233    lambda: F,
234    /// Diagonal of the posterior covariance matrix `Sigma`.
235    sigma: Array1<F>,
236    /// Full `(n_features, n_features)` posterior covariance matrix, mirroring
237    /// sklearn's `sigma_` (`_bayes.py:333-337`).
238    sigma_full: Array2<F>,
239    /// Actual number of EM iterations run, mirroring sklearn's `n_iter_`
240    /// (`_bayes.py:316`, `iter_ + 1`).
241    n_iter: usize,
242    /// Per-iteration log marginal likelihood (sklearn `scores_`,
243    /// `_bayes.py:283/302/330`). Empty unless `compute_score` was set;
244    /// otherwise length `n_iter + 1`.
245    scores: Vec<F>,
246}
247
248impl<F: Float> FittedBayesianRidge<F> {
249    /// Returns the estimated noise precision (alpha = 1/sigma²_noise).
250    pub fn alpha(&self) -> F {
251        self.alpha
252    }
253
254    /// Returns the estimated weight precision (lambda = 1/sigma²_weights).
255    pub fn lambda(&self) -> F {
256        self.lambda
257    }
258
259    /// Returns the diagonal of the posterior covariance matrix.
260    pub fn sigma(&self) -> &Array1<F> {
261        &self.sigma
262    }
263
264    /// Returns the full `(n_features, n_features)` posterior covariance matrix
265    /// (sklearn `sigma_`, `_bayes.py:333-337`).
266    pub fn sigma_full(&self) -> &Array2<F> {
267        &self.sigma_full
268    }
269
270    /// Returns the actual number of EM iterations run to reach the stopping
271    /// criterion (sklearn `n_iter_`, `_bayes.py:316`).
272    pub fn n_iter(&self) -> usize {
273        self.n_iter
274    }
275
276    /// Returns the per-iteration log-marginal-likelihood sequence (sklearn
277    /// `scores_`, `_bayes.py:283/302/330`). Empty unless the model was built
278    /// with `with_compute_score(true)`; otherwise of length `n_iter() + 1`.
279    pub fn scores(&self) -> &[F] {
280        &self.scores
281    }
282}
283
284/// Thin-SVD factor triple `(U, S, Vh)` returned by [`svd_thin`].
285type SvdFactors<F> = (Array2<F>, Array1<F>, Array2<F>);
286
287/// Compute the SVD of the (centered) design `X = U S Vᵀ` via the ferray
288/// substrate (`ferray::linalg::svd`, `ferray-linalg/src/decomp/svd.rs:40`),
289/// the analog of scikit-learn's `U, S, Vh = scipy.linalg.svd(X,
290/// full_matrices=False)` (`sklearn/linear_model/_bayes.py:287`).
291///
292/// The `ndarray ↔ ferray` conversion happens at this boundary (R-SUBSTRATE-4):
293/// the caller keeps its `ndarray` signature during the workspace-wide
294/// migration. Returns `(U, S, Vh)` as owned `ndarray` arrays with `U` of shape
295/// `(n_samples, k)`, `S` of length `k`, and `Vh` of shape `(k, n_features)`
296/// where `k = min(n_samples, n_features)`.
297fn svd_thin<F: LinalgFloat>(x: &Array2<F>) -> Result<SvdFactors<F>, FerroError> {
298    let (n_samples, n_features) = x.dim();
299
300    // Bridge ndarray -> ferray (R-SUBSTRATE-4).
301    let x_flat: Vec<F> = x.iter().copied().collect();
302    let a = FerrayArray::<F, FerrayIx2>::from_vec(FerrayIx2::new([n_samples, n_features]), x_flat)
303        .map_err(|e| FerroError::NumericalInstability {
304            message: format!("ferray svd: failed to build design matrix: {e}"),
305        })?;
306
307    // full_matrices=false => thin SVD, matching scipy's `full_matrices=False`.
308    let (u, s, vt) = svd(&a, false).map_err(|e| FerroError::NumericalInstability {
309        message: format!("ferray svd failed: {e}"),
310    })?;
311
312    // Bridge ferray -> ndarray.
313    let u_shape = u.shape();
314    let u_nd = Array2::from_shape_vec((u_shape[0], u_shape[1]), u.iter().copied().collect())
315        .map_err(|e| FerroError::NumericalInstability {
316            message: format!("ferray svd: U shape conversion failed: {e}"),
317        })?;
318    let s_nd = Array1::from_vec(s.iter().copied().collect());
319    let vt_shape = vt.shape();
320    let vt_nd = Array2::from_shape_vec((vt_shape[0], vt_shape[1]), vt.iter().copied().collect())
321        .map_err(|e| FerroError::NumericalInstability {
322            message: format!("ferray svd: Vt shape conversion failed: {e}"),
323        })?;
324
325    Ok((u_nd, s_nd, vt_nd))
326}
327
328/// Posterior mean `coef_` and residual sum of squares `rmse_`, mirroring
329/// scikit-learn's `BayesianRidge._update_coef_`
330/// (`sklearn/linear_model/_bayes.py:373-394`):
331///
332/// ```text
333/// coef_ = Vhᵀ · diag(S / (eigen_vals_ + lambda_/alpha_)) · (Uᵀ y)    (n > p)
334///       = Xᵀ · diag(1 / (eigen_vals_ + lambda_/alpha_)) · (Uᵀ y)·... (n ≤ p)
335/// rmse_ = sum((y - X·coef_)²)
336/// ```
337///
338/// We implement the `n_samples > n_features` posterior-mean form
339/// `coef_ = (Vhᵀ * S/(eigen_vals_ + lambda_/alpha_)) @ (Uᵀ y)` for both cases:
340/// the thin-SVD identity `Xᵀ y = Vhᵀ · diag(S) · (Uᵀ y)` makes
341/// `Vhᵀ · diag(S/(eig + lambda/alpha)) · (Uᵀ y)` equal to sklearn's `n ≤ p`
342/// branch `Xᵀ · diag(1/(eig + lambda/alpha)) · U Uᵀ y` whenever it shares the
343/// same row space, so the single form reproduces sklearn's `coef_` on both
344/// regimes (the test suite covers `n > p` and the binding's f64 path).
345#[allow(
346    clippy::too_many_arguments,
347    reason = "mirrors sklearn's BayesianRidge._update_coef_(self, X, y, n_samples, \
348              n_features, XT_y, U, Vh, eigen_vals_, alpha_, lambda_) — the SVD factors \
349              + precisions are the intrinsic posterior-mean inputs (_bayes.py:373)"
350)]
351fn update_coef<F: Float + ScalarOperand + 'static>(
352    x: &Array2<F>,
353    y: &Array1<F>,
354    u: &Array2<F>,
355    vt: &Array2<F>,
356    s: &Array1<F>,
357    eigen_vals: &Array1<F>,
358    alpha: F,
359    lambda: F,
360) -> (Array1<F>, F) {
361    let k = s.len();
362    // Uᵀ y, length k.
363    let ut_y = u.t().dot(y);
364    // scale_i = S_i / (eigen_vals_i + lambda_/alpha_)
365    let ratio = lambda / alpha;
366    let mut scaled = Array1::<F>::zeros(k);
367    for i in 0..k {
368        scaled[i] = s[i] / (eigen_vals[i] + ratio) * ut_y[i];
369    }
370    // coef_ = Vhᵀ · scaled  (Vh is (k, n_features), so Vhᵀ is (n_features, k)).
371    let coef = vt.t().dot(&scaled);
372
373    // rmse_ = sum((y - X·coef_)²)
374    let residual = y - &x.dot(&coef);
375    let rmse = residual.dot(&residual);
376
377    (coef, rmse)
378}
379
380/// Hyperprior shape/rate pairs `(alpha_1, alpha_2, lambda_1, lambda_2)` passed
381/// through to [`log_marginal_likelihood`].
382type Hyperpriors<F> = (F, F, F, F);
383
384/// Log marginal likelihood of the Bayesian-ridge evidence, mirroring
385/// scikit-learn's `BayesianRidge._log_marginal_likelihood`
386/// (`sklearn/linear_model/_bayes.py:396-426`).
387///
388/// For the `n_samples > n_features` regime (the only regime the ferrolearn fit
389/// exercises) the log-determinant of the posterior covariance is
390/// `logdet_sigma = -sum(log(lambda_ + alpha_ * eigen_vals_))` (`_bayes.py:409`),
391/// and the score is the sum of the Gamma-hyperprior terms and the evidence
392/// terms (`_bayes.py:415-424`):
393///
394/// ```text
395/// score = lambda_1*log(lambda_) - lambda_2*lambda_
396///       + alpha_1*log(alpha_)  - alpha_2*alpha_
397///       + 0.5*( n_features*log(lambda_) + n_samples*log(alpha_)
398///               - alpha_*rmse - lambda_*sum(coef²) + logdet_sigma
399///               - n_samples*log(2π) )
400/// ```
401#[allow(
402    clippy::too_many_arguments,
403    reason = "mirrors sklearn's BayesianRidge._log_marginal_likelihood(self, n_samples, \
404              n_features, eigen_vals, alpha_, lambda_, coef, rmse) — these are the \
405              intrinsic LML inputs (_bayes.py:396), with the four Gamma hyperpriors \
406              passed as one tuple"
407)]
408fn log_marginal_likelihood<F: Float + FromPrimitive>(
409    n_samples: usize,
410    n_features: usize,
411    eigen_vals: &Array1<F>,
412    alpha: F,
413    lambda: F,
414    coef: &Array1<F>,
415    rmse: F,
416    hyperpriors: Hyperpriors<F>,
417) -> F {
418    let (alpha_1, alpha_2, lambda_1, lambda_2) = hyperpriors;
419    let zero = F::zero();
420    let half = F::from(0.5).unwrap_or_else(|| F::one() / (F::one() + F::one()));
421    let two_pi = F::from(std::f64::consts::TAU).unwrap_or_else(F::one);
422
423    let n_s = F::from(n_samples).unwrap_or_else(F::one);
424    let n_f = F::from(n_features).unwrap_or_else(F::one);
425
426    // n_samples > n_features branch (`_bayes.py:408-409`).
427    // logdet_sigma = -sum(log(lambda_ + alpha_ * eigen_vals_)).
428    let logdet_sigma: F = eigen_vals
429        .iter()
430        .map(|&ev| (lambda + alpha * ev).ln())
431        .fold(zero, |acc, t| acc + t);
432    let logdet_sigma = -logdet_sigma;
433
434    let coef_sq: F = coef.iter().map(|&c| c * c).fold(zero, |a, b| a + b);
435
436    let mut score = lambda_1 * lambda.ln() - lambda_2 * lambda;
437    score = score + alpha_1 * alpha.ln() - alpha_2 * alpha;
438    score = score
439        + half
440            * (n_f * lambda.ln() + n_s * alpha.ln() - alpha * rmse - lambda * coef_sq
441                + logdet_sigma
442                - n_s * two_pi.ln());
443    score
444}
445
446/// Rescale `(X, y)` by `sqrt(sample_weight)` per sample, mirroring
447/// scikit-learn's `_rescale_data` (`sklearn/linear_model/_base.py`, applied at
448/// `_bayes.py:254-256`). This is the sample-weight implementation: a weighted
449/// least-squares fit is an ordinary fit on the rescaled data.
450fn rescale_data<F: Float>(
451    x: &Array2<F>,
452    y: &Array1<F>,
453    sample_weight: &Array1<F>,
454) -> (Array2<F>, Array1<F>) {
455    let sqrt_sw: Array1<F> = sample_weight.mapv(|w| w.sqrt());
456    let mut x_scaled = x.clone();
457    for (mut row, &s) in x_scaled.outer_iter_mut().zip(sqrt_sw.iter()) {
458        row.mapv_inplace(|v| v * s);
459    }
460    let y_scaled = y * &sqrt_sw;
461    (x_scaled, y_scaled)
462}
463
464impl<F: LinalgFloat + ScalarOperand + FromPrimitive> BayesianRidge<F> {
465    /// Fit the Bayesian Ridge model with optional per-sample weights, mirroring
466    /// `sklearn.linear_model.BayesianRidge.fit(X, y, sample_weight=None)`
467    /// (`sklearn/linear_model/_bayes.py:217-341`).
468    ///
469    /// When `sample_weight` is `Some`, `X` and `y` are rescaled by
470    /// `sqrt(sample_weight)` AFTER centering, exactly as sklearn applies
471    /// `_rescale_data` after `_preprocess_data` (`_bayes.py:246-256`); a
472    /// weighted least-squares fit is then an ordinary fit on the rescaled data.
473    /// Passing `None` is byte-identical to [`Fit::fit`].
474    ///
475    /// After centering (when `fit_intercept`), the (thin) SVD `X = U S Vᵀ`
476    /// gives `eigen_vals_ = S²` (`_bayes.py:287-288`). Each iteration updates
477    /// the posterior mean `coef_` (`_bayes.py:294`, `_update_coef_`), then the
478    /// effective degrees of freedom and the Gamma-prior precision updates
479    /// (`_bayes.py:305-307`):
480    ///
481    /// ```text
482    /// gamma_  = sum((alpha_ * eigen_vals_) / (lambda_ + alpha_ * eigen_vals_))
483    /// lambda_ = (gamma_ + 2*lambda_1) / (sum(coef_²) + 2*lambda_2)
484    /// alpha_  = (n_samples - gamma_ + 2*alpha_1) / (rmse_ + 2*alpha_2)
485    /// ```
486    ///
487    /// converging when `sum(|coef_old - coef_|) < tol` (`_bayes.py:310`).
488    /// `n_iter_` is set to `iter_ + 1` (`_bayes.py:316`); when `compute_score`
489    /// is set, the log marginal likelihood is accumulated per iteration plus
490    /// once after the loop (`_bayes.py:283/302/330`).
491    ///
492    /// # Errors
493    ///
494    /// - [`FerroError::ShapeMismatch`] — sample count mismatch (`y` or
495    ///   `sample_weight`).
496    /// - [`FerroError::InvalidParameter`] — non-positive `alpha_init`/`lambda_init`.
497    /// - [`FerroError::InsufficientSamples`] — fewer than 2 samples.
498    /// - [`FerroError::NumericalInstability`] — SVD or numerical failure.
499    pub fn fit_with_sample_weight(
500        &self,
501        x: &Array2<F>,
502        y: &Array1<F>,
503        sample_weight: Option<&Array1<F>>,
504    ) -> Result<FittedBayesianRidge<F>, FerroError> {
505        let (n_samples, n_features) = x.dim();
506
507        if n_samples != y.len() {
508            return Err(FerroError::ShapeMismatch {
509                expected: vec![n_samples],
510                actual: vec![y.len()],
511                context: "y length must match number of samples in X".into(),
512            });
513        }
514
515        if let Some(sw) = sample_weight
516            && sw.len() != n_samples
517        {
518            return Err(FerroError::ShapeMismatch {
519                expected: vec![n_samples],
520                actual: vec![sw.len()],
521                context: "sample_weight length must match number of samples in X".into(),
522            });
523        }
524
525        // Non-finite input validation, mirroring sklearn's
526        // `self._validate_data(X, y, ..., y_numeric=True)` (`_bayes.py:238-239`)
527        // which keeps the default `force_all_finite=True`, so `check_array`
528        // rejects any NaN or +/-inf in X OR y with a `ValueError` BEFORE the
529        // SVD/EM loop. sklearn also validates `sample_weight` via
530        // `_check_sample_weight` (default `force_all_finite=True`,
531        // `_bayes.py:244`). `.iter().any(|v| !v.is_finite())` rejects both NaN
532        // and Inf (bounds-safe, no panic, R-CODE-2), matching the crate idiom
533        // (`ridge.rs`/`lasso.rs`). The finite path is byte-identical (the guard
534        // never fires on finite input). `Fit::fit` delegates here with `None`.
535        if x.iter().any(|v| !v.is_finite()) {
536            return Err(FerroError::InvalidParameter {
537                name: "X".into(),
538                reason: "Input X contains NaN or infinity.".into(),
539            });
540        }
541        if y.iter().any(|v| !v.is_finite()) {
542            return Err(FerroError::InvalidParameter {
543                name: "y".into(),
544                reason: "Input y contains NaN or infinity.".into(),
545            });
546        }
547        if let Some(w) = sample_weight
548            && w.iter().any(|v| !v.is_finite())
549        {
550            return Err(FerroError::InvalidParameter {
551                name: "sample_weight".into(),
552                reason: "Input sample_weight contains NaN or infinity.".into(),
553            });
554        }
555
556        if n_samples < 2 {
557            return Err(FerroError::InsufficientSamples {
558                required: 2,
559                actual: n_samples,
560                context: "BayesianRidge requires at least 2 samples".into(),
561            });
562        }
563
564        let zero = <F as num_traits::Zero>::zero();
565        let one = <F as num_traits::One>::one();
566
567        if let Some(a0) = self.alpha_init
568            && a0 <= zero
569        {
570            return Err(FerroError::InvalidParameter {
571                name: "alpha_init".into(),
572                reason: "must be positive".into(),
573            });
574        }
575
576        if let Some(l0) = self.lambda_init
577            && l0 <= zero
578        {
579            return Err(FerroError::InvalidParameter {
580                name: "lambda_init".into(),
581                reason: "must be positive".into(),
582            });
583        }
584
585        let n_f = <F as num_traits::NumCast>::from(n_samples).unwrap_or(one);
586
587        // Center data for intercept (sklearn `_preprocess_data`, `_bayes.py:246`).
588        // sklearn's `_preprocess_data` computes the WEIGHTED column/target means
589        // when `sample_weight` is given; the rescaling itself (`_rescale_data`,
590        // `_bayes.py:254-256`) then multiplies the centered data by sqrt(w).
591        let (x_centered, y_centered, x_mean, y_mean) = if self.fit_intercept {
592            let (x_mean, y_mean) = match sample_weight {
593                Some(sw) => weighted_means(x, y, sw)?,
594                None => {
595                    let x_mean =
596                        x.mean_axis(Axis(0))
597                            .ok_or_else(|| FerroError::NumericalInstability {
598                                message: "failed to compute column means".into(),
599                            })?;
600                    let y_mean = y.mean().ok_or_else(|| FerroError::NumericalInstability {
601                        message: "failed to compute target mean".into(),
602                    })?;
603                    (x_mean, y_mean)
604                }
605            };
606            let x_c = x - &x_mean;
607            let y_c = y - y_mean;
608            (x_c, y_c, Some(x_mean), Some(y_mean))
609        } else {
610            (x.clone(), y.clone(), None, None)
611        };
612
613        // sample_weight: rescale centered (X, y) by sqrt(w) (`_bayes.py:254-256`).
614        let (x_work, y_work) = match sample_weight {
615            Some(sw) => rescale_data(&x_centered, &y_centered, sw),
616            None => (x_centered, y_centered),
617        };
618
619        // Initialization (`_bayes.py:262-271`): eps = finfo(dtype).eps;
620        // alpha_ = 1/(Var(y)+eps) when alpha_init is None; lambda_ = 1 when
621        // lambda_init is None. sklearn computes Var on the (rescaled) y.
622        let eps = <F as Float>::epsilon();
623        let mut alpha = match self.alpha_init {
624            Some(a0) => a0,
625            None => {
626                let var_y = variance(&y_work);
627                one / (var_y + eps)
628            }
629        };
630        let mut lambda = self.lambda_init.unwrap_or(one);
631
632        // SVD (`_bayes.py:287-288`): U, S, Vh = svd(X, full_matrices=False);
633        // eigen_vals_ = S².
634        let (u, s, vt) = svd_thin(&x_work)?;
635        let eigen_vals: Array1<F> = s.mapv(|v| v * v);
636
637        let two = one + one;
638        let alpha_1 = self.alpha_1;
639        let alpha_2 = self.alpha_2;
640        let lambda_1 = self.lambda_1;
641        let lambda_2 = self.lambda_2;
642        let hyperpriors = (alpha_1, alpha_2, lambda_1, lambda_2);
643
644        // `coef_old_` tracks the previous iterate for the convergence check;
645        // sklearn recomputes `coef_` once more after the loop (`_bayes.py:322`),
646        // so the in-loop posterior mean is not itself the returned coefficient.
647        let mut coef_old: Option<Array1<F>> = None;
648        let mut scores: Vec<F> = Vec::new();
649
650        // The LOCAL `coef_` from the last in-loop iteration. sklearn's post-loop
651        // `_log_marginal_likelihood` (`_bayes.py:327`) is passed this loop-local
652        // `coef_` (the posterior mean from the final iteration, computed with the
653        // pre-final alpha_/lambda_) — NOT the recomputed `self.coef_` of
654        // `_bayes.py:322` — paired with the freshly recomputed post-loop `rmse_`.
655        // We retain it to replicate sklearn's exact `scores_[-1]` (#2162).
656        let mut last_in_loop_coef: Option<Array1<F>> = None;
657
658        // `n_iter_` = iter_ + 1 after the loop (`_bayes.py:316`). The loop always
659        // runs at least once (max_iter >= 1 in sklearn's constraint), so track
660        // the last `iter_`.
661        let mut last_iter: usize = 0;
662
663        // Convergence loop (`_bayes.py:291-314`).
664        for iter_ in 0..self.max_iter {
665            last_iter = iter_;
666            let (coef_new, rmse) =
667                update_coef(&x_work, &y_work, &u, &vt, &s, &eigen_vals, alpha, lambda);
668
669            // compute_score: log marginal likelihood with the CURRENT
670            // alpha_/lambda_ and the just-computed coef_/rmse_ (`_bayes.py:297-302`).
671            if self.compute_score {
672                scores.push(log_marginal_likelihood(
673                    n_samples,
674                    n_features,
675                    &eigen_vals,
676                    alpha,
677                    lambda,
678                    &coef_new,
679                    rmse,
680                    hyperpriors,
681                ));
682            }
683
684            // gamma_ = sum((alpha_ * eigen_vals_) / (lambda_ + alpha_ * eigen_vals_))
685            let gamma: F = eigen_vals
686                .iter()
687                .map(|&ev| (alpha * ev) / (lambda + alpha * ev))
688                .fold(zero, |acc, t| acc + t);
689
690            // lambda_ = (gamma_ + 2*lambda_1) / (sum(coef_²) + 2*lambda_2)
691            let coef_sq: F = coef_new.iter().map(|&c| c * c).fold(zero, |a, b| a + b);
692            lambda = (gamma + two * lambda_1) / (coef_sq + two * lambda_2);
693
694            // alpha_ = (n_samples - gamma_ + 2*alpha_1) / (rmse_ + 2*alpha_2)
695            alpha = (n_f - gamma + two * alpha_1) / (rmse + two * alpha_2);
696
697            // Convergence: iter>0 and sum(|coef_old - coef|) < tol.
698            if iter_ != 0
699                && let Some(ref prev) = coef_old
700            {
701                let delta: F = prev
702                    .iter()
703                    .zip(coef_new.iter())
704                    .map(|(&o, &c)| (o - c).abs())
705                    .fold(zero, |a, b| a + b);
706                if delta < self.tol {
707                    last_in_loop_coef = Some(coef_new);
708                    break;
709                }
710            }
711            last_in_loop_coef = Some(coef_new.clone());
712            coef_old = Some(coef_new);
713        }
714
715        let n_iter = last_iter + 1;
716
717        // Final coef_ update with the converged alpha_/lambda_ (`_bayes.py:322`).
718        let (coef, final_rmse) =
719            update_coef(&x_work, &y_work, &u, &vt, &s, &eigen_vals, alpha, lambda);
720
721        // Final score with the converged alpha_/lambda_ (`_bayes.py:325-330`).
722        // R-DEV-1: sklearn's line 327 passes the LOOP-LOCAL `coef_` (the last
723        // in-loop posterior mean) together with the freshly RECOMPUTED `rmse_`
724        // — a mismatched pair, since line 322 only rebinds `self.coef_`, not the
725        // local `coef_`. We replicate that exactly: `last_in_loop_coef` (NOT the
726        // recomputed `coef`) paired with `final_rmse` (#2162). The fitted
727        // `coef`/predict path keeps the recomputed `coef` (line 322's
728        // `self.coef_`) and is unaffected.
729        if self.compute_score {
730            let score_coef = last_in_loop_coef.as_ref().unwrap_or(&coef);
731            scores.push(log_marginal_likelihood(
732                n_samples,
733                n_features,
734                &eigen_vals,
735                alpha,
736                lambda,
737                score_coef,
738                final_rmse,
739                hyperpriors,
740            ));
741        }
742
743        // Full posterior covariance sigma_ = (1/alpha_) * Vhᵀ ·
744        // diag(1/(eigen_vals_ + lambda_/alpha_)) · Vh (`_bayes.py:333-337`).
745        let ratio = lambda / alpha;
746        let inv_alpha = one / alpha;
747        let k = s.len();
748        // scaled_rows_i = Vh_i / (eigen_vals_i + lambda_/alpha_); sigma_full =
749        // (1/alpha_) * Vhᵀ @ scaled_rows.
750        let mut sigma_full = Array2::<F>::zeros((n_features, n_features));
751        for a in 0..n_features {
752            for b in 0..n_features {
753                let mut acc = zero;
754                for i in 0..k {
755                    acc += (vt[[i, a]] * vt[[i, b]]) / (eigen_vals[i] + ratio);
756                }
757                sigma_full[[a, b]] = inv_alpha * acc;
758            }
759        }
760        let sigma_diag: Array1<F> = (0..n_features).map(|j| sigma_full[[j, j]]).collect();
761
762        // intercept_ = y_offset - X_offset · coef_ (`_bayes.py:339`,
763        // `_set_intercept`).
764        let intercept = if let (Some(xm), Some(ym)) = (&x_mean, &y_mean) {
765            *ym - xm.dot(&coef)
766        } else {
767            zero
768        };
769
770        Ok(FittedBayesianRidge {
771            coefficients: coef,
772            intercept,
773            alpha,
774            lambda,
775            sigma: sigma_diag,
776            sigma_full,
777            n_iter,
778            scores,
779        })
780    }
781}
782
783impl<F: LinalgFloat + ScalarOperand + FromPrimitive> Fit<Array2<F>, Array1<F>>
784    for BayesianRidge<F>
785{
786    type Fitted = FittedBayesianRidge<F>;
787    type Error = FerroError;
788
789    /// Fit the Bayesian Ridge model by MacKay (1992) evidence maximization,
790    /// mirroring `sklearn.linear_model.BayesianRidge.fit`
791    /// (`sklearn/linear_model/_bayes.py:217-341`). This delegates to
792    /// [`BayesianRidge::fit_with_sample_weight`] with `sample_weight = None`.
793    ///
794    /// # Errors
795    ///
796    /// See [`BayesianRidge::fit_with_sample_weight`].
797    fn fit(&self, x: &Array2<F>, y: &Array1<F>) -> Result<FittedBayesianRidge<F>, FerroError> {
798        self.fit_with_sample_weight(x, y, None)
799    }
800}
801
802/// Weighted column means of `X` and weighted mean of `y` using `sample_weight`,
803/// mirroring the weighted averages sklearn's `_preprocess_data` computes when
804/// `sample_weight` is supplied (`sklearn/linear_model/_base.py`, used at
805/// `_bayes.py:246-252`): `X_offset_ = average(X, axis=0, weights=sw)`,
806/// `y_offset_ = average(y, weights=sw)`.
807fn weighted_means<F: Float>(
808    x: &Array2<F>,
809    y: &Array1<F>,
810    sample_weight: &Array1<F>,
811) -> Result<(Array1<F>, F), FerroError> {
812    let n_features = x.ncols();
813    let sw_sum = sample_weight.iter().fold(F::zero(), |a, &b| a + b);
814    if sw_sum <= F::zero() {
815        return Err(FerroError::InvalidParameter {
816            name: "sample_weight".into(),
817            reason: "sum of sample_weight must be positive".into(),
818        });
819    }
820    let mut x_mean = Array1::<F>::zeros(n_features);
821    for (row, &w) in x.outer_iter().zip(sample_weight.iter()) {
822        for (j, &v) in row.iter().enumerate() {
823            x_mean[j] = x_mean[j] + w * v;
824        }
825    }
826    x_mean.mapv_inplace(|s| s / sw_sum);
827    let y_mean = y
828        .iter()
829        .zip(sample_weight.iter())
830        .fold(F::zero(), |acc, (&yi, &w)| acc + w * yi)
831        / sw_sum;
832    Ok((x_mean, y_mean))
833}
834
835/// Population variance `mean((v - mean(v))²)`, matching numpy's `np.var`
836/// (the `ddof=0` default sklearn relies on at `_bayes.py:269`).
837fn variance<F: Float>(v: &Array1<F>) -> F {
838    let n = v.len();
839    if n == 0 {
840        return F::zero();
841    }
842    let n_f = F::from(n).unwrap_or_else(F::one);
843    let mean = v.iter().fold(F::zero(), |a, &b| a + b) / n_f;
844    let ss = v
845        .iter()
846        .map(|&x| (x - mean) * (x - mean))
847        .fold(F::zero(), |a, b| a + b);
848    ss / n_f
849}
850
851impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>>
852    for FittedBayesianRidge<F>
853{
854    type Output = Array1<F>;
855    type Error = FerroError;
856
857    /// Predict target values using the posterior mean coefficients.
858    ///
859    /// Computes `X @ coefficients + intercept`.
860    ///
861    /// # Errors
862    ///
863    /// Returns [`FerroError::ShapeMismatch`] if the number of features
864    /// does not match the fitted model.
865    fn predict(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
866        let n_features = x.ncols();
867        if n_features != self.coefficients.len() {
868            return Err(FerroError::ShapeMismatch {
869                expected: vec![self.coefficients.len()],
870                actual: vec![n_features],
871                context: "number of features must match fitted model".into(),
872            });
873        }
874
875        let preds = x.dot(&self.coefficients) + self.intercept;
876        Ok(preds)
877    }
878}
879
880impl<F: Float + ScalarOperand + 'static> FittedBayesianRidge<F> {
881    /// Predict the posterior mean AND the predictive standard deviation,
882    /// mirroring `sklearn.linear_model.BayesianRidge.predict(X, return_std=True)`
883    /// (`sklearn/linear_model/_bayes.py:367-371`):
884    ///
885    /// ```text
886    /// y_mean = X @ coef_ + intercept_
887    /// y_std  = sqrt( (X @ sigma_ * X).sum(axis=1) + 1/alpha_ )
888    /// ```
889    ///
890    /// Returns `(y_mean, y_std)`.
891    ///
892    /// # Errors
893    ///
894    /// Returns [`FerroError::ShapeMismatch`] if the number of features does not
895    /// match the fitted model.
896    pub fn predict_with_std(&self, x: &Array2<F>) -> Result<(Array1<F>, Array1<F>), FerroError> {
897        let n_features = x.ncols();
898        if n_features != self.coefficients.len() {
899            return Err(FerroError::ShapeMismatch {
900                expected: vec![self.coefficients.len()],
901                actual: vec![n_features],
902                context: "number of features must match fitted model".into(),
903            });
904        }
905
906        let y_mean = x.dot(&self.coefficients) + self.intercept;
907
908        // sigmas_squared_data = (X @ sigma_ * X).sum(axis=1) (`_bayes.py:369`).
909        let xs = x.dot(&self.sigma_full); // (n_samples, n_features)
910        let inv_alpha = F::one() / self.alpha;
911        let y_std: Array1<F> = xs
912            .outer_iter()
913            .zip(x.outer_iter())
914            .map(|(xs_row, x_row)| {
915                let q = xs_row
916                    .iter()
917                    .zip(x_row.iter())
918                    .fold(F::zero(), |acc, (&a, &b)| acc + a * b);
919                (q + inv_alpha).sqrt()
920            })
921            .collect();
922
923        Ok((y_mean, y_std))
924    }
925}
926
927impl<F: Float + Send + Sync + ScalarOperand + 'static> HasCoefficients<F>
928    for FittedBayesianRidge<F>
929{
930    /// Returns the posterior mean coefficient vector.
931    fn coefficients(&self) -> &Array1<F> {
932        &self.coefficients
933    }
934
935    /// Returns the intercept term.
936    fn intercept(&self) -> F {
937        self.intercept
938    }
939}
940
941// Pipeline integration.
942impl<F> PipelineEstimator<F> for BayesianRidge<F>
943where
944    F: LinalgFloat + FromPrimitive + ScalarOperand,
945{
946    /// Fit the model and return it as a boxed pipeline estimator.
947    ///
948    /// # Errors
949    ///
950    /// Propagates any [`FerroError`] from `fit`.
951    fn fit_pipeline(
952        &self,
953        x: &Array2<F>,
954        y: &Array1<F>,
955    ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
956        let fitted = self.fit(x, y)?;
957        Ok(Box::new(fitted))
958    }
959}
960
961impl<F> FittedPipelineEstimator<F> for FittedBayesianRidge<F>
962where
963    F: Float + ScalarOperand + Send + Sync + 'static,
964{
965    /// Generate predictions via the pipeline interface.
966    ///
967    /// # Errors
968    ///
969    /// Propagates any [`FerroError`] from `predict`.
970    fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
971        self.predict(x)
972    }
973}
974
975#[cfg(test)]
976mod tests {
977    use super::*;
978    use approx::assert_relative_eq;
979    use ndarray::array;
980
981    // ---- Builder ----
982
983    #[test]
984    fn test_default_constructor() {
985        // Mirrors sklearn BayesianRidge.__init__ defaults (`_bayes.py:187-202`):
986        // alpha_init/lambda_init default to None; the four Gamma hyperpriors
987        // default to 1e-6.
988        let m = BayesianRidge::<f64>::new();
989        assert_eq!(m.max_iter, 300);
990        assert!(m.fit_intercept);
991        assert!(m.alpha_init.is_none());
992        assert!(m.lambda_init.is_none());
993        assert_relative_eq!(m.alpha_1, 1e-6);
994        assert_relative_eq!(m.alpha_2, 1e-6);
995        assert_relative_eq!(m.lambda_1, 1e-6);
996        assert_relative_eq!(m.lambda_2, 1e-6);
997    }
998
999    #[test]
1000    fn test_builder_setters() {
1001        let m = BayesianRidge::<f64>::new()
1002            .with_max_iter(50)
1003            .with_tol(1e-6)
1004            .with_alpha_init(2.0)
1005            .with_lambda_init(0.5)
1006            .with_alpha_1(1e-4)
1007            .with_alpha_2(2e-4)
1008            .with_lambda_1(3e-4)
1009            .with_lambda_2(4e-4)
1010            .with_fit_intercept(false);
1011        assert_eq!(m.max_iter, 50);
1012        assert!(!m.fit_intercept);
1013        assert_eq!(m.alpha_init, Some(2.0));
1014        assert_eq!(m.lambda_init, Some(0.5));
1015        assert_relative_eq!(m.alpha_1, 1e-4);
1016        assert_relative_eq!(m.alpha_2, 2e-4);
1017        assert_relative_eq!(m.lambda_1, 3e-4);
1018        assert_relative_eq!(m.lambda_2, 4e-4);
1019    }
1020
1021    // ---- Validation errors ----
1022
1023    #[test]
1024    fn test_shape_mismatch() {
1025        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
1026        let y = array![1.0, 2.0];
1027        let result = BayesianRidge::<f64>::new().fit(&x, &y);
1028        assert!(result.is_err());
1029    }
1030
1031    #[test]
1032    fn test_insufficient_samples() {
1033        let x = Array2::from_shape_vec((1, 1), vec![1.0]).unwrap();
1034        let y = array![1.0];
1035        let result = BayesianRidge::<f64>::new().fit(&x, &y);
1036        assert!(result.is_err());
1037    }
1038
1039    #[test]
1040    fn test_non_positive_alpha_init() {
1041        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
1042        let y = array![1.0, 2.0, 3.0];
1043        let result = BayesianRidge::<f64>::new().with_alpha_init(0.0).fit(&x, &y);
1044        assert!(result.is_err());
1045    }
1046
1047    #[test]
1048    fn test_non_positive_lambda_init() {
1049        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
1050        let y = array![1.0, 2.0, 3.0];
1051        let result = BayesianRidge::<f64>::new()
1052            .with_lambda_init(-1.0)
1053            .fit(&x, &y);
1054        assert!(result.is_err());
1055    }
1056
1057    // ---- Correctness ----
1058
1059    #[test]
1060    fn test_fits_linear_data() {
1061        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
1062        let y = array![3.0, 5.0, 7.0, 9.0, 11.0];
1063
1064        let fitted = BayesianRidge::<f64>::new().fit(&x, &y).unwrap();
1065
1066        // Should recover roughly y = 2x + 1.
1067        assert_relative_eq!(fitted.coefficients()[0], 2.0, epsilon = 0.1);
1068        assert_relative_eq!(fitted.intercept(), 1.0, epsilon = 0.5);
1069    }
1070
1071    #[test]
1072    fn test_alpha_and_lambda_positive() {
1073        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
1074        let y = array![2.0, 4.0, 6.0, 8.0, 10.0];
1075
1076        let fitted = BayesianRidge::<f64>::new().fit(&x, &y).unwrap();
1077
1078        assert!(fitted.alpha() > 0.0);
1079        assert!(fitted.lambda() > 0.0);
1080    }
1081
1082    #[test]
1083    fn test_sigma_diagonal_positive() {
1084        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
1085        let y = array![2.0, 4.0, 6.0, 8.0, 10.0];
1086
1087        let fitted = BayesianRidge::<f64>::new().fit(&x, &y).unwrap();
1088
1089        for &v in fitted.sigma() {
1090            assert!(v > 0.0, "sigma diagonal must be positive, got {v}");
1091        }
1092    }
1093
1094    #[test]
1095    fn test_sigma_length_matches_features() {
1096        let x = Array2::from_shape_vec(
1097            (5, 2),
1098            vec![1.0, 0.5, 2.0, 1.0, 3.0, 1.5, 4.0, 2.0, 5.0, 2.5],
1099        )
1100        .unwrap();
1101        let y = array![2.0, 4.0, 6.0, 8.0, 10.0];
1102
1103        let fitted = BayesianRidge::<f64>::new().fit(&x, &y).unwrap();
1104        assert_eq!(fitted.sigma().len(), 2);
1105    }
1106
1107    #[test]
1108    fn test_no_intercept() {
1109        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1110        let y = array![2.0, 4.0, 6.0, 8.0];
1111
1112        let fitted = BayesianRidge::<f64>::new()
1113            .with_fit_intercept(false)
1114            .fit(&x, &y)
1115            .unwrap();
1116
1117        assert_relative_eq!(fitted.intercept(), 0.0, epsilon = 1e-10);
1118    }
1119
1120    #[test]
1121    fn test_predict_length() {
1122        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
1123        let y = array![2.0, 4.0, 6.0, 8.0, 10.0];
1124
1125        let fitted = BayesianRidge::<f64>::new().fit(&x, &y).unwrap();
1126        let preds = fitted.predict(&x).unwrap();
1127        assert_eq!(preds.len(), 5);
1128    }
1129
1130    #[test]
1131    fn test_predict_feature_mismatch() {
1132        let x = Array2::from_shape_vec((3, 2), vec![1.0, 0.0, 2.0, 0.0, 3.0, 0.0]).unwrap();
1133        let y = array![1.0, 2.0, 3.0];
1134        let fitted = BayesianRidge::<f64>::new().fit(&x, &y).unwrap();
1135
1136        let x_bad = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
1137        assert!(fitted.predict(&x_bad).is_err());
1138    }
1139
1140    #[test]
1141    fn test_has_coefficients_length() {
1142        let x = Array2::from_shape_vec(
1143            (4, 3),
1144            vec![1.0, 0.0, 0.5, 2.0, 1.0, 1.0, 3.0, 0.0, 1.5, 4.0, 1.0, 2.0],
1145        )
1146        .unwrap();
1147        let y = array![1.0, 2.0, 3.0, 4.0];
1148        let fitted = BayesianRidge::<f64>::new().fit(&x, &y).unwrap();
1149        assert_eq!(fitted.coefficients().len(), 3);
1150    }
1151
1152    #[test]
1153    fn test_pipeline_integration() {
1154        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1155        let y = array![3.0, 5.0, 7.0, 9.0];
1156
1157        let model = BayesianRidge::<f64>::new();
1158        let fitted_pipe = model.fit_pipeline(&x, &y).unwrap();
1159        let preds = fitted_pipe.predict_pipeline(&x).unwrap();
1160        assert_eq!(preds.len(), 4);
1161    }
1162
1163    #[test]
1164    fn test_multivariate_fit() {
1165        // y = 1*x1 + 2*x2
1166        let x =
1167            Array2::from_shape_vec((4, 2), vec![1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0, 2.0]).unwrap();
1168        let y = array![1.0, 2.0, 3.0, 6.0];
1169
1170        let fitted = BayesianRidge::<f64>::new().fit(&x, &y).unwrap();
1171        let preds = fitted.predict(&x).unwrap();
1172        assert_eq!(preds.len(), 4);
1173        // Rough sanity: residuals should be small.
1174        let residuals: Vec<f64> = preds
1175            .iter()
1176            .zip(y.iter())
1177            .map(|(p, t)| (p - t).abs())
1178            .collect();
1179        assert!(residuals.iter().all(|&r| r < 1.0));
1180    }
1181}