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    // Normal equations on the design with each column scaled to unit length.
297    // The intercept column is all ones, so a predictor in a small unit (1e-9)
298    // makes X'X span eighteen orders of magnitude and the Cholesky factor
299    // refuses it; the scaled X'X is the correlation-like matrix of the same
300    // problem, whose conditioning does not depend on the units. With
301    // S = diag(1/‖column‖): β = S·β̃ and (X'X)⁻¹ = S·(X̃'X̃)⁻¹·S.
302    let mut col_scale = vec![0.0_f64; ncols];
303    for (j, c) in col_scale.iter_mut().enumerate() {
304        let norm = (0..n).map(|i| x_mat.get(i, j).powi(2)).sum::<f64>().sqrt();
305        if !norm.is_finite() || norm == 0.0 {
306            return None;
307        }
308        *c = 1.0 / norm;
309    }
310    let mut scaled_data = Vec::with_capacity(n * ncols);
311    for i in 0..n {
312        for (j, c) in col_scale.iter().enumerate() {
313            scaled_data.push(x_mat.get(i, j) * c);
314        }
315    }
316    let x_scaled = Matrix::new(n, ncols, scaled_data).ok()?;
317
318    // X̃'X̃ (ncols × ncols)
319    let xt = x_scaled.transpose();
320    let xtx = xt.mul_mat(&x_scaled).ok()?;
321
322    // X̃'y (ncols × 1)
323    let xty = xt.mul_vec(y).ok()?;
324
325    // Solve via Cholesky: β̃ = (X̃'X̃)⁻¹ X̃'y, then back to the original units
326    let coefficients: Vec<f64> = xtx
327        .cholesky_solve(&xty)
328        .ok()?
329        .iter()
330        .zip(&col_scale)
331        .map(|(b, c)| b * c)
332        .collect();
333
334    // Fitted values and residuals
335    let fitted = x_mat.mul_vec(&coefficients).ok()?;
336    let residuals: Vec<f64> = y
337        .iter()
338        .zip(fitted.iter())
339        .map(|(&yi, &fi)| yi - fi)
340        .collect();
341
342    // R² and adjusted R²
343    let y_mean = stats::mean(y)?;
344    let ss_res: f64 = residuals.iter().map(|r| r * r).sum();
345    let ss_tot: f64 = y.iter().map(|&yi| (yi - y_mean).powi(2)).sum();
346
347    let nf = n as f64;
348    let pf = p as f64;
349    let df_res = nf - pf - 1.0;
350
351    let r_squared = if ss_tot > 1e-300 {
352        1.0 - ss_res / ss_tot
353    } else {
354        1.0
355    };
356    let adjusted_r_squared = 1.0 - (1.0 - r_squared) * (nf - 1.0) / df_res;
357
358    // Residual standard error
359    let mse = ss_res / df_res;
360    let residual_se = mse.sqrt();
361
362    // Standard errors: SE(β) = sqrt(diag((X'X)⁻¹) · MSE)
363    let xtx_inv = xtx.inverse().ok()?;
364    let mut std_errors = Vec::with_capacity(ncols);
365    let mut t_statistics = Vec::with_capacity(ncols);
366    let mut p_values = Vec::with_capacity(ncols);
367
368    for (j, &coeff_j) in coefficients.iter().enumerate() {
369        let se = (xtx_inv.get(j, j) * col_scale[j] * col_scale[j] * mse).sqrt();
370        std_errors.push(se);
371        let t = if se > 1e-300 {
372            coeff_j / se
373        } else {
374            f64::INFINITY
375        };
376        t_statistics.push(t);
377        let pv = 2.0 * special::t_distribution_sf(t.abs(), df_res);
378        p_values.push(pv);
379    }
380
381    // F-statistic: (SS_reg / p) / (SS_res / (n-p-1))
382    let ss_reg = ss_tot - ss_res;
383    let f_statistic = if pf > 0.0 && mse > 1e-300 {
384        (ss_reg / pf) / mse
385    } else {
386        0.0
387    };
388    let f_p_value = if f_statistic.is_infinite() || f_statistic.is_nan() {
389        0.0
390    } else {
391        special::f_distribution_sf(f_statistic, pf, df_res)
392    };
393
394    // VIF for each predictor: VIF_j = 1/(1 - R²_j) where R²_j is from
395    // regressing x_j on all other predictors
396    let vif = compute_vif(predictors);
397
398    Some(MultipleRegressionResult {
399        coefficients,
400        std_errors,
401        t_statistics,
402        p_values,
403        r_squared,
404        adjusted_r_squared,
405        f_statistic,
406        f_p_value,
407        residual_se,
408        residuals,
409        fitted,
410        vif,
411        n,
412        p,
413    })
414}
415
416/// Computes VIF for each predictor by regressing each on all others.
417fn compute_vif(predictors: &[&[f64]]) -> Vec<f64> {
418    let p = predictors.len();
419    if p < 2 {
420        return vec![1.0; p];
421    }
422
423    let mut vif = Vec::with_capacity(p);
424    for j in 0..p {
425        // Regress x_j on all other predictors
426        let y_j = predictors[j];
427        let others: Vec<&[f64]> = predictors
428            .iter()
429            .enumerate()
430            .filter(|&(i, _)| i != j)
431            .map(|(_, v)| *v)
432            .collect();
433
434        if let Some(result) = multiple_linear_regression(&others, y_j) {
435            let r2 = result.r_squared;
436            if r2 < 1.0 - 1e-15 {
437                vif.push(1.0 / (1.0 - r2));
438            } else {
439                vif.push(f64::INFINITY); // perfect multicollinearity
440            }
441        } else {
442            vif.push(f64::NAN);
443        }
444    }
445    vif
446}
447
448/// Predicts y values given new x data and a simple regression result.
449///
450/// # Examples
451///
452/// ```
453/// use u_analytics::regression::{simple_linear_regression, predict_simple};
454///
455/// let x = [1.0, 2.0, 3.0, 4.0, 5.0];
456/// let y = [2.0, 4.0, 6.0, 8.0, 10.0];
457/// let model = simple_linear_regression(&x, &y).unwrap();
458/// let pred = predict_simple(&model, &[6.0, 7.0]);
459/// assert!((pred[0] - 12.0).abs() < 1e-10);
460/// assert!((pred[1] - 14.0).abs() < 1e-10);
461/// ```
462pub fn predict_simple(model: &SimpleRegressionResult, x_new: &[f64]) -> Vec<f64> {
463    x_new
464        .iter()
465        .map(|&xi| model.intercept + model.slope * xi)
466        .collect()
467}
468
469/// Predicts y values given new predictor data and a multiple regression result.
470///
471/// # Arguments
472///
473/// * `model` — Multiple regression result.
474/// * `predictors_new` — Slice of predictor slices (same order as training).
475///
476/// Returns `None` if predictor count doesn't match or lengths differ.
477pub fn predict_multiple(
478    model: &MultipleRegressionResult,
479    predictors_new: &[&[f64]],
480) -> Option<Vec<f64>> {
481    if predictors_new.len() != model.p {
482        return None;
483    }
484    let n = predictors_new.first()?.len();
485    for pred in predictors_new {
486        if pred.len() != n {
487            return None;
488        }
489    }
490
491    let mut result = Vec::with_capacity(n);
492    for i in 0..n {
493        let mut yi = model.coefficients[0]; // intercept
494        for (j, pred) in predictors_new.iter().enumerate() {
495            yi += model.coefficients[j + 1] * pred[i];
496        }
497        result.push(yi);
498    }
499    Some(result)
500}
501
502// ---------------------------------------------------------------------------
503// Multicollinearity diagnostics (public)
504// ---------------------------------------------------------------------------
505
506/// Computes the Variance Inflation Factor for each predictor.
507///
508/// `VIF_j = 1 / (1 - R²_j)`, where `R²_j` is the coefficient of determination
509/// from regressing predictor `j` on all other predictors. Standard thresholds
510/// are `VIF > 5` (moderate multicollinearity) and `VIF > 10` (severe).
511///
512/// # Returns
513///
514/// `None` if `predictors` is empty, lengths differ, fewer than `p+1`
515/// observations, or any predictor contains a non-finite value. With a single
516/// predictor, returns `Some(vec![1.0])` since VIF is undefined for one
517/// regressor.
518///
519/// # References
520///
521/// Marquardt (1970). "Generalized inverses, ridge regression."
522/// Belsley, Kuh, Welsch (1980). *Regression Diagnostics*.
523///
524/// # Examples
525///
526/// ```
527/// use u_analytics::regression::vif;
528///
529/// let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
530/// let x2 = [8.0, 1.0, 6.0, 3.0, 5.0, 7.0, 2.0, 4.0];
531/// let v = vif(&[&x1, &x2]).unwrap();
532/// assert!(v[0] < 2.0); // independent predictors
533/// ```
534pub fn vif(predictors: &[&[f64]]) -> Option<Vec<f64>> {
535    let p = predictors.len();
536    if p == 0 {
537        return None;
538    }
539    let n = predictors[0].len();
540    if !predictors.iter().all(|c| c.len() == n) {
541        return None;
542    }
543    if n <= p {
544        return None;
545    }
546    if predictors.iter().any(|c| c.iter().any(|v| !v.is_finite())) {
547        return None;
548    }
549    Some(compute_vif(predictors))
550}
551
552/// Computes the 2-norm condition number of the sample covariance matrix.
553///
554/// `cond(Σ) = λ_max / λ_min`, where `λ` are the eigenvalues of the unbiased
555/// sample covariance matrix of the (column-major) predictors. Standard
556/// threshold: `cond > 30` indicates multicollinearity worth investigating;
557/// `cond > 100` is severe (Belsley 1991).
558///
559/// # Returns
560///
561/// `None` for empty input, length mismatch, fewer than `p+1` observations,
562/// non-finite values, or eigenvalue decomposition failure. Returns
563/// `f64::INFINITY` when the smallest eigenvalue is at most
564/// `p * f64::EPSILON` times the largest (numerically singular).
565///
566/// # References
567///
568/// Belsley (1991). *Conditioning Diagnostics*.
569///
570/// # Examples
571///
572/// ```
573/// use u_analytics::regression::condition_number;
574///
575/// let x1 = [1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0];
576/// let x2 = [1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, -1.0];
577/// let c = condition_number(&[&x1, &x2]).unwrap();
578/// assert!(c < 5.0); // near-orthogonal columns: cond should be small
579/// ```
580pub fn condition_number(predictors: &[&[f64]]) -> Option<f64> {
581    use u_numflow::matrix::Matrix;
582    let p = predictors.len();
583    if p == 0 {
584        return None;
585    }
586    let n = predictors[0].len();
587    if !predictors.iter().all(|c| c.len() == n) {
588        return None;
589    }
590    if n <= p {
591        return None;
592    }
593    if predictors.iter().any(|c| c.iter().any(|v| !v.is_finite())) {
594        return None;
595    }
596
597    // Sample covariance matrix (unbiased, denominator n-1)
598    let mut means = vec![0.0_f64; p];
599    for (j, col) in predictors.iter().enumerate() {
600        means[j] = col.iter().sum::<f64>() / n as f64;
601    }
602    let mut cov = vec![0.0_f64; p * p];
603    let denom = (n - 1) as f64;
604    for r in 0..p {
605        let col_r = predictors[r];
606        let mean_r = means[r];
607        for c in r..p {
608            let col_c = predictors[c];
609            let mean_c = means[c];
610            let sum: f64 = col_r
611                .iter()
612                .zip(col_c.iter())
613                .map(|(&a, &b)| (a - mean_r) * (b - mean_c))
614                .sum();
615            let v = sum / denom;
616            cov[r * p + c] = v;
617            cov[c * p + r] = v;
618        }
619    }
620
621    let mat = Matrix::new(p, p, cov).ok()?;
622    let (eigenvalues, _eigenvectors) = mat.eigen_symmetric().ok()?;
623    let lambda_max = eigenvalues
624        .iter()
625        .copied()
626        .fold(f64::NEG_INFINITY, f64::max);
627    let lambda_min = eigenvalues.iter().copied().fold(f64::INFINITY, f64::min);
628
629    if !lambda_max.is_finite() || lambda_max <= 0.0 {
630        return None;
631    }
632    // Numerically singular is judged against the matrix's own scale (the
633    // tolerance NumPy's `matrix_rank` uses), not against a fixed number: the
634    // same data in a smaller unit has a covariance 1e-18 as large and is no
635    // less well conditioned.
636    if lambda_min <= lambda_max * p as f64 * f64::EPSILON {
637        return Some(f64::INFINITY);
638    }
639    Some(lambda_max / lambda_min)
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    // -----------------------------------------------------------------------
647    // Simple regression tests
648    // -----------------------------------------------------------------------
649
650    #[test]
651    fn simple_perfect_fit() {
652        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
653        let y = [3.0, 5.0, 7.0, 9.0, 11.0]; // y = 1 + 2x
654        let r = simple_linear_regression(&x, &y).expect("should compute");
655        assert!((r.slope - 2.0).abs() < 1e-10);
656        assert!((r.intercept - 1.0).abs() < 1e-10);
657        assert!((r.r_squared - 1.0).abs() < 1e-10);
658    }
659
660    #[test]
661    fn simple_with_noise() {
662        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
663        let y = [2.1, 3.9, 6.1, 7.9, 10.1]; // y ≈ 2x + 0.1
664        let r = simple_linear_regression(&x, &y).expect("should compute");
665        assert!((r.slope - 2.0).abs() < 0.1);
666        assert!(r.r_squared > 0.99);
667        assert_eq!(r.residuals.len(), 5);
668        assert_eq!(r.fitted.len(), 5);
669    }
670
671    #[test]
672    fn simple_residuals_sum_to_zero() {
673        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
674        let y = [2.1, 3.9, 6.1, 7.9, 10.1];
675        let r = simple_linear_regression(&x, &y).expect("should compute");
676        let sum: f64 = r.residuals.iter().sum();
677        assert!(sum.abs() < 1e-10, "residuals sum = {sum}");
678    }
679
680    #[test]
681    fn simple_f_equals_t_squared() {
682        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
683        let y = [2.1, 3.9, 6.1, 7.9, 10.1];
684        let r = simple_linear_regression(&x, &y).expect("should compute");
685        assert!(
686            (r.f_statistic - r.slope_t * r.slope_t).abs() < 1e-8,
687            "F = {}, t² = {}",
688            r.f_statistic,
689            r.slope_t * r.slope_t
690        );
691    }
692
693    #[test]
694    fn simple_significant_slope() {
695        let x: Vec<f64> = (0..20).map(|i| i as f64).collect();
696        let y: Vec<f64> = x.iter().map(|&xi| 3.0 + 2.0 * xi).collect();
697        let r = simple_linear_regression(&x, &y).expect("should compute");
698        assert!(r.slope_p < 1e-10, "slope p = {}", r.slope_p);
699        assert!(r.f_p_value < 1e-10, "F p = {}", r.f_p_value);
700    }
701
702    #[test]
703    fn simple_negative_slope() {
704        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
705        let y = [10.0, 8.0, 6.0, 4.0, 2.0]; // y = 12 - 2x
706        let r = simple_linear_regression(&x, &y).expect("should compute");
707        assert!((r.slope + 2.0).abs() < 1e-10);
708        assert!((r.intercept - 12.0).abs() < 1e-10);
709    }
710
711    #[test]
712    fn simple_edge_cases() {
713        assert!(simple_linear_regression(&[1.0, 2.0], &[3.0, 4.0]).is_none()); // n < 3
714        assert!(simple_linear_regression(&[1.0, 2.0, 3.0], &[4.0, 5.0]).is_none()); // mismatch
715        assert!(simple_linear_regression(&[5.0, 5.0, 5.0], &[1.0, 2.0, 3.0]).is_none()); // zero var
716        assert!(simple_linear_regression(&[1.0, f64::NAN, 3.0], &[4.0, 5.0, 6.0]).is_none());
717    }
718
719    /// Verifies exact OLS numeric reference from the spec.
720    ///
721    /// x = [1,2,3,4,5], y = [2,4,5,4,5]
722    /// x̄=3, ȳ=4
723    /// Σ(xᵢ-x̄)(yᵢ-ȳ) = 6, Σ(xᵢ-x̄)² = 10
724    /// β̂₁ = 6/10 = 0.6, β̂₀ = 4 - 0.6·3 = 2.2
725    /// SS_tot = 6, SS_res = 2.4, R² = 1 - 2.4/6 = 0.6
726    ///
727    /// Reference: Draper & Smith (1998), Applied Regression Analysis, 3rd ed.
728    #[test]
729    fn simple_numeric_reference_ols() {
730        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
731        let y = [2.0, 4.0, 5.0, 4.0, 5.0];
732        let r = simple_linear_regression(&x, &y).expect("should compute");
733
734        assert!(
735            (r.slope - 0.6).abs() < 1e-10,
736            "β̂₁ expected 0.6, got {}",
737            r.slope
738        );
739        assert!(
740            (r.intercept - 2.2).abs() < 1e-10,
741            "β̂₀ expected 2.2, got {}",
742            r.intercept
743        );
744        assert!(
745            (r.r_squared - 0.6).abs() < 1e-3,
746            "R² expected 0.6, got {}",
747            r.r_squared
748        );
749
750        // Verify fitted values
751        // ŷ₁=2.8, ŷ₂=3.4, ŷ₃=4.0, ŷ₄=4.6, ŷ₅=5.2
752        let expected_fitted = [2.8, 3.4, 4.0, 4.6, 5.2];
753        for (i, (&fi, &ef)) in r.fitted.iter().zip(expected_fitted.iter()).enumerate() {
754            assert!(
755                (fi - ef).abs() < 1e-10,
756                "ŷ_{} expected {}, got {}",
757                i + 1,
758                ef,
759                fi
760            );
761        }
762    }
763
764    #[test]
765    fn simple_adjusted_r_squared() {
766        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
767        let y = [2.1, 3.9, 6.1, 7.9, 10.1];
768        let r = simple_linear_regression(&x, &y).expect("should compute");
769        // For perfect fit, adjusted R² ≈ R²
770        assert!(r.adjusted_r_squared <= r.r_squared);
771        assert!(r.adjusted_r_squared > 0.98);
772    }
773
774    // -----------------------------------------------------------------------
775    // Multiple regression tests
776    // -----------------------------------------------------------------------
777
778    #[test]
779    fn multiple_perfect_fit() {
780        // y = 1 + 2*x1 + 3*x2
781        let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
782        let x2 = [2.0, 1.0, 3.0, 2.0, 4.0, 3.0, 5.0, 4.0, 6.0, 5.0];
783        let y: Vec<f64> = x1
784            .iter()
785            .zip(x2.iter())
786            .map(|(&a, &b)| 1.0 + 2.0 * a + 3.0 * b)
787            .collect();
788        let r = multiple_linear_regression(&[&x1, &x2], &y).expect("should compute");
789
790        assert!(
791            (r.coefficients[0] - 1.0).abs() < 1e-8,
792            "β₀ = {}",
793            r.coefficients[0]
794        );
795        assert!(
796            (r.coefficients[1] - 2.0).abs() < 1e-8,
797            "β₁ = {}",
798            r.coefficients[1]
799        );
800        assert!(
801            (r.coefficients[2] - 3.0).abs() < 1e-8,
802            "β₂ = {}",
803            r.coefficients[2]
804        );
805        assert!((r.r_squared - 1.0).abs() < 1e-8);
806    }
807
808    #[test]
809    fn multiple_with_noise() {
810        let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
811        let x2 = [2.0, 1.0, 3.0, 2.0, 4.0, 3.0, 5.0, 4.0];
812        let y = [5.1, 5.0, 9.2, 8.9, 13.1, 12.0, 17.2, 15.9];
813        let r = multiple_linear_regression(&[&x1, &x2], &y).expect("should compute");
814        assert!(r.r_squared > 0.95);
815        assert_eq!(r.coefficients.len(), 3);
816        assert_eq!(r.std_errors.len(), 3);
817        assert_eq!(r.residuals.len(), 8);
818        assert_eq!(r.vif.len(), 2);
819    }
820
821    #[test]
822    fn multiple_residuals_sum_to_zero() {
823        let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
824        let x2 = [2.0, 1.0, 3.0, 2.0, 4.0, 3.0, 5.0, 4.0];
825        let y = [5.1, 5.0, 9.2, 8.9, 13.1, 12.0, 17.2, 15.9];
826        let r = multiple_linear_regression(&[&x1, &x2], &y).expect("should compute");
827        let sum: f64 = r.residuals.iter().sum();
828        assert!(sum.abs() < 1e-8, "residuals sum = {sum}");
829    }
830
831    #[test]
832    fn multiple_single_predictor_matches_simple() {
833        let x = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
834        let y: Vec<f64> = x
835            .iter()
836            .map(|&xi| 3.0 + 2.5 * xi + 0.1 * (xi - 5.0))
837            .collect();
838
839        let simple = simple_linear_regression(&x, &y).expect("simple");
840        let multi = multiple_linear_regression(&[&x], &y).expect("multiple");
841
842        assert!(
843            (simple.slope - multi.coefficients[1]).abs() < 1e-8,
844            "slope: {} vs {}",
845            simple.slope,
846            multi.coefficients[1]
847        );
848        assert!(
849            (simple.intercept - multi.coefficients[0]).abs() < 1e-8,
850            "intercept: {} vs {}",
851            simple.intercept,
852            multi.coefficients[0]
853        );
854        assert!((simple.r_squared - multi.r_squared).abs() < 1e-8);
855    }
856
857    #[test]
858    fn multiple_edge_cases() {
859        let x1 = [1.0, 2.0];
860        let y = [3.0, 4.0];
861        assert!(multiple_linear_regression(&[&x1], &y).is_none()); // n < p+2
862
863        let x2 = [1.0, 2.0, 3.0];
864        let y2 = [4.0, 5.0];
865        assert!(multiple_linear_regression(&[&x2], &y2).is_none()); // length mismatch
866    }
867
868    #[test]
869    fn multiple_vif_independent_predictors() {
870        // Independent predictors → VIF ≈ 1
871        let x1 = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0];
872        let x2 = [0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
873        let y: Vec<f64> = x1
874            .iter()
875            .zip(x2.iter())
876            .map(|(&a, &b)| 1.0 + 2.0 * a + 3.0 * b)
877            .collect();
878        let r = multiple_linear_regression(&[&x1, &x2], &y).expect("should compute");
879        for (i, &v) in r.vif.iter().enumerate() {
880            assert!(
881                v < 2.0,
882                "VIF[{i}] = {v}, expected near 1.0 for independent predictors"
883            );
884        }
885    }
886
887    #[test]
888    fn multiple_vif_correlated_predictors() {
889        // Highly but not perfectly correlated predictors → higher VIF
890        let x1: Vec<f64> = (0..20).map(|i| i as f64).collect();
891        // Add small perturbation to break perfect collinearity
892        let noise = [
893            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,
894            0.1, -0.1, 0.2, -0.2,
895        ];
896        let x2: Vec<f64> = x1
897            .iter()
898            .zip(noise.iter())
899            .map(|(&v, &n)| v * 0.9 + 1.0 + n)
900            .collect();
901        let y: Vec<f64> = x1
902            .iter()
903            .zip(x2.iter())
904            .map(|(&a, &b)| 1.0 + a + b)
905            .collect();
906        let r = multiple_linear_regression(&[&x1, &x2], &y).expect("should compute");
907        // VIF > 5 for highly correlated predictors
908        assert!(
909            r.vif[0] > 5.0,
910            "VIF[0] = {}, expected > 5.0 for correlated predictors",
911            r.vif[0]
912        );
913    }
914
915    // -----------------------------------------------------------------------
916    // Prediction tests
917    // -----------------------------------------------------------------------
918
919    #[test]
920    fn predict_simple_basic() {
921        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
922        let y = [3.0, 5.0, 7.0, 9.0, 11.0]; // y = 1 + 2x
923        let model = simple_linear_regression(&x, &y).expect("should compute");
924        let pred = predict_simple(&model, &[6.0, 7.0]);
925        assert!((pred[0] - 13.0).abs() < 1e-10);
926        assert!((pred[1] - 15.0).abs() < 1e-10);
927    }
928
929    #[test]
930    fn predict_multiple_basic() {
931        let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
932        let x2 = [2.0, 1.0, 3.0, 2.0, 4.0, 3.0, 5.0, 4.0, 6.0, 5.0];
933        let y: Vec<f64> = x1
934            .iter()
935            .zip(x2.iter())
936            .map(|(&a, &b)| 1.0 + 2.0 * a + 3.0 * b)
937            .collect();
938        let model = multiple_linear_regression(&[&x1, &x2], &y).expect("should compute");
939
940        let new_x1 = [11.0];
941        let new_x2 = [6.0];
942        let pred = predict_multiple(&model, &[&new_x1, &new_x2]).expect("should predict");
943        let expected = 1.0 + 2.0 * 11.0 + 3.0 * 6.0;
944        assert!(
945            (pred[0] - expected).abs() < 1e-6,
946            "pred = {}, expected = {}",
947            pred[0],
948            expected
949        );
950    }
951
952    // -----------------------------------------------------------------------
953    // Multicollinearity diagnostics — public vif() / condition_number()
954    // -----------------------------------------------------------------------
955
956    #[test]
957    fn vif_pub_independent_predictors() {
958        let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
959        let x2 = [8.0, 1.0, 6.0, 3.0, 5.0, 7.0, 2.0, 4.0];
960        let v = vif(&[&x1, &x2]).expect("should compute");
961        for r in &v {
962            assert!(*r < 2.0, "VIF should be near 1.0 for independent, got {r}");
963        }
964    }
965
966    #[test]
967    fn vif_pub_collinear_predictors() {
968        let x1: Vec<f64> = (0..20).map(|i| i as f64).collect();
969        let x2: Vec<f64> = x1.iter().map(|&v| v * 2.0 + 0.001).collect();
970        let v = vif(&[&x1[..], &x2[..]]).expect("should compute");
971        assert!(
972            v[0] > 50.0,
973            "VIF should explode under near-collinearity, got {}",
974            v[0]
975        );
976    }
977
978    #[test]
979    fn vif_pub_rejects_short_input() {
980        let x = [1.0, 2.0];
981        assert!(vif(&[&x[..], &x[..]]).is_none());
982    }
983
984    #[test]
985    fn vif_pub_rejects_non_finite() {
986        let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
987        let x2 = [1.0, 2.0, f64::NAN, 4.0, 5.0, 6.0];
988        assert!(vif(&[&x1[..], &x2[..]]).is_none());
989    }
990
991    #[test]
992    fn condition_number_orthogonal() {
993        let x1 = [1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0];
994        let x2 = [1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, -1.0];
995        let c = condition_number(&[&x1[..], &x2[..]]).expect("should compute");
996        assert!(c < 5.0, "near-orthogonal: cond should be small, got {c}");
997    }
998
999    #[test]
1000    fn condition_number_collinear_explodes() {
1001        let x1: Vec<f64> = (0..20).map(|i| i as f64).collect();
1002        let x2: Vec<f64> = x1.iter().map(|&v| v * 2.0 + 1e-6).collect();
1003        let c = condition_number(&[&x1[..], &x2[..]]).expect("should compute");
1004        assert!(
1005            c > 1e5 || c.is_infinite(),
1006            "collinear: cond should be huge, got {c}"
1007        );
1008    }
1009
1010    #[test]
1011    fn condition_number_rejects_short_input() {
1012        let x = [1.0, 2.0];
1013        assert!(condition_number(&[&x[..], &x[..]]).is_none());
1014    }
1015
1016    #[test]
1017    fn predict_multiple_wrong_predictors() {
1018        let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
1019        let x2 = [2.0, 1.0, 3.0, 2.0, 4.0, 3.0, 5.0, 4.0, 6.0, 5.0];
1020        let y: Vec<f64> = x1.iter().zip(x2.iter()).map(|(&a, &b)| a + b).collect();
1021        let model = multiple_linear_regression(&[&x1, &x2], &y).expect("should compute");
1022
1023        // Wrong number of predictors
1024        assert!(predict_multiple(&model, &[&[1.0]]).is_none());
1025    }
1026}
1027
1028#[cfg(test)]
1029mod proptests {
1030    use super::*;
1031    use proptest::prelude::*;
1032
1033    proptest! {
1034        #[test]
1035        fn simple_r_squared_bounded(
1036            data in proptest::collection::vec(-1e3_f64..1e3, 5..=30)
1037                .prop_flat_map(|x| {
1038                    let n = x.len();
1039                    (Just(x), proptest::collection::vec(-1e3_f64..1e3, n..=n))
1040                })
1041        ) {
1042            let (x, y) = data;
1043            if let Some(r) = simple_linear_regression(&x, &y) {
1044                prop_assert!(r.r_squared >= -0.01 && r.r_squared <= 1.01,
1045                    "R² = {}", r.r_squared);
1046            }
1047        }
1048
1049        #[test]
1050        fn simple_residuals_orthogonal_to_x(
1051            data in proptest::collection::vec(-1e3_f64..1e3, 5..=30)
1052                .prop_flat_map(|x| {
1053                    let n = x.len();
1054                    (Just(x), proptest::collection::vec(-1e3_f64..1e3, n..=n))
1055                })
1056        ) {
1057            let (x, y) = data;
1058            if let Some(r) = simple_linear_regression(&x, &y) {
1059                // Σ(xᵢ · eᵢ) should be near zero (OLS normal equation)
1060                let dot: f64 = x.iter().zip(r.residuals.iter()).map(|(&xi, &ei)| xi * ei).sum();
1061                let norm = r.residuals.iter().map(|e| e * e).sum::<f64>().sqrt();
1062                let x_norm = x.iter().map(|xi| xi * xi).sum::<f64>().sqrt();
1063                if norm > 1e-10 && x_norm > 1e-10 {
1064                    prop_assert!((dot / (norm * x_norm)).abs() < 1e-6,
1065                        "residuals not orthogonal to x: dot={dot}");
1066                }
1067            }
1068        }
1069
1070        #[test]
1071        fn multiple_r_squared_bounded(
1072            x1 in proptest::collection::vec(-1e3_f64..1e3, 8..=20),
1073            x2_seed in proptest::collection::vec(-1e3_f64..1e3, 8..=20),
1074            y_seed in proptest::collection::vec(-1e3_f64..1e3, 8..=20),
1075        ) {
1076            let n = x1.len().min(x2_seed.len()).min(y_seed.len());
1077            let x2 = &x2_seed[..n];
1078            let y = &y_seed[..n];
1079            let x1 = &x1[..n];
1080            if let Some(r) = multiple_linear_regression(&[x1, x2], y) {
1081                prop_assert!(r.r_squared >= -0.01 && r.r_squared <= 1.01,
1082                    "R² = {}", r.r_squared);
1083            }
1084        }
1085    }
1086}
1087
1088/// Slope, intercept and F p-values far in the tail against SciPy 1.17.1
1089/// (`scipy.stats.linregress`; the intercept's and the F test's from `t.sf` and
1090/// `f.sf`) -- the smallest is 8e-33.
1091#[cfg(test)]
1092mod scipy_tail_reference {
1093    use super::*;
1094
1095    fn close(got: f64, want: f64, what: &str) {
1096        assert!(
1097            (got - want).abs() <= 1e-9 * want.abs(),
1098            "{what}: got {got:e}, SciPy {want:e}"
1099        );
1100    }
1101
1102    #[test]
1103    fn simple_regression_p_values_keep_their_tail() {
1104        let x: Vec<f64> = (1..=20).map(f64::from).collect();
1105        let y = [
1106            5.3999999999999995,
1107            7.0,
1108            9.4,
1109            11.8,
1110            13.2,
1111            15.8,
1112            17.6,
1113            19.8,
1114            22.250000000000004,
1115            23.75,
1116            26.25,
1117            28.150000000000002,
1118            30.55,
1119            32.050000000000004,
1120            34.55,
1121            36.7,
1122            38.550000000000004,
1123            41.1,
1124            42.699999999999996,
1125            45.0,
1126        ];
1127        let r = simple_linear_regression(&x, &y).expect("valid");
1128        close(r.slope_p, 8.203356920391111e-33, "slope p");
1129        close(r.intercept_p, 1.5689270135401997e-16, "intercept p");
1130        close(r.f_p_value, 8.203356920397853e-33, "F p");
1131    }
1132    /// A fit and a condition number are properties of the data, not of the unit
1133    /// it is written in: the predictors in a unit a billion times smaller give
1134    /// the same R², p-values and conditioning, and coefficients that scale
1135    /// with the unit.
1136    #[test]
1137    fn multiple_regression_and_conditioning_do_not_depend_on_the_unit() {
1138        let n = 24;
1139        let x1: Vec<f64> = (0..n)
1140            .map(|i| (i as f64 * 0.7).sin() * 3.0 + i as f64 * 0.1)
1141            .collect();
1142        let x2: Vec<f64> = (0..n)
1143            .map(|i| (i as f64 * 1.3).cos() + 0.5 * x1[i])
1144            .collect();
1145        let y: Vec<f64> = (0..n)
1146            .map(|i| 1.0 + 2.0 * x1[i] - 0.5 * x2[i] + (i as f64 * 2.1).sin() * 0.3)
1147            .collect();
1148        let base = multiple_linear_regression(&[&x1, &x2], &y).expect("valid");
1149        let cond = condition_number(&[&x1, &x2]).expect("valid");
1150        for factor in [1e-9, 1e6] {
1151            let s1: Vec<f64> = x1.iter().map(|v| v * factor).collect();
1152            let s2: Vec<f64> = x2.iter().map(|v| v * factor).collect();
1153            let r = multiple_linear_regression(&[&s1, &s2], &y)
1154                .unwrap_or_else(|| panic!("a fit in units of {factor:e} was refused"));
1155            close(r.r_squared, base.r_squared, "R2");
1156            close(r.f_p_value, base.f_p_value, "F p");
1157            close(r.coefficients[0], base.coefficients[0], "intercept");
1158            for j in 0..2 {
1159                close(
1160                    r.coefficients[j + 1] * factor,
1161                    base.coefficients[j + 1],
1162                    "slope",
1163                );
1164                close(r.p_values[j + 1], base.p_values[j + 1], "slope p");
1165                close(r.vif[j], base.vif[j], "vif");
1166            }
1167            close(
1168                condition_number(&[&s1, &s2]).expect("valid"),
1169                cond,
1170                "condition number",
1171            );
1172        }
1173    }
1174}