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}