use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
use crate::error::{RegressionError, Result};
use crate::linalg::{self, dmatrix_from_rows, dvector_from_slice};
#[derive(Debug, Clone)]
pub struct OlsFit {
x: Array2<f64>,
y: Array1<f64>,
coefficients: Array1<f64>,
fitted: Array1<f64>,
residuals: Array1<f64>,
leverage: Array1<f64>,
xtx_inv: Array2<f64>,
singular_values: Vec<f64>,
intercept_col: Option<usize>,
n: usize,
p: usize,
rss: f64,
sigma2: f64,
}
impl OlsFit {
pub fn new(x: Array2<f64>, y: Array1<f64>) -> Result<Self> {
let intercept_col = detect_constant_column(&x);
Self::build(x, y, intercept_col)
}
pub fn with_intercept(x: Array2<f64>, y: Array1<f64>) -> Result<Self> {
if x.nrows() == 0 {
return Err(RegressionError::EmptyInput { what: "X" });
}
let n = x.nrows();
let mut augmented = Array2::<f64>::ones((n, x.ncols() + 1));
for j in 0..x.ncols() {
augmented.column_mut(j + 1).assign(&x.column(j));
}
Self::build(augmented, y, Some(0))
}
fn build(x: Array2<f64>, y: Array1<f64>, intercept_col: Option<usize>) -> Result<Self> {
let n = x.nrows();
let p = x.ncols();
if n == 0 || p == 0 {
return Err(RegressionError::EmptyInput { what: "X" });
}
if y.is_empty() {
return Err(RegressionError::EmptyInput { what: "y" });
}
if y.len() != n {
return Err(RegressionError::ShapeMismatch {
what: "y length vs X rows",
expected: n,
got: y.len(),
});
}
if n <= p {
return Err(RegressionError::NoResidualDegreesOfFreedom {
n,
p,
df: n as isize - p as isize,
});
}
let x_dm = dmatrix_from_rows(
n,
p,
x.as_standard_layout().as_slice().expect("standard layout"),
);
let y_dv = dvector_from_slice(y.as_standard_layout().as_slice().expect("standard layout"));
let qr = linalg::ols_via_qr(&x_dm, &y_dv)?;
let coefficients = Array1::from_iter(qr.coef.iter().copied());
let fitted = Array1::from_iter(qr.fitted.iter().copied());
let residuals = &y - &fitted;
let leverage = Array1::from_iter(qr.leverage.iter().copied());
let xtx_inv = Array2::from_shape_fn((p, p), |(i, j)| qr.xtx_inv[(i, j)]);
let rss: f64 = residuals.iter().map(|r| r * r).sum();
let sigma2 = rss / (n - p) as f64;
Ok(Self {
x,
y,
coefficients,
fitted,
residuals,
leverage,
xtx_inv,
singular_values: qr.singular_values,
intercept_col,
n,
p,
rss,
sigma2,
})
}
pub fn n_observations(&self) -> usize {
self.n
}
pub fn n_parameters(&self) -> usize {
self.p
}
pub fn has_intercept(&self) -> bool {
self.intercept_col.is_some()
}
pub fn intercept_column(&self) -> Option<usize> {
self.intercept_col
}
pub fn design_matrix(&self) -> ArrayView2<'_, f64> {
self.x.view()
}
pub fn response(&self) -> ArrayView1<'_, f64> {
self.y.view()
}
pub fn coefficients(&self) -> ArrayView1<'_, f64> {
self.coefficients.view()
}
pub fn fitted_values(&self) -> ArrayView1<'_, f64> {
self.fitted.view()
}
pub fn residuals(&self) -> ArrayView1<'_, f64> {
self.residuals.view()
}
pub fn leverage(&self) -> ArrayView1<'_, f64> {
self.leverage.view()
}
pub fn residual_sum_of_squares(&self) -> f64 {
self.rss
}
pub fn df_residual(&self) -> f64 {
(self.n - self.p) as f64
}
pub fn df_model(&self) -> f64 {
if self.has_intercept() {
(self.p - 1) as f64
} else {
self.p as f64
}
}
pub fn residual_variance(&self) -> f64 {
self.sigma2
}
pub fn residual_standard_error(&self) -> f64 {
self.sigma2.sqrt()
}
pub fn coefficient_standard_errors(&self) -> Array1<f64> {
Array1::from_shape_fn(self.p, |j| (self.sigma2 * self.xtx_inv[(j, j)]).sqrt())
}
pub fn singular_values(&self) -> &[f64] {
&self.singular_values
}
pub(crate) fn column_on_others_r2(&self, j: usize) -> Option<f64> {
if self.intercept_col == Some(j) {
return None;
}
let others: Vec<usize> = (0..self.p).filter(|&c| c != j).collect();
let sub = self.x.select(ndarray::Axis(1), &others);
let target = self.x.column(j).to_owned();
let sub_dm = dmatrix_from_rows(
self.n,
others.len(),
sub.as_standard_layout()
.as_slice()
.expect("standard layout"),
);
let target_dv = dvector_from_slice(target.as_slice().expect("contiguous"));
linalg::aux_r_squared(&sub_dm, &target_dv)
}
}
fn detect_constant_column(x: &Array2<f64>) -> Option<usize> {
for (j, col) in x.columns().into_iter().enumerate() {
let first = col[0];
let scale = first.abs().max(1.0);
if col.iter().all(|&v| (v - first).abs() <= 1e-12 * scale) {
return Some(j);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::array;
#[test]
fn coefficients_match_closed_form() {
let x = array![[1.0, 0.0], [1.0, 1.0], [1.0, 2.0], [1.0, 3.0]];
let y = array![1.0, 3.0, 5.0, 7.0];
let fit = OlsFit::new(x, y).unwrap();
assert!((fit.coefficients()[0] - 1.0).abs() < 1e-9);
assert!((fit.coefficients()[1] - 2.0).abs() < 1e-9);
assert!(fit.residuals().iter().all(|&e| e.abs() < 1e-9));
}
#[test]
fn coefficients_match_worked_example() {
let x = array![[1.0, 1.0], [1.0, 2.0], [1.0, 3.0], [1.0, 4.0], [1.0, 5.0]];
let y = array![1.0, 2.0, 1.3, 3.75, 2.25];
let fit = OlsFit::new(x, y).unwrap();
assert!((fit.coefficients()[0] - 0.785).abs() < 1e-3);
assert!((fit.coefficients()[1] - 0.425).abs() < 1e-3);
}
#[test]
fn leverage_sums_to_p() {
let x = array![
[1.0, 0.0, 2.0],
[1.0, 1.0, 1.0],
[1.0, 2.0, 4.0],
[1.0, 3.0, 1.0],
[1.0, 4.0, 5.0],
[1.0, 5.0, 2.0],
];
let y = array![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let fit = OlsFit::new(x, y).unwrap();
let total: f64 = fit.leverage().sum();
assert!((total - 3.0).abs() < 1e-9, "leverage sum = {total}");
assert!(fit
.leverage()
.iter()
.all(|&h| (0.0..=1.0 + 1e-9).contains(&h)));
}
#[test]
fn with_intercept_prepends_ones() {
let x = array![[0.0], [1.0], [2.0], [3.0]];
let y = array![1.0, 3.0, 5.0, 7.0];
let fit = OlsFit::with_intercept(x, y).unwrap();
assert_eq!(fit.n_parameters(), 2);
assert_eq!(fit.intercept_column(), Some(0));
assert!((fit.coefficients()[0] - 1.0).abs() < 1e-9);
assert!((fit.coefficients()[1] - 2.0).abs() < 1e-9);
}
}