Skip to main content

u_analytics/
regression.rs

1//! Regression analysis.
2//!
3//! Simple and multiple linear regression with OLS, R², coefficient testing,
4//! ANOVA, VIF, and residual diagnostics.
5//!
6//! # Examples
7//!
8//! ```
9//! use u_analytics::regression::simple_linear_regression;
10//!
11//! let x = [1.0, 2.0, 3.0, 4.0, 5.0];
12//! let y = [2.1, 3.9, 6.1, 7.9, 10.1];
13//! let result = simple_linear_regression(&x, &y).unwrap();
14//! assert!((result.slope - 2.0).abs() < 0.1);
15//! assert!((result.intercept - 0.1).abs() < 0.2);
16//! assert!(result.r_squared > 0.99);
17//! ```
18
19use u_numflow::matrix::Matrix;
20use u_numflow::special;
21use u_numflow::stats;
22
23/// Result of a simple linear regression: y = intercept + slope · x.
24#[derive(Debug, Clone)]
25pub struct SimpleRegressionResult {
26    /// Slope coefficient (β₁).
27    pub slope: f64,
28    /// Intercept (β₀).
29    pub intercept: f64,
30    /// Coefficient of determination (R²).
31    pub r_squared: f64,
32    /// Adjusted R² = 1 - (1-R²)(n-1)/(n-2).
33    pub adjusted_r_squared: f64,
34    /// Standard error of the slope.
35    pub slope_se: f64,
36    /// Standard error of the intercept.
37    pub intercept_se: f64,
38    /// t-statistic for slope (H₀: β₁ = 0).
39    pub slope_t: f64,
40    /// t-statistic for intercept (H₀: β₀ = 0).
41    pub intercept_t: f64,
42    /// p-value for slope.
43    pub slope_p: f64,
44    /// p-value for intercept.
45    pub intercept_p: f64,
46    /// Residual standard error (√(SSE/(n-2))).
47    pub residual_se: f64,
48    /// F-statistic (= t² for simple regression).
49    pub f_statistic: f64,
50    /// p-value for F-statistic.
51    pub f_p_value: f64,
52    /// Residuals (yᵢ - ŷᵢ).
53    pub residuals: Vec<f64>,
54    /// Fitted values (ŷᵢ).
55    pub fitted: Vec<f64>,
56    /// Sample size.
57    pub n: usize,
58}
59
60/// Result of a multiple linear regression: y = Xβ + ε.
61#[derive(Debug, Clone)]
62pub struct MultipleRegressionResult {
63    /// Coefficient vector [β₀, β₁, ..., βₚ] (intercept first).
64    pub coefficients: Vec<f64>,
65    /// Standard errors of coefficients.
66    pub std_errors: Vec<f64>,
67    /// t-statistics for each coefficient.
68    pub t_statistics: Vec<f64>,
69    /// p-values for each coefficient.
70    pub p_values: Vec<f64>,
71    /// Coefficient of determination (R²).
72    pub r_squared: f64,
73    /// Adjusted R² = 1 - (1-R²)(n-1)/(n-p-1).
74    pub adjusted_r_squared: f64,
75    /// F-statistic for overall significance.
76    pub f_statistic: f64,
77    /// p-value for F-statistic.
78    pub f_p_value: f64,
79    /// Residual standard error.
80    pub residual_se: f64,
81    /// Residuals.
82    pub residuals: Vec<f64>,
83    /// Fitted values.
84    pub fitted: Vec<f64>,
85    /// VIF (Variance Inflation Factor) for each predictor (excludes intercept).
86    pub vif: Vec<f64>,
87    /// Sample size.
88    pub n: usize,
89    /// Number of predictors (excluding intercept).
90    pub p: usize,
91}
92
93// ---------------------------------------------------------------------------
94// Simple Linear Regression
95// ---------------------------------------------------------------------------
96
97/// Computes simple linear regression (OLS closed-form).
98///
99/// # Algorithm
100///
101/// β₁ = cov(x,y) / var(x)
102/// β₀ = ȳ - β₁·x̄
103///
104/// # Returns
105///
106/// `None` if fewer than 3 observations, slices differ in length, x has zero
107/// variance, or inputs contain non-finite values.
108///
109/// # References
110///
111/// Draper & Smith (1998). "Applied Regression Analysis", 3rd edition.
112///
113/// # Examples
114///
115/// ```
116/// use u_analytics::regression::simple_linear_regression;
117///
118/// let x = [1.0, 2.0, 3.0, 4.0, 5.0];
119/// let y = [2.0, 4.0, 6.0, 8.0, 10.0];
120/// let r = simple_linear_regression(&x, &y).unwrap();
121/// assert!((r.slope - 2.0).abs() < 1e-10);
122/// assert!((r.intercept).abs() < 1e-10);
123/// assert!((r.r_squared - 1.0).abs() < 1e-10);
124/// ```
125pub fn simple_linear_regression(x: &[f64], y: &[f64]) -> Option<SimpleRegressionResult> {
126    let n = x.len();
127    if n < 3 || n != y.len() {
128        return None;
129    }
130    if x.iter().any(|v| !v.is_finite()) || y.iter().any(|v| !v.is_finite()) {
131        return None;
132    }
133
134    let x_mean = stats::mean(x)?;
135    let y_mean = stats::mean(y)?;
136    let x_var = stats::variance(x)?;
137    let cov = stats::covariance(x, y)?;
138
139    if x_var < 1e-300 {
140        return None; // zero variance in x
141    }
142
143    let slope = cov / x_var;
144    let intercept = y_mean - slope * x_mean;
145
146    // Fitted values and residuals
147    let fitted: Vec<f64> = x.iter().map(|&xi| intercept + slope * xi).collect();
148    let residuals: Vec<f64> = y
149        .iter()
150        .zip(fitted.iter())
151        .map(|(&yi, &fi)| yi - fi)
152        .collect();
153
154    // Sum of squares
155    let ss_res: f64 = residuals.iter().map(|r| r * r).sum();
156    let ss_tot: f64 = y.iter().map(|&yi| (yi - y_mean).powi(2)).sum();
157
158    let nf = n as f64;
159    let df_res = nf - 2.0;
160
161    let r_squared = if ss_tot > 1e-300 {
162        1.0 - ss_res / ss_tot
163    } else {
164        1.0
165    };
166    let adjusted_r_squared = 1.0 - (1.0 - r_squared) * (nf - 1.0) / df_res;
167
168    // Residual standard error
169    let mse = ss_res / df_res;
170    let residual_se = mse.sqrt();
171
172    // Standard errors of coefficients
173    let ss_x: f64 = x.iter().map(|&xi| (xi - x_mean).powi(2)).sum();
174    let slope_se = (mse / ss_x).sqrt();
175    let intercept_se = (mse * (1.0 / nf + x_mean * x_mean / ss_x)).sqrt();
176
177    // t-statistics
178    let slope_t = if slope_se > 1e-300 {
179        slope / slope_se
180    } else {
181        f64::INFINITY
182    };
183    let intercept_t = if intercept_se > 1e-300 {
184        intercept / intercept_se
185    } else {
186        f64::INFINITY
187    };
188
189    // p-values via t-distribution
190    let slope_p = 2.0 * special::t_distribution_sf(slope_t.abs(), df_res);
191    let intercept_p = 2.0 * special::t_distribution_sf(intercept_t.abs(), df_res);
192
193    // F-statistic (= t² for simple regression)
194    let f_statistic = slope_t * slope_t;
195    let f_p_value = if f_statistic.is_infinite() {
196        0.0
197    } else {
198        special::f_distribution_sf(f_statistic, 1.0, df_res)
199    };
200
201    Some(SimpleRegressionResult {
202        slope,
203        intercept,
204        r_squared,
205        adjusted_r_squared,
206        slope_se,
207        intercept_se,
208        slope_t,
209        intercept_t,
210        slope_p,
211        intercept_p,
212        residual_se,
213        f_statistic,
214        f_p_value,
215        residuals,
216        fitted,
217        n,
218    })
219}
220
221// ---------------------------------------------------------------------------
222// Multiple Linear Regression
223// ---------------------------------------------------------------------------
224
225/// Computes multiple linear regression via OLS (Cholesky solve).
226///
227/// # Arguments
228///
229/// * `predictors` — Slice of predictor variable slices. Each inner slice is
230///   one predictor's observations. All must have the same length.
231/// * `y` — Response variable observations.
232///
233/// # Algorithm
234///
235/// Constructs the design matrix X = [1 | x₁ | x₂ | ... | xₚ] (intercept column prepended),
236/// then solves the normal equations X'Xβ = X'y via Cholesky decomposition.
237///
238/// # Returns
239///
240/// `None` if n < p+2, predictor lengths differ, inputs contain non-finite
241/// values, or the system is singular.
242///
243/// # References
244///
245/// Draper & Smith (1998). "Applied Regression Analysis", 3rd edition.
246/// Montgomery, Peck & Vining (2012). "Introduction to Linear Regression Analysis", 5th edition.
247///
248/// # Examples
249///
250/// ```
251/// use u_analytics::regression::multiple_linear_regression;
252///
253/// let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
254/// let x2 = [2.0, 1.0, 3.0, 2.0, 4.0, 3.0, 5.0, 4.0];
255/// let y  = [5.1, 5.0, 9.2, 8.9, 13.1, 12.0, 17.2, 15.9];
256/// let result = multiple_linear_regression(&[&x1, &x2], &y).unwrap();
257/// assert!(result.r_squared > 0.95);
258/// assert_eq!(result.coefficients.len(), 3); // intercept + 2 predictors
259/// ```
260pub fn multiple_linear_regression(
261    predictors: &[&[f64]],
262    y: &[f64],
263) -> Option<MultipleRegressionResult> {
264    let p = predictors.len(); // number of predictors
265    let n = y.len();
266
267    if p == 0 || n < p + 2 {
268        return None;
269    }
270
271    // Validate lengths and finite values
272    for pred in predictors {
273        if pred.len() != n {
274            return None;
275        }
276        if pred.iter().any(|v| !v.is_finite()) {
277            return None;
278        }
279    }
280    if y.iter().any(|v| !v.is_finite()) {
281        return None;
282    }
283
284    let ncols = p + 1; // intercept + predictors
285
286    // Build design matrix X (n × ncols, row-major)
287    let mut x_data = Vec::with_capacity(n * ncols);
288    for i in 0..n {
289        x_data.push(1.0); // intercept
290        for pred in predictors {
291            x_data.push(pred[i]);
292        }
293    }
294    let x_mat = Matrix::new(n, ncols, x_data).ok()?;
295
296    // X'X (ncols × ncols)
297    let xt = x_mat.transpose();
298    let xtx = xt.mul_mat(&x_mat).ok()?;
299
300    // X'y (ncols × 1)
301    let xty = xt.mul_vec(y).ok()?;
302
303    // Solve via Cholesky: β = (X'X)⁻¹ X'y
304    let coefficients = xtx.cholesky_solve(&xty).ok()?;
305
306    // Fitted values and residuals
307    let fitted = x_mat.mul_vec(&coefficients).ok()?;
308    let residuals: Vec<f64> = y
309        .iter()
310        .zip(fitted.iter())
311        .map(|(&yi, &fi)| yi - fi)
312        .collect();
313
314    // R² and adjusted R²
315    let y_mean = stats::mean(y)?;
316    let ss_res: f64 = residuals.iter().map(|r| r * r).sum();
317    let ss_tot: f64 = y.iter().map(|&yi| (yi - y_mean).powi(2)).sum();
318
319    let nf = n as f64;
320    let pf = p as f64;
321    let df_res = nf - pf - 1.0;
322
323    let r_squared = if ss_tot > 1e-300 {
324        1.0 - ss_res / ss_tot
325    } else {
326        1.0
327    };
328    let adjusted_r_squared = 1.0 - (1.0 - r_squared) * (nf - 1.0) / df_res;
329
330    // Residual standard error
331    let mse = ss_res / df_res;
332    let residual_se = mse.sqrt();
333
334    // Standard errors: SE(β) = sqrt(diag((X'X)⁻¹) · MSE)
335    let xtx_inv = xtx.inverse().ok()?;
336    let mut std_errors = Vec::with_capacity(ncols);
337    let mut t_statistics = Vec::with_capacity(ncols);
338    let mut p_values = Vec::with_capacity(ncols);
339
340    for (j, &coeff_j) in coefficients.iter().enumerate() {
341        let se = (xtx_inv.get(j, j) * mse).sqrt();
342        std_errors.push(se);
343        let t = if se > 1e-300 {
344            coeff_j / se
345        } else {
346            f64::INFINITY
347        };
348        t_statistics.push(t);
349        let pv = 2.0 * special::t_distribution_sf(t.abs(), df_res);
350        p_values.push(pv);
351    }
352
353    // F-statistic: (SS_reg / p) / (SS_res / (n-p-1))
354    let ss_reg = ss_tot - ss_res;
355    let f_statistic = if pf > 0.0 && mse > 1e-300 {
356        (ss_reg / pf) / mse
357    } else {
358        0.0
359    };
360    let f_p_value = if f_statistic.is_infinite() || f_statistic.is_nan() {
361        0.0
362    } else {
363        special::f_distribution_sf(f_statistic, pf, df_res)
364    };
365
366    // VIF for each predictor: VIF_j = 1/(1 - R²_j) where R²_j is from
367    // regressing x_j on all other predictors
368    let vif = compute_vif(predictors);
369
370    Some(MultipleRegressionResult {
371        coefficients,
372        std_errors,
373        t_statistics,
374        p_values,
375        r_squared,
376        adjusted_r_squared,
377        f_statistic,
378        f_p_value,
379        residual_se,
380        residuals,
381        fitted,
382        vif,
383        n,
384        p,
385    })
386}
387
388/// Computes VIF for each predictor by regressing each on all others.
389fn compute_vif(predictors: &[&[f64]]) -> Vec<f64> {
390    let p = predictors.len();
391    if p < 2 {
392        return vec![1.0; p];
393    }
394
395    let mut vif = Vec::with_capacity(p);
396    for j in 0..p {
397        // Regress x_j on all other predictors
398        let y_j = predictors[j];
399        let others: Vec<&[f64]> = predictors
400            .iter()
401            .enumerate()
402            .filter(|&(i, _)| i != j)
403            .map(|(_, v)| *v)
404            .collect();
405
406        if let Some(result) = multiple_linear_regression(&others, y_j) {
407            let r2 = result.r_squared;
408            if r2 < 1.0 - 1e-15 {
409                vif.push(1.0 / (1.0 - r2));
410            } else {
411                vif.push(f64::INFINITY); // perfect multicollinearity
412            }
413        } else {
414            vif.push(f64::NAN);
415        }
416    }
417    vif
418}
419
420/// Predicts y values given new x data and a simple regression result.
421///
422/// # Examples
423///
424/// ```
425/// use u_analytics::regression::{simple_linear_regression, predict_simple};
426///
427/// let x = [1.0, 2.0, 3.0, 4.0, 5.0];
428/// let y = [2.0, 4.0, 6.0, 8.0, 10.0];
429/// let model = simple_linear_regression(&x, &y).unwrap();
430/// let pred = predict_simple(&model, &[6.0, 7.0]);
431/// assert!((pred[0] - 12.0).abs() < 1e-10);
432/// assert!((pred[1] - 14.0).abs() < 1e-10);
433/// ```
434pub fn predict_simple(model: &SimpleRegressionResult, x_new: &[f64]) -> Vec<f64> {
435    x_new
436        .iter()
437        .map(|&xi| model.intercept + model.slope * xi)
438        .collect()
439}
440
441/// Predicts y values given new predictor data and a multiple regression result.
442///
443/// # Arguments
444///
445/// * `model` — Multiple regression result.
446/// * `predictors_new` — Slice of predictor slices (same order as training).
447///
448/// Returns `None` if predictor count doesn't match or lengths differ.
449pub fn predict_multiple(
450    model: &MultipleRegressionResult,
451    predictors_new: &[&[f64]],
452) -> Option<Vec<f64>> {
453    if predictors_new.len() != model.p {
454        return None;
455    }
456    let n = predictors_new.first()?.len();
457    for pred in predictors_new {
458        if pred.len() != n {
459            return None;
460        }
461    }
462
463    let mut result = Vec::with_capacity(n);
464    for i in 0..n {
465        let mut yi = model.coefficients[0]; // intercept
466        for (j, pred) in predictors_new.iter().enumerate() {
467            yi += model.coefficients[j + 1] * pred[i];
468        }
469        result.push(yi);
470    }
471    Some(result)
472}
473
474// ---------------------------------------------------------------------------
475// Multicollinearity diagnostics (public)
476// ---------------------------------------------------------------------------
477
478/// Computes the Variance Inflation Factor for each predictor.
479///
480/// `VIF_j = 1 / (1 - R²_j)`, where `R²_j` is the coefficient of determination
481/// from regressing predictor `j` on all other predictors. Standard thresholds
482/// are `VIF > 5` (moderate multicollinearity) and `VIF > 10` (severe).
483///
484/// # Returns
485///
486/// `None` if `predictors` is empty, lengths differ, fewer than `p+1`
487/// observations, or any predictor contains a non-finite value. With a single
488/// predictor, returns `Some(vec![1.0])` since VIF is undefined for one
489/// regressor.
490///
491/// # References
492///
493/// Marquardt (1970). "Generalized inverses, ridge regression."
494/// Belsley, Kuh, Welsch (1980). *Regression Diagnostics*.
495///
496/// # Examples
497///
498/// ```
499/// use u_analytics::regression::vif;
500///
501/// let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
502/// let x2 = [8.0, 1.0, 6.0, 3.0, 5.0, 7.0, 2.0, 4.0];
503/// let v = vif(&[&x1, &x2]).unwrap();
504/// assert!(v[0] < 2.0); // independent predictors
505/// ```
506pub fn vif(predictors: &[&[f64]]) -> Option<Vec<f64>> {
507    let p = predictors.len();
508    if p == 0 {
509        return None;
510    }
511    let n = predictors[0].len();
512    if !predictors.iter().all(|c| c.len() == n) {
513        return None;
514    }
515    if n <= p {
516        return None;
517    }
518    if predictors.iter().any(|c| c.iter().any(|v| !v.is_finite())) {
519        return None;
520    }
521    Some(compute_vif(predictors))
522}
523
524/// Computes the 2-norm condition number of the sample covariance matrix.
525///
526/// `cond(Σ) = λ_max / λ_min`, where `λ` are the eigenvalues of the unbiased
527/// sample covariance matrix of the (column-major) predictors. Standard
528/// threshold: `cond > 30` indicates multicollinearity worth investigating;
529/// `cond > 100` is severe (Belsley 1991).
530///
531/// # Returns
532///
533/// `None` for empty input, length mismatch, fewer than `p+1` observations,
534/// non-finite values, or eigenvalue decomposition failure. Returns
535/// `f64::INFINITY` when the smallest eigenvalue is below `1e-15`
536/// (numerically singular).
537///
538/// # References
539///
540/// Belsley (1991). *Conditioning Diagnostics*.
541///
542/// # Examples
543///
544/// ```
545/// use u_analytics::regression::condition_number;
546///
547/// let x1 = [1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0];
548/// let x2 = [1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, -1.0];
549/// let c = condition_number(&[&x1, &x2]).unwrap();
550/// assert!(c < 5.0); // near-orthogonal columns: cond should be small
551/// ```
552pub fn condition_number(predictors: &[&[f64]]) -> Option<f64> {
553    use u_numflow::matrix::Matrix;
554    let p = predictors.len();
555    if p == 0 {
556        return None;
557    }
558    let n = predictors[0].len();
559    if !predictors.iter().all(|c| c.len() == n) {
560        return None;
561    }
562    if n <= p {
563        return None;
564    }
565    if predictors.iter().any(|c| c.iter().any(|v| !v.is_finite())) {
566        return None;
567    }
568
569    // Sample covariance matrix (unbiased, denominator n-1)
570    let mut means = vec![0.0_f64; p];
571    for (j, col) in predictors.iter().enumerate() {
572        means[j] = col.iter().sum::<f64>() / n as f64;
573    }
574    let mut cov = vec![0.0_f64; p * p];
575    let denom = (n - 1) as f64;
576    for r in 0..p {
577        let col_r = predictors[r];
578        let mean_r = means[r];
579        for c in r..p {
580            let col_c = predictors[c];
581            let mean_c = means[c];
582            let sum: f64 = col_r
583                .iter()
584                .zip(col_c.iter())
585                .map(|(&a, &b)| (a - mean_r) * (b - mean_c))
586                .sum();
587            let v = sum / denom;
588            cov[r * p + c] = v;
589            cov[c * p + r] = v;
590        }
591    }
592
593    let mat = Matrix::new(p, p, cov).ok()?;
594    let (eigenvalues, _eigenvectors) = mat.eigen_symmetric().ok()?;
595    let lambda_max = eigenvalues
596        .iter()
597        .copied()
598        .fold(f64::NEG_INFINITY, f64::max);
599    let lambda_min = eigenvalues.iter().copied().fold(f64::INFINITY, f64::min);
600
601    if !lambda_max.is_finite() || lambda_max <= 0.0 {
602        return None;
603    }
604    if lambda_min < 1e-15 {
605        return Some(f64::INFINITY);
606    }
607    Some(lambda_max / lambda_min)
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    // -----------------------------------------------------------------------
615    // Simple regression tests
616    // -----------------------------------------------------------------------
617
618    #[test]
619    fn simple_perfect_fit() {
620        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
621        let y = [3.0, 5.0, 7.0, 9.0, 11.0]; // y = 1 + 2x
622        let r = simple_linear_regression(&x, &y).expect("should compute");
623        assert!((r.slope - 2.0).abs() < 1e-10);
624        assert!((r.intercept - 1.0).abs() < 1e-10);
625        assert!((r.r_squared - 1.0).abs() < 1e-10);
626    }
627
628    #[test]
629    fn simple_with_noise() {
630        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
631        let y = [2.1, 3.9, 6.1, 7.9, 10.1]; // y ≈ 2x + 0.1
632        let r = simple_linear_regression(&x, &y).expect("should compute");
633        assert!((r.slope - 2.0).abs() < 0.1);
634        assert!(r.r_squared > 0.99);
635        assert_eq!(r.residuals.len(), 5);
636        assert_eq!(r.fitted.len(), 5);
637    }
638
639    #[test]
640    fn simple_residuals_sum_to_zero() {
641        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
642        let y = [2.1, 3.9, 6.1, 7.9, 10.1];
643        let r = simple_linear_regression(&x, &y).expect("should compute");
644        let sum: f64 = r.residuals.iter().sum();
645        assert!(sum.abs() < 1e-10, "residuals sum = {sum}");
646    }
647
648    #[test]
649    fn simple_f_equals_t_squared() {
650        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
651        let y = [2.1, 3.9, 6.1, 7.9, 10.1];
652        let r = simple_linear_regression(&x, &y).expect("should compute");
653        assert!(
654            (r.f_statistic - r.slope_t * r.slope_t).abs() < 1e-8,
655            "F = {}, t² = {}",
656            r.f_statistic,
657            r.slope_t * r.slope_t
658        );
659    }
660
661    #[test]
662    fn simple_significant_slope() {
663        let x: Vec<f64> = (0..20).map(|i| i as f64).collect();
664        let y: Vec<f64> = x.iter().map(|&xi| 3.0 + 2.0 * xi).collect();
665        let r = simple_linear_regression(&x, &y).expect("should compute");
666        assert!(r.slope_p < 1e-10, "slope p = {}", r.slope_p);
667        assert!(r.f_p_value < 1e-10, "F p = {}", r.f_p_value);
668    }
669
670    #[test]
671    fn simple_negative_slope() {
672        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
673        let y = [10.0, 8.0, 6.0, 4.0, 2.0]; // y = 12 - 2x
674        let r = simple_linear_regression(&x, &y).expect("should compute");
675        assert!((r.slope + 2.0).abs() < 1e-10);
676        assert!((r.intercept - 12.0).abs() < 1e-10);
677    }
678
679    #[test]
680    fn simple_edge_cases() {
681        assert!(simple_linear_regression(&[1.0, 2.0], &[3.0, 4.0]).is_none()); // n < 3
682        assert!(simple_linear_regression(&[1.0, 2.0, 3.0], &[4.0, 5.0]).is_none()); // mismatch
683        assert!(simple_linear_regression(&[5.0, 5.0, 5.0], &[1.0, 2.0, 3.0]).is_none()); // zero var
684        assert!(simple_linear_regression(&[1.0, f64::NAN, 3.0], &[4.0, 5.0, 6.0]).is_none());
685    }
686
687    /// Verifies exact OLS numeric reference from the spec.
688    ///
689    /// x = [1,2,3,4,5], y = [2,4,5,4,5]
690    /// x̄=3, ȳ=4
691    /// Σ(xᵢ-x̄)(yᵢ-ȳ) = 6, Σ(xᵢ-x̄)² = 10
692    /// β̂₁ = 6/10 = 0.6, β̂₀ = 4 - 0.6·3 = 2.2
693    /// SS_tot = 6, SS_res = 2.4, R² = 1 - 2.4/6 = 0.6
694    ///
695    /// Reference: Draper & Smith (1998), Applied Regression Analysis, 3rd ed.
696    #[test]
697    fn simple_numeric_reference_ols() {
698        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
699        let y = [2.0, 4.0, 5.0, 4.0, 5.0];
700        let r = simple_linear_regression(&x, &y).expect("should compute");
701
702        assert!(
703            (r.slope - 0.6).abs() < 1e-10,
704            "β̂₁ expected 0.6, got {}",
705            r.slope
706        );
707        assert!(
708            (r.intercept - 2.2).abs() < 1e-10,
709            "β̂₀ expected 2.2, got {}",
710            r.intercept
711        );
712        assert!(
713            (r.r_squared - 0.6).abs() < 1e-3,
714            "R² expected 0.6, got {}",
715            r.r_squared
716        );
717
718        // Verify fitted values
719        // ŷ₁=2.8, ŷ₂=3.4, ŷ₃=4.0, ŷ₄=4.6, ŷ₅=5.2
720        let expected_fitted = [2.8, 3.4, 4.0, 4.6, 5.2];
721        for (i, (&fi, &ef)) in r.fitted.iter().zip(expected_fitted.iter()).enumerate() {
722            assert!(
723                (fi - ef).abs() < 1e-10,
724                "ŷ_{} expected {}, got {}",
725                i + 1,
726                ef,
727                fi
728            );
729        }
730    }
731
732    #[test]
733    fn simple_adjusted_r_squared() {
734        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
735        let y = [2.1, 3.9, 6.1, 7.9, 10.1];
736        let r = simple_linear_regression(&x, &y).expect("should compute");
737        // For perfect fit, adjusted R² ≈ R²
738        assert!(r.adjusted_r_squared <= r.r_squared);
739        assert!(r.adjusted_r_squared > 0.98);
740    }
741
742    // -----------------------------------------------------------------------
743    // Multiple regression tests
744    // -----------------------------------------------------------------------
745
746    #[test]
747    fn multiple_perfect_fit() {
748        // y = 1 + 2*x1 + 3*x2
749        let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
750        let x2 = [2.0, 1.0, 3.0, 2.0, 4.0, 3.0, 5.0, 4.0, 6.0, 5.0];
751        let y: Vec<f64> = x1
752            .iter()
753            .zip(x2.iter())
754            .map(|(&a, &b)| 1.0 + 2.0 * a + 3.0 * b)
755            .collect();
756        let r = multiple_linear_regression(&[&x1, &x2], &y).expect("should compute");
757
758        assert!(
759            (r.coefficients[0] - 1.0).abs() < 1e-8,
760            "β₀ = {}",
761            r.coefficients[0]
762        );
763        assert!(
764            (r.coefficients[1] - 2.0).abs() < 1e-8,
765            "β₁ = {}",
766            r.coefficients[1]
767        );
768        assert!(
769            (r.coefficients[2] - 3.0).abs() < 1e-8,
770            "β₂ = {}",
771            r.coefficients[2]
772        );
773        assert!((r.r_squared - 1.0).abs() < 1e-8);
774    }
775
776    #[test]
777    fn multiple_with_noise() {
778        let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
779        let x2 = [2.0, 1.0, 3.0, 2.0, 4.0, 3.0, 5.0, 4.0];
780        let y = [5.1, 5.0, 9.2, 8.9, 13.1, 12.0, 17.2, 15.9];
781        let r = multiple_linear_regression(&[&x1, &x2], &y).expect("should compute");
782        assert!(r.r_squared > 0.95);
783        assert_eq!(r.coefficients.len(), 3);
784        assert_eq!(r.std_errors.len(), 3);
785        assert_eq!(r.residuals.len(), 8);
786        assert_eq!(r.vif.len(), 2);
787    }
788
789    #[test]
790    fn multiple_residuals_sum_to_zero() {
791        let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
792        let x2 = [2.0, 1.0, 3.0, 2.0, 4.0, 3.0, 5.0, 4.0];
793        let y = [5.1, 5.0, 9.2, 8.9, 13.1, 12.0, 17.2, 15.9];
794        let r = multiple_linear_regression(&[&x1, &x2], &y).expect("should compute");
795        let sum: f64 = r.residuals.iter().sum();
796        assert!(sum.abs() < 1e-8, "residuals sum = {sum}");
797    }
798
799    #[test]
800    fn multiple_single_predictor_matches_simple() {
801        let x = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
802        let y: Vec<f64> = x
803            .iter()
804            .map(|&xi| 3.0 + 2.5 * xi + 0.1 * (xi - 5.0))
805            .collect();
806
807        let simple = simple_linear_regression(&x, &y).expect("simple");
808        let multi = multiple_linear_regression(&[&x], &y).expect("multiple");
809
810        assert!(
811            (simple.slope - multi.coefficients[1]).abs() < 1e-8,
812            "slope: {} vs {}",
813            simple.slope,
814            multi.coefficients[1]
815        );
816        assert!(
817            (simple.intercept - multi.coefficients[0]).abs() < 1e-8,
818            "intercept: {} vs {}",
819            simple.intercept,
820            multi.coefficients[0]
821        );
822        assert!((simple.r_squared - multi.r_squared).abs() < 1e-8);
823    }
824
825    #[test]
826    fn multiple_edge_cases() {
827        let x1 = [1.0, 2.0];
828        let y = [3.0, 4.0];
829        assert!(multiple_linear_regression(&[&x1], &y).is_none()); // n < p+2
830
831        let x2 = [1.0, 2.0, 3.0];
832        let y2 = [4.0, 5.0];
833        assert!(multiple_linear_regression(&[&x2], &y2).is_none()); // length mismatch
834    }
835
836    #[test]
837    fn multiple_vif_independent_predictors() {
838        // Independent predictors → VIF ≈ 1
839        let x1 = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0];
840        let x2 = [0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
841        let y: Vec<f64> = x1
842            .iter()
843            .zip(x2.iter())
844            .map(|(&a, &b)| 1.0 + 2.0 * a + 3.0 * b)
845            .collect();
846        let r = multiple_linear_regression(&[&x1, &x2], &y).expect("should compute");
847        for (i, &v) in r.vif.iter().enumerate() {
848            assert!(
849                v < 2.0,
850                "VIF[{i}] = {v}, expected near 1.0 for independent predictors"
851            );
852        }
853    }
854
855    #[test]
856    fn multiple_vif_correlated_predictors() {
857        // Highly but not perfectly correlated predictors → higher VIF
858        let x1: Vec<f64> = (0..20).map(|i| i as f64).collect();
859        // Add small perturbation to break perfect collinearity
860        let noise = [
861            0.1, -0.2, 0.3, -0.1, 0.2, -0.3, 0.1, -0.1, 0.2, -0.2, 0.3, -0.1, 0.1, -0.2, 0.3, -0.3,
862            0.1, -0.1, 0.2, -0.2,
863        ];
864        let x2: Vec<f64> = x1
865            .iter()
866            .zip(noise.iter())
867            .map(|(&v, &n)| v * 0.9 + 1.0 + n)
868            .collect();
869        let y: Vec<f64> = x1
870            .iter()
871            .zip(x2.iter())
872            .map(|(&a, &b)| 1.0 + a + b)
873            .collect();
874        let r = multiple_linear_regression(&[&x1, &x2], &y).expect("should compute");
875        // VIF > 5 for highly correlated predictors
876        assert!(
877            r.vif[0] > 5.0,
878            "VIF[0] = {}, expected > 5.0 for correlated predictors",
879            r.vif[0]
880        );
881    }
882
883    // -----------------------------------------------------------------------
884    // Prediction tests
885    // -----------------------------------------------------------------------
886
887    #[test]
888    fn predict_simple_basic() {
889        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
890        let y = [3.0, 5.0, 7.0, 9.0, 11.0]; // y = 1 + 2x
891        let model = simple_linear_regression(&x, &y).expect("should compute");
892        let pred = predict_simple(&model, &[6.0, 7.0]);
893        assert!((pred[0] - 13.0).abs() < 1e-10);
894        assert!((pred[1] - 15.0).abs() < 1e-10);
895    }
896
897    #[test]
898    fn predict_multiple_basic() {
899        let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
900        let x2 = [2.0, 1.0, 3.0, 2.0, 4.0, 3.0, 5.0, 4.0, 6.0, 5.0];
901        let y: Vec<f64> = x1
902            .iter()
903            .zip(x2.iter())
904            .map(|(&a, &b)| 1.0 + 2.0 * a + 3.0 * b)
905            .collect();
906        let model = multiple_linear_regression(&[&x1, &x2], &y).expect("should compute");
907
908        let new_x1 = [11.0];
909        let new_x2 = [6.0];
910        let pred = predict_multiple(&model, &[&new_x1, &new_x2]).expect("should predict");
911        let expected = 1.0 + 2.0 * 11.0 + 3.0 * 6.0;
912        assert!(
913            (pred[0] - expected).abs() < 1e-6,
914            "pred = {}, expected = {}",
915            pred[0],
916            expected
917        );
918    }
919
920    // -----------------------------------------------------------------------
921    // Multicollinearity diagnostics — public vif() / condition_number()
922    // -----------------------------------------------------------------------
923
924    #[test]
925    fn vif_pub_independent_predictors() {
926        let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
927        let x2 = [8.0, 1.0, 6.0, 3.0, 5.0, 7.0, 2.0, 4.0];
928        let v = vif(&[&x1, &x2]).expect("should compute");
929        for r in &v {
930            assert!(*r < 2.0, "VIF should be near 1.0 for independent, got {r}");
931        }
932    }
933
934    #[test]
935    fn vif_pub_collinear_predictors() {
936        let x1: Vec<f64> = (0..20).map(|i| i as f64).collect();
937        let x2: Vec<f64> = x1.iter().map(|&v| v * 2.0 + 0.001).collect();
938        let v = vif(&[&x1[..], &x2[..]]).expect("should compute");
939        assert!(
940            v[0] > 50.0,
941            "VIF should explode under near-collinearity, got {}",
942            v[0]
943        );
944    }
945
946    #[test]
947    fn vif_pub_rejects_short_input() {
948        let x = [1.0, 2.0];
949        assert!(vif(&[&x[..], &x[..]]).is_none());
950    }
951
952    #[test]
953    fn vif_pub_rejects_non_finite() {
954        let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
955        let x2 = [1.0, 2.0, f64::NAN, 4.0, 5.0, 6.0];
956        assert!(vif(&[&x1[..], &x2[..]]).is_none());
957    }
958
959    #[test]
960    fn condition_number_orthogonal() {
961        let x1 = [1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0];
962        let x2 = [1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, -1.0];
963        let c = condition_number(&[&x1[..], &x2[..]]).expect("should compute");
964        assert!(c < 5.0, "near-orthogonal: cond should be small, got {c}");
965    }
966
967    #[test]
968    fn condition_number_collinear_explodes() {
969        let x1: Vec<f64> = (0..20).map(|i| i as f64).collect();
970        let x2: Vec<f64> = x1.iter().map(|&v| v * 2.0 + 1e-6).collect();
971        let c = condition_number(&[&x1[..], &x2[..]]).expect("should compute");
972        assert!(
973            c > 1e5 || c.is_infinite(),
974            "collinear: cond should be huge, got {c}"
975        );
976    }
977
978    #[test]
979    fn condition_number_rejects_short_input() {
980        let x = [1.0, 2.0];
981        assert!(condition_number(&[&x[..], &x[..]]).is_none());
982    }
983
984    #[test]
985    fn predict_multiple_wrong_predictors() {
986        let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
987        let x2 = [2.0, 1.0, 3.0, 2.0, 4.0, 3.0, 5.0, 4.0, 6.0, 5.0];
988        let y: Vec<f64> = x1.iter().zip(x2.iter()).map(|(&a, &b)| a + b).collect();
989        let model = multiple_linear_regression(&[&x1, &x2], &y).expect("should compute");
990
991        // Wrong number of predictors
992        assert!(predict_multiple(&model, &[&[1.0]]).is_none());
993    }
994}
995
996#[cfg(test)]
997mod proptests {
998    use super::*;
999    use proptest::prelude::*;
1000
1001    proptest! {
1002        #[test]
1003        fn simple_r_squared_bounded(
1004            data in proptest::collection::vec(-1e3_f64..1e3, 5..=30)
1005                .prop_flat_map(|x| {
1006                    let n = x.len();
1007                    (Just(x), proptest::collection::vec(-1e3_f64..1e3, n..=n))
1008                })
1009        ) {
1010            let (x, y) = data;
1011            if let Some(r) = simple_linear_regression(&x, &y) {
1012                prop_assert!(r.r_squared >= -0.01 && r.r_squared <= 1.01,
1013                    "R² = {}", r.r_squared);
1014            }
1015        }
1016
1017        #[test]
1018        fn simple_residuals_orthogonal_to_x(
1019            data in proptest::collection::vec(-1e3_f64..1e3, 5..=30)
1020                .prop_flat_map(|x| {
1021                    let n = x.len();
1022                    (Just(x), proptest::collection::vec(-1e3_f64..1e3, n..=n))
1023                })
1024        ) {
1025            let (x, y) = data;
1026            if let Some(r) = simple_linear_regression(&x, &y) {
1027                // Σ(xᵢ · eᵢ) should be near zero (OLS normal equation)
1028                let dot: f64 = x.iter().zip(r.residuals.iter()).map(|(&xi, &ei)| xi * ei).sum();
1029                let norm = r.residuals.iter().map(|e| e * e).sum::<f64>().sqrt();
1030                let x_norm = x.iter().map(|xi| xi * xi).sum::<f64>().sqrt();
1031                if norm > 1e-10 && x_norm > 1e-10 {
1032                    prop_assert!((dot / (norm * x_norm)).abs() < 1e-6,
1033                        "residuals not orthogonal to x: dot={dot}");
1034                }
1035            }
1036        }
1037
1038        #[test]
1039        fn multiple_r_squared_bounded(
1040            x1 in proptest::collection::vec(-1e3_f64..1e3, 8..=20),
1041            x2_seed in proptest::collection::vec(-1e3_f64..1e3, 8..=20),
1042            y_seed in proptest::collection::vec(-1e3_f64..1e3, 8..=20),
1043        ) {
1044            let n = x1.len().min(x2_seed.len()).min(y_seed.len());
1045            let x2 = &x2_seed[..n];
1046            let y = &y_seed[..n];
1047            let x1 = &x1[..n];
1048            if let Some(r) = multiple_linear_regression(&[x1, x2], y) {
1049                prop_assert!(r.r_squared >= -0.01 && r.r_squared <= 1.01,
1050                    "R² = {}", r.r_squared);
1051            }
1052        }
1053    }
1054}
1055
1056/// Slope, intercept and F p-values far in the tail against SciPy 1.17.1
1057/// (`scipy.stats.linregress`; the intercept's and the F test's from `t.sf` and
1058/// `f.sf`) -- the smallest is 8e-33.
1059#[cfg(test)]
1060mod scipy_tail_reference {
1061    use super::*;
1062
1063    fn close(got: f64, want: f64, what: &str) {
1064        assert!(
1065            (got - want).abs() <= 1e-9 * want.abs(),
1066            "{what}: got {got:e}, SciPy {want:e}"
1067        );
1068    }
1069
1070    #[test]
1071    fn simple_regression_p_values_keep_their_tail() {
1072        let x: Vec<f64> = (1..=20).map(f64::from).collect();
1073        let y = [
1074            5.3999999999999995,
1075            7.0,
1076            9.4,
1077            11.8,
1078            13.2,
1079            15.8,
1080            17.6,
1081            19.8,
1082            22.250000000000004,
1083            23.75,
1084            26.25,
1085            28.150000000000002,
1086            30.55,
1087            32.050000000000004,
1088            34.55,
1089            36.7,
1090            38.550000000000004,
1091            41.1,
1092            42.699999999999996,
1093            45.0,
1094        ];
1095        let r = simple_linear_regression(&x, &y).expect("valid");
1096        close(r.slope_p, 8.203356920391111e-33, "slope p");
1097        close(r.intercept_p, 1.5689270135401997e-16, "intercept p");
1098        close(r.f_p_value, 8.203356920397853e-33, "F p");
1099    }
1100}