Skip to main content

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;