stats_claw/algorithms/regression/mod.rs
1//! Linear regression: ordinary least squares (OLS) and ridge (L2) regression.
2//!
3//! Both estimators fit a coefficient vector `β` and an intercept by the normal
4//! equations. OLS minimises `‖y − Xβ‖²`; ridge adds an L2 penalty `λ‖β‖²` on the
5//! slopes (the intercept is left unpenalised, matching `scikit-learn`'s `Ridge`).
6//! The fit centres the design and target, solves `(X_cᵀX_c + λI)β = X_cᵀy_c` for
7//! the centred slopes by Gaussian elimination with partial pivoting, and recovers
8//! the intercept from the column means. This base block backs `RegressionMethod`.
9
10mod linalg;
11
12use linalg::{centered, column_means, mean, solve};
13
14/// A fitted linear model: an intercept plus one slope per predictor column.
15///
16/// Fields are private so the fitted parameters stay an invariant pair (the
17/// intercept and slopes always come from the same fit); read them through
18/// [`LinearModel::intercept`] and [`LinearModel::coefficients`], and evaluate the
19/// model with [`LinearModel::predict`] / [`LinearModel::r_squared`].
20#[derive(Debug, Clone, PartialEq)]
21pub struct LinearModel {
22 /// The fitted intercept term (`β₀`); the model's prediction at the origin.
23 intercept: f64,
24 /// One fitted slope coefficient per predictor column, in input column order.
25 coefficients: Vec<f64>,
26}
27
28impl LinearModel {
29 /// Returns the fitted intercept term (`β₀`).
30 #[must_use]
31 pub const fn intercept(&self) -> f64 {
32 self.intercept
33 }
34
35 /// Returns the fitted slope coefficients, one per predictor column in input
36 /// column order.
37 #[must_use]
38 pub fn coefficients(&self) -> &[f64] {
39 &self.coefficients
40 }
41
42 /// Predicts the target for a single observation `row`.
43 ///
44 /// Computes `β₀ + Σ_j β_j x_j`. Coordinates of `row` beyond the fitted slope
45 /// count are ignored, and missing coordinates contribute zero, so a `row` whose
46 /// length differs from the training width still yields a finite prediction
47 /// (callers are responsible for passing rows of the fitted dimension).
48 ///
49 /// # Arguments
50 ///
51 /// * `row` — the predictor values for one observation, in fitted column order.
52 ///
53 /// # Returns
54 ///
55 /// The model's scalar prediction `ŷ` for `row`.
56 #[must_use]
57 pub fn predict(&self, row: &[f64]) -> f64 {
58 self.coefficients
59 .iter()
60 .zip(row)
61 .fold(self.intercept, |acc, (&beta, &x)| beta.mul_add(x, acc))
62 }
63
64 /// Computes the coefficient of determination `R²` of the model on `x`/`y`.
65 ///
66 /// `R² = 1 − SS_res / SS_tot`, where `SS_res = Σ(yᵢ − ŷᵢ)²` and
67 /// `SS_tot = Σ(yᵢ − ȳ)²`. Matches `scikit-learn`'s `score`: when `SS_tot` is
68 /// zero (a constant target) the result is `1.0` if the residuals also vanish,
69 /// else `0.0`. `R²` can be negative when the model fits worse than the mean.
70 ///
71 /// # Arguments
72 ///
73 /// * `x` — design matrix to score against, one inner slice per observation.
74 /// * `y` — observed targets, one per row of `x`.
75 ///
76 /// # Returns
77 ///
78 /// The `R²` score in `(−∞, 1.0]`. An empty `y` yields `0.0`.
79 #[must_use]
80 pub fn r_squared(&self, x: &[Vec<f64>], y: &[f64]) -> f64 {
81 if y.is_empty() {
82 return 0.0;
83 }
84 let y_mean = mean(y);
85 let mut ss_res = 0.0_f64;
86 let mut ss_tot = 0.0_f64;
87 for (row, &yi) in x.iter().zip(y) {
88 let resid = yi - self.predict(row);
89 ss_res = resid.mul_add(resid, ss_res);
90 let dev = yi - y_mean;
91 ss_tot = dev.mul_add(dev, ss_tot);
92 }
93 if ss_tot <= 0.0 {
94 return if ss_res <= 0.0 { 1.0 } else { 0.0 };
95 }
96 1.0 - ss_res / ss_tot
97 }
98}
99
100/// Errors that prevent a linear model from being fitted.
101///
102/// Returned (never panicked) so callers stay clear of the crate's `unwrap`/`panic`
103/// lint gate and can surface a clean diagnostic.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum RegressionError {
106 /// `x` and `y` disagreed on the number of observations (rows).
107 RowMismatch {
108 /// Number of rows in the design matrix `x`.
109 rows_x: usize,
110 /// Number of entries in the target vector `y`.
111 rows_y: usize,
112 },
113 /// The design matrix had no rows, so no model can be fitted.
114 EmptyInput,
115 /// Rows of `x` did not all share the same number of columns.
116 RaggedRows,
117 /// The (penalised) normal-equations matrix was singular to working precision,
118 /// so the coefficients are not uniquely determined.
119 Singular,
120 /// The ridge penalty `λ` was negative (or non-finite), which is not a valid L2
121 /// regularisation strength.
122 InvalidPenalty,
123}
124
125impl std::fmt::Display for RegressionError {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 match self {
128 Self::RowMismatch { rows_x, rows_y } => {
129 write!(f, "row count mismatch: x has {rows_x}, y has {rows_y}")
130 }
131 Self::EmptyInput => write!(f, "design matrix has no observations"),
132 Self::RaggedRows => write!(f, "design matrix rows have unequal length"),
133 Self::Singular => write!(f, "normal-equations matrix is singular"),
134 Self::InvalidPenalty => {
135 write!(f, "ridge penalty must be finite and non-negative")
136 }
137 }
138 }
139}
140
141impl std::error::Error for RegressionError {}
142
143/// Fits an ordinary-least-squares linear model to `x` and `y`.
144///
145/// Equivalent to `scikit-learn`'s `LinearRegression` with `fit_intercept=True`: the
146/// model includes an unpenalised intercept and minimises the residual sum of
147/// squares `‖y − Xβ − β₀‖²`. Internally this is ridge with a zero penalty.
148///
149/// # Arguments
150///
151/// * `x` — design matrix as one inner slice per observation; every row must have
152/// the same number of predictor columns.
153/// * `y` — target values, one per row of `x`.
154///
155/// # Returns
156///
157/// A [`LinearModel`] with the fitted intercept and per-column slopes.
158///
159/// # Errors
160///
161/// Returns [`RegressionError::EmptyInput`] for no rows,
162/// [`RegressionError::RowMismatch`] if `x` and `y` disagree on the row count,
163/// [`RegressionError::RaggedRows`] if rows differ in width, or
164/// [`RegressionError::Singular`] if the normal-equations matrix is not invertible
165/// (e.g. perfectly collinear predictors).
166///
167/// # Examples
168///
169/// ```
170/// use stats_claw::algorithms::regression::ols;
171///
172/// // y = 2 + 3*x₁ exactly: the fit recovers intercept 2 and slope 3.
173/// let x = vec![vec![0.0], vec![1.0], vec![2.0], vec![3.0]];
174/// let y = vec![2.0, 5.0, 8.0, 11.0];
175/// let model = ols(&x, &y)?;
176/// assert!((model.intercept() - 2.0).abs() < 1e-9, "intercept {}", model.intercept());
177/// let slope = model.coefficients().first().copied().unwrap_or(f64::NAN);
178/// assert!((slope - 3.0).abs() < 1e-9, "slope {slope}");
179/// # Ok::<(), stats_claw::algorithms::regression::RegressionError>(())
180/// ```
181pub fn ols(x: &[Vec<f64>], y: &[f64]) -> Result<LinearModel, RegressionError> {
182 ridge(x, y, 0.0)
183}
184
185/// Fits a ridge (L2-regularised) linear model to `x` and `y` with penalty `lambda`.
186///
187/// Equivalent to `scikit-learn`'s `Ridge(alpha=lambda, fit_intercept=True)`: the
188/// design and target are centred, the penalised normal equations
189/// `(X_cᵀX_c + λI)β = X_cᵀy_c` are solved for the centred slopes (so the penalty
190/// shrinks the slopes but never the intercept), and the intercept is recovered from
191/// the column means. With `lambda == 0.0` this reduces to [`ols`].
192///
193/// # Arguments
194///
195/// * `x` — design matrix, one inner slice per observation; rows must share width.
196/// * `y` — target values, one per row of `x`.
197/// * `lambda` — L2 penalty strength `λ ≥ 0`; larger values shrink the slopes more.
198///
199/// # Returns
200///
201/// A [`LinearModel`] with the fitted intercept and shrunk per-column slopes.
202///
203/// # Errors
204///
205/// Returns [`RegressionError::InvalidPenalty`] if `lambda` is negative or
206/// non-finite, plus the same shape/singularity errors as [`ols`].
207///
208/// # Examples
209///
210/// ```
211/// use stats_claw::algorithms::regression::ridge;
212///
213/// // A positive penalty shrinks the slope toward zero relative to OLS.
214/// let x = vec![vec![0.0], vec![1.0], vec![2.0], vec![3.0]];
215/// let y = vec![2.0, 5.0, 8.0, 11.0];
216/// let model = ridge(&x, &y, 1.0)?;
217/// let slope = model.coefficients().first().copied().unwrap_or(f64::NAN);
218/// assert!(slope < 3.0 && slope > 0.0, "shrunk slope {slope}");
219/// # Ok::<(), stats_claw::algorithms::regression::RegressionError>(())
220/// ```
221pub fn ridge(x: &[Vec<f64>], y: &[f64], lambda: f64) -> Result<LinearModel, RegressionError> {
222 if !lambda.is_finite() || lambda < 0.0 {
223 return Err(RegressionError::InvalidPenalty);
224 }
225 let n_rows = x.len();
226 if n_rows == 0 {
227 return Err(RegressionError::EmptyInput);
228 }
229 if y.len() != n_rows {
230 return Err(RegressionError::RowMismatch {
231 rows_x: n_rows,
232 rows_y: y.len(),
233 });
234 }
235 let n_cols = x.first().map_or(0, Vec::len);
236 if x.iter().any(|row| row.len() != n_cols) {
237 return Err(RegressionError::RaggedRows);
238 }
239 // A model with no predictors is just the target mean as the intercept.
240 if n_cols == 0 {
241 return Ok(LinearModel {
242 intercept: mean(y),
243 coefficients: Vec::new(),
244 });
245 }
246
247 let col_means = column_means(x, n_cols);
248 let y_mean = mean(y);
249
250 // Penalised normal equations on the centred data:
251 // A = X_cᵀX_c + λI (size n_cols × n_cols, symmetric)
252 // b = X_cᵀy_c
253 let mut a = vec![vec![0.0_f64; n_cols]; n_cols];
254 let mut b = vec![0.0_f64; n_cols];
255 for (row, &yi) in x.iter().zip(y) {
256 let yc = yi - y_mean;
257 for j in 0..n_cols {
258 let xj = centered(row, &col_means, j);
259 if let Some(bj) = b.get_mut(j) {
260 *bj += xj * yc;
261 }
262 if let Some(a_row) = a.get_mut(j) {
263 for k in 0..n_cols {
264 let xk = centered(row, &col_means, k);
265 if let Some(a_jk) = a_row.get_mut(k) {
266 *a_jk += xj * xk;
267 }
268 }
269 }
270 }
271 }
272 for (j, a_row) in a.iter_mut().enumerate() {
273 if let Some(a_jj) = a_row.get_mut(j) {
274 *a_jj += lambda;
275 }
276 }
277
278 let coefficients = solve(a, b)?;
279 // Recover the intercept: β₀ = ȳ − Σ_j β_j x̄_j.
280 let slope_dot_mean: f64 = coefficients
281 .iter()
282 .zip(&col_means)
283 .map(|(&beta, &mean_j)| beta * mean_j)
284 .sum();
285 Ok(LinearModel {
286 intercept: y_mean - slope_dot_mean,
287 coefficients,
288 })
289}
290
291#[cfg(test)]
292#[path = "tests.rs"]
293mod tests;