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    /// Covariance of the intercept and slope estimates, −x̄ · MSE / Sxx.
57    /// With `intercept_se` and `slope_se` it gives the variance of the fitted
58    /// mean at any x (see [`predict_simple_intervals`]).
59    pub intercept_slope_covariance: f64,
60    /// Sample size.
61    pub n: usize,
62}
63
64/// Result of a multiple linear regression: y = Xβ + ε.
65#[derive(Debug, Clone)]
66pub struct MultipleRegressionResult {
67    /// Coefficient vector [β₀, β₁, ..., βₚ] (intercept first).
68    pub coefficients: Vec<f64>,
69    /// Standard errors of coefficients.
70    pub std_errors: Vec<f64>,
71    /// t-statistics for each coefficient.
72    pub t_statistics: Vec<f64>,
73    /// p-values for each coefficient.
74    pub p_values: Vec<f64>,
75    /// Coefficient of determination (R²).
76    pub r_squared: f64,
77    /// Adjusted R² = 1 - (1-R²)(n-1)/(n-p-1).
78    pub adjusted_r_squared: f64,
79    /// F-statistic for overall significance.
80    pub f_statistic: f64,
81    /// p-value for F-statistic.
82    pub f_p_value: f64,
83    /// Residual standard error.
84    pub residual_se: f64,
85    /// Residuals.
86    pub residuals: Vec<f64>,
87    /// Fitted values.
88    pub fitted: Vec<f64>,
89    /// VIF (Variance Inflation Factor) for each predictor (excludes intercept).
90    pub vif: Vec<f64>,
91    /// Covariance matrix of the coefficients, MSE · (X'X)⁻¹, row-major
92    /// `(p+1) × (p+1)` in the order of `coefficients` (intercept first) --
93    /// R's `vcov`. Its diagonal is the square of `std_errors`.
94    pub covariance: Vec<f64>,
95    /// Sample size.
96    pub n: usize,
97    /// Number of predictors (excluding intercept).
98    pub p: usize,
99}
100
101// ---------------------------------------------------------------------------
102// Simple Linear Regression
103// ---------------------------------------------------------------------------
104
105/// Computes simple linear regression (OLS closed-form).
106///
107/// # Algorithm
108///
109/// β₁ = cov(x,y) / var(x)
110/// β₀ = ȳ - β₁·x̄
111///
112/// # Returns
113///
114/// `None` if fewer than 3 observations, slices differ in length, x has zero
115/// variance, or inputs contain non-finite values.
116///
117/// # References
118///
119/// Draper & Smith (1998). "Applied Regression Analysis", 3rd edition.
120///
121/// # Examples
122///
123/// ```
124/// use u_analytics::regression::simple_linear_regression;
125///
126/// let x = [1.0, 2.0, 3.0, 4.0, 5.0];
127/// let y = [2.0, 4.0, 6.0, 8.0, 10.0];
128/// let r = simple_linear_regression(&x, &y).unwrap();
129/// assert!((r.slope - 2.0).abs() < 1e-10);
130/// assert!((r.intercept).abs() < 1e-10);
131/// assert!((r.r_squared - 1.0).abs() < 1e-10);
132/// ```
133pub fn simple_linear_regression(x: &[f64], y: &[f64]) -> Option<SimpleRegressionResult> {
134    let n = x.len();
135    if n < 3 || n != y.len() {
136        return None;
137    }
138    if x.iter().any(|v| !v.is_finite()) || y.iter().any(|v| !v.is_finite()) {
139        return None;
140    }
141
142    let x_mean = stats::mean(x)?;
143    let y_mean = stats::mean(y)?;
144    let x_var = stats::variance(x)?;
145    let cov = stats::covariance(x, y)?;
146
147    if x_var < 1e-300 {
148        return None; // zero variance in x
149    }
150
151    let slope = cov / x_var;
152    let intercept = y_mean - slope * x_mean;
153
154    // Fitted values and residuals
155    let fitted: Vec<f64> = x.iter().map(|&xi| intercept + slope * xi).collect();
156    let residuals: Vec<f64> = y
157        .iter()
158        .zip(fitted.iter())
159        .map(|(&yi, &fi)| yi - fi)
160        .collect();
161
162    // Sum of squares
163    let ss_res: f64 = residuals.iter().map(|r| r * r).sum();
164    let ss_tot: f64 = y.iter().map(|&yi| (yi - y_mean).powi(2)).sum();
165
166    let nf = n as f64;
167    let df_res = nf - 2.0;
168
169    let r_squared = if ss_tot > 1e-300 {
170        1.0 - ss_res / ss_tot
171    } else {
172        1.0
173    };
174    let adjusted_r_squared = 1.0 - (1.0 - r_squared) * (nf - 1.0) / df_res;
175
176    // Residual standard error
177    let mse = ss_res / df_res;
178    let residual_se = mse.sqrt();
179
180    // Standard errors of coefficients
181    let ss_x: f64 = x.iter().map(|&xi| (xi - x_mean).powi(2)).sum();
182    let slope_se = (mse / ss_x).sqrt();
183    let intercept_se = (mse * (1.0 / nf + x_mean * x_mean / ss_x)).sqrt();
184    let intercept_slope_covariance = -x_mean * mse / ss_x;
185
186    // t-statistics
187    let slope_t = if slope_se > 1e-300 {
188        slope / slope_se
189    } else {
190        f64::INFINITY
191    };
192    let intercept_t = if intercept_se > 1e-300 {
193        intercept / intercept_se
194    } else {
195        f64::INFINITY
196    };
197
198    // p-values via t-distribution
199    let slope_p = 2.0 * special::t_distribution_sf(slope_t.abs(), df_res);
200    let intercept_p = 2.0 * special::t_distribution_sf(intercept_t.abs(), df_res);
201
202    // F-statistic (= t² for simple regression)
203    let f_statistic = slope_t * slope_t;
204    let f_p_value = if f_statistic.is_infinite() {
205        0.0
206    } else {
207        special::f_distribution_sf(f_statistic, 1.0, df_res)
208    };
209
210    Some(SimpleRegressionResult {
211        slope,
212        intercept,
213        r_squared,
214        adjusted_r_squared,
215        slope_se,
216        intercept_se,
217        slope_t,
218        intercept_t,
219        slope_p,
220        intercept_p,
221        residual_se,
222        f_statistic,
223        f_p_value,
224        residuals,
225        fitted,
226        intercept_slope_covariance,
227        n,
228    })
229}
230
231// ---------------------------------------------------------------------------
232// Multiple Linear Regression
233// ---------------------------------------------------------------------------
234
235/// Computes multiple linear regression via OLS (Cholesky solve).
236///
237/// # Arguments
238///
239/// * `predictors` — Slice of predictor variable slices. Each inner slice is
240///   one predictor's observations. All must have the same length.
241/// * `y` — Response variable observations.
242///
243/// # Algorithm
244///
245/// Constructs the design matrix X = [1 | x₁ | x₂ | ... | xₚ] (intercept column prepended),
246/// then solves the normal equations X'Xβ = X'y via Cholesky decomposition.
247///
248/// # Returns
249///
250/// `None` if n < p+2, predictor lengths differ, inputs contain non-finite
251/// values, or the system is singular.
252///
253/// # References
254///
255/// Draper & Smith (1998). "Applied Regression Analysis", 3rd edition.
256/// Montgomery, Peck & Vining (2012). "Introduction to Linear Regression Analysis", 5th edition.
257///
258/// # Examples
259///
260/// ```
261/// use u_analytics::regression::multiple_linear_regression;
262///
263/// let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
264/// let x2 = [2.0, 1.0, 3.0, 2.0, 4.0, 3.0, 5.0, 4.0];
265/// let y  = [5.1, 5.0, 9.2, 8.9, 13.1, 12.0, 17.2, 15.9];
266/// let result = multiple_linear_regression(&[&x1, &x2], &y).unwrap();
267/// assert!(result.r_squared > 0.95);
268/// assert_eq!(result.coefficients.len(), 3); // intercept + 2 predictors
269/// ```
270pub fn multiple_linear_regression(
271    predictors: &[&[f64]],
272    y: &[f64],
273) -> Option<MultipleRegressionResult> {
274    let p = predictors.len(); // number of predictors
275    let n = y.len();
276
277    if p == 0 || n < p + 2 {
278        return None;
279    }
280
281    // Validate lengths and finite values
282    for pred in predictors {
283        if pred.len() != n {
284            return None;
285        }
286        if pred.iter().any(|v| !v.is_finite()) {
287            return None;
288        }
289    }
290    if y.iter().any(|v| !v.is_finite()) {
291        return None;
292    }
293
294    let ncols = p + 1; // intercept + predictors
295
296    // Build design matrix X (n × ncols, row-major)
297    let mut x_data = Vec::with_capacity(n * ncols);
298    for i in 0..n {
299        x_data.push(1.0); // intercept
300        for pred in predictors {
301            x_data.push(pred[i]);
302        }
303    }
304    let x_mat = Matrix::new(n, ncols, x_data).ok()?;
305
306    // Normal equations on the design with each column scaled to unit length.
307    // The intercept column is all ones, so a predictor in a small unit (1e-9)
308    // makes X'X span eighteen orders of magnitude and the Cholesky factor
309    // refuses it; the scaled X'X is the correlation-like matrix of the same
310    // problem, whose conditioning does not depend on the units. With
311    // S = diag(1/‖column‖): β = S·β̃ and (X'X)⁻¹ = S·(X̃'X̃)⁻¹·S.
312    let mut col_scale = vec![0.0_f64; ncols];
313    for (j, c) in col_scale.iter_mut().enumerate() {
314        let norm = (0..n).map(|i| x_mat.get(i, j).powi(2)).sum::<f64>().sqrt();
315        if !norm.is_finite() || norm == 0.0 {
316            return None;
317        }
318        *c = 1.0 / norm;
319    }
320    let mut scaled_data = Vec::with_capacity(n * ncols);
321    for i in 0..n {
322        for (j, c) in col_scale.iter().enumerate() {
323            scaled_data.push(x_mat.get(i, j) * c);
324        }
325    }
326    let x_scaled = Matrix::new(n, ncols, scaled_data).ok()?;
327
328    // X̃'X̃ (ncols × ncols)
329    let xt = x_scaled.transpose();
330    let xtx = xt.mul_mat(&x_scaled).ok()?;
331
332    // X̃'y (ncols × 1)
333    let xty = xt.mul_vec(y).ok()?;
334
335    // Solve via Cholesky: β̃ = (X̃'X̃)⁻¹ X̃'y, then back to the original units
336    let coefficients: Vec<f64> = xtx
337        .cholesky_solve(&xty)
338        .ok()?
339        .iter()
340        .zip(&col_scale)
341        .map(|(b, c)| b * c)
342        .collect();
343
344    // Fitted values and residuals
345    let fitted = x_mat.mul_vec(&coefficients).ok()?;
346    let residuals: Vec<f64> = y
347        .iter()
348        .zip(fitted.iter())
349        .map(|(&yi, &fi)| yi - fi)
350        .collect();
351
352    // R² and adjusted R²
353    let y_mean = stats::mean(y)?;
354    let ss_res: f64 = residuals.iter().map(|r| r * r).sum();
355    let ss_tot: f64 = y.iter().map(|&yi| (yi - y_mean).powi(2)).sum();
356
357    let nf = n as f64;
358    let pf = p as f64;
359    let df_res = nf - pf - 1.0;
360
361    let r_squared = if ss_tot > 1e-300 {
362        1.0 - ss_res / ss_tot
363    } else {
364        1.0
365    };
366    let adjusted_r_squared = 1.0 - (1.0 - r_squared) * (nf - 1.0) / df_res;
367
368    // Residual standard error
369    let mse = ss_res / df_res;
370    let residual_se = mse.sqrt();
371
372    // Standard errors: SE(β) = sqrt(diag((X'X)⁻¹) · MSE)
373    let xtx_inv = xtx.inverse().ok()?;
374    let mut covariance = Vec::with_capacity(ncols * ncols);
375    for i in 0..ncols {
376        for j in 0..ncols {
377            covariance.push(xtx_inv.get(i, j) * col_scale[i] * col_scale[j] * mse);
378        }
379    }
380    let mut std_errors = Vec::with_capacity(ncols);
381    let mut t_statistics = Vec::with_capacity(ncols);
382    let mut p_values = Vec::with_capacity(ncols);
383
384    for (j, &coeff_j) in coefficients.iter().enumerate() {
385        let se = covariance[j * ncols + j].sqrt();
386        std_errors.push(se);
387        let t = if se > 1e-300 {
388            coeff_j / se
389        } else {
390            f64::INFINITY
391        };
392        t_statistics.push(t);
393        let pv = 2.0 * special::t_distribution_sf(t.abs(), df_res);
394        p_values.push(pv);
395    }
396
397    // F-statistic: (SS_reg / p) / (SS_res / (n-p-1))
398    let ss_reg = ss_tot - ss_res;
399    let f_statistic = if pf > 0.0 && mse > 1e-300 {
400        (ss_reg / pf) / mse
401    } else {
402        0.0
403    };
404    let f_p_value = if f_statistic.is_infinite() || f_statistic.is_nan() {
405        0.0
406    } else {
407        special::f_distribution_sf(f_statistic, pf, df_res)
408    };
409
410    // VIF for each predictor: VIF_j = 1/(1 - R²_j) where R²_j is from
411    // regressing x_j on all other predictors
412    let vif = compute_vif(predictors);
413
414    Some(MultipleRegressionResult {
415        coefficients,
416        std_errors,
417        t_statistics,
418        p_values,
419        r_squared,
420        adjusted_r_squared,
421        f_statistic,
422        f_p_value,
423        residual_se,
424        residuals,
425        fitted,
426        vif,
427        covariance,
428        n,
429        p,
430    })
431}
432
433/// Computes VIF for each predictor by regressing each on all others.
434fn compute_vif(predictors: &[&[f64]]) -> Vec<f64> {
435    let p = predictors.len();
436    if p < 2 {
437        return vec![1.0; p];
438    }
439
440    let mut vif = Vec::with_capacity(p);
441    for j in 0..p {
442        // Regress x_j on all other predictors
443        let y_j = predictors[j];
444        let others: Vec<&[f64]> = predictors
445            .iter()
446            .enumerate()
447            .filter(|&(i, _)| i != j)
448            .map(|(_, v)| *v)
449            .collect();
450
451        if let Some(result) = multiple_linear_regression(&others, y_j) {
452            let r2 = result.r_squared;
453            if r2 < 1.0 - 1e-15 {
454                vif.push(1.0 / (1.0 - r2));
455            } else {
456                vif.push(f64::INFINITY); // perfect multicollinearity
457            }
458        } else {
459            vif.push(f64::NAN);
460        }
461    }
462    vif
463}
464
465/// Predicts y values given new x data and a simple regression result.
466///
467/// # Examples
468///
469/// ```
470/// use u_analytics::regression::{simple_linear_regression, predict_simple};
471///
472/// let x = [1.0, 2.0, 3.0, 4.0, 5.0];
473/// let y = [2.0, 4.0, 6.0, 8.0, 10.0];
474/// let model = simple_linear_regression(&x, &y).unwrap();
475/// let pred = predict_simple(&model, &[6.0, 7.0]);
476/// assert!((pred[0] - 12.0).abs() < 1e-10);
477/// assert!((pred[1] - 14.0).abs() < 1e-10);
478/// ```
479pub fn predict_simple(model: &SimpleRegressionResult, x_new: &[f64]) -> Vec<f64> {
480    x_new
481        .iter()
482        .map(|&xi| model.intercept + model.slope * xi)
483        .collect()
484}
485
486/// Predicts y values given new predictor data and a multiple regression result.
487///
488/// # Arguments
489///
490/// * `model` — Multiple regression result.
491/// * `predictors_new` — Slice of predictor slices (same order as training).
492///
493/// Returns `None` if predictor count doesn't match or lengths differ.
494pub fn predict_multiple(
495    model: &MultipleRegressionResult,
496    predictors_new: &[&[f64]],
497) -> Option<Vec<f64>> {
498    if predictors_new.len() != model.p {
499        return None;
500    }
501    let n = predictors_new.first()?.len();
502    for pred in predictors_new {
503        if pred.len() != n {
504            return None;
505        }
506    }
507
508    let mut result = Vec::with_capacity(n);
509    for i in 0..n {
510        let mut yi = model.coefficients[0]; // intercept
511        for (j, pred) in predictors_new.iter().enumerate() {
512            yi += model.coefficients[j + 1] * pred[i];
513        }
514        result.push(yi);
515    }
516    Some(result)
517}
518
519/// A fitted regression's prediction at one new point, with its uncertainty.
520#[derive(Debug, Clone, Copy, PartialEq)]
521pub struct RegressionPrediction {
522    /// The point prediction ŷ₀.
523    pub fit: f64,
524    /// Standard error of the fitted mean at the point, √(x₀' V x₀) with V the
525    /// coefficients' covariance.
526    pub se_fit: f64,
527    /// Confidence interval of the mean response at the point:
528    /// ŷ₀ ± t · `se_fit`.
529    pub confidence_lower: f64,
530    /// Upper end of the confidence interval.
531    pub confidence_upper: f64,
532    /// Prediction interval of a new observation at the point:
533    /// ŷ₀ ± t · √(MSE + `se_fit`²).
534    pub prediction_lower: f64,
535    /// Upper end of the prediction interval.
536    pub prediction_upper: f64,
537}
538
539/// The intervals of a prediction `fit` whose mean has variance `var_fit`,
540/// with residual variance `mse` on `df` degrees of freedom, at two-sided
541/// `level`.
542fn prediction_at(fit: f64, var_fit: f64, mse: f64, df: f64, level: f64) -> RegressionPrediction {
543    let t = special::t_distribution_quantile(0.5 + level / 2.0, df);
544    // Rounding can leave x₀'Vx₀ a hair below zero at a point the fit passes
545    // through exactly.
546    let se_fit = var_fit.max(0.0).sqrt();
547    let se_new = (mse + var_fit.max(0.0)).sqrt();
548    RegressionPrediction {
549        fit,
550        se_fit,
551        confidence_lower: fit - t * se_fit,
552        confidence_upper: fit + t * se_fit,
553        prediction_lower: fit - t * se_new,
554        prediction_upper: fit + t * se_new,
555    }
556}
557
558/// Predictions of a simple regression at `x_new`, each with the confidence
559/// interval of the mean response and the prediction interval of a new
560/// observation at two-sided `level` (Student's t on n − 2 degrees of freedom).
561///
562/// Var(ŷ₀) = SE(β₀)² + x₀² SE(β₁)² + 2 x₀ Cov(β₀, β₁) = MSE (1/n + (x₀ − x̄)²/Sxx).
563///
564/// # Returns
565///
566/// `None` when `level` is not strictly between 0 and 1, or an `x_new` value is
567/// not finite.
568///
569/// # References
570///
571/// Montgomery, Peck & Vining (2012). "Introduction to Linear Regression
572/// Analysis", 5th edition, §2.4.2 and §2.5.
573///
574/// # Examples
575///
576/// ```
577/// use u_analytics::regression::{predict_simple_intervals, simple_linear_regression};
578///
579/// let x = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
580/// let y = [2.2, 3.9, 6.1, 8.2, 9.8, 12.1];
581/// let model = simple_linear_regression(&x, &y).unwrap();
582/// let p = predict_simple_intervals(&model, &[3.5, 10.0], 0.95).unwrap();
583/// // Wider for a new observation than for the mean, and wider away from x̄.
584/// assert!(p[0].prediction_upper - p[0].prediction_lower > p[0].confidence_upper - p[0].confidence_lower);
585/// assert!(p[1].se_fit > p[0].se_fit);
586/// ```
587pub fn predict_simple_intervals(
588    model: &SimpleRegressionResult,
589    x_new: &[f64],
590    level: f64,
591) -> Option<Vec<RegressionPrediction>> {
592    if !(level > 0.0 && level < 1.0) || x_new.iter().any(|v| !v.is_finite()) {
593        return None;
594    }
595    let df = model.n as f64 - 2.0;
596    let mse = model.residual_se * model.residual_se;
597    Some(
598        x_new
599            .iter()
600            .map(|&x0| {
601                let var_fit = model.intercept_se * model.intercept_se
602                    + x0 * x0 * model.slope_se * model.slope_se
603                    + 2.0 * x0 * model.intercept_slope_covariance;
604                prediction_at(model.intercept + model.slope * x0, var_fit, mse, df, level)
605            })
606            .collect(),
607    )
608}
609
610/// Predictions of a multiple regression at the new points `predictors_new`
611/// (one slice per predictor, in the training order), each with the
612/// confidence interval of the mean response and the prediction interval of a
613/// new observation at two-sided `level` (Student's t on n − p − 1 degrees of
614/// freedom).
615///
616/// Var(ŷ₀) = x₀' V x₀, x₀ = (1, x₀₁, …, x₀ₚ) and V the coefficients'
617/// covariance ([`MultipleRegressionResult::covariance`]).
618///
619/// # Returns
620///
621/// `None` when the predictor count differs from the model's, the slices
622/// differ in length, a value is not finite, or `level` is not strictly
623/// between 0 and 1.
624///
625/// # References
626///
627/// Montgomery, Peck & Vining (2012). "Introduction to Linear Regression
628/// Analysis", 5th edition, §3.4.
629///
630/// # Examples
631///
632/// ```
633/// use u_analytics::regression::{multiple_linear_regression, predict_multiple_intervals};
634///
635/// let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
636/// let x2 = [2.0, 1.0, 3.0, 2.0, 4.0, 3.0, 5.0, 4.0];
637/// let y = [5.1, 5.0, 9.2, 8.9, 13.1, 12.0, 17.2, 15.9];
638/// let model = multiple_linear_regression(&[&x1, &x2], &y).unwrap();
639/// let p = predict_multiple_intervals(&model, &[&[4.5], &[3.0]], 0.95).unwrap();
640/// assert!(p[0].prediction_lower < p[0].fit && p[0].fit < p[0].prediction_upper);
641/// ```
642pub fn predict_multiple_intervals(
643    model: &MultipleRegressionResult,
644    predictors_new: &[&[f64]],
645    level: f64,
646) -> Option<Vec<RegressionPrediction>> {
647    if !(level > 0.0 && level < 1.0) {
648        return None;
649    }
650    let fits = predict_multiple(model, predictors_new)?;
651    if predictors_new
652        .iter()
653        .any(|p| p.iter().any(|v| !v.is_finite()))
654    {
655        return None;
656    }
657    let k = model.p + 1;
658    let df = model.n as f64 - k as f64;
659    let mse = model.residual_se * model.residual_se;
660    Some(
661        fits.iter()
662            .enumerate()
663            .map(|(i, &fit)| {
664                let x0: Vec<f64> = std::iter::once(1.0)
665                    .chain(predictors_new.iter().map(|p| p[i]))
666                    .collect();
667                let var_fit: f64 = (0..k)
668                    .map(|a| {
669                        (0..k)
670                            .map(|b| x0[a] * model.covariance[a * k + b] * x0[b])
671                            .sum::<f64>()
672                    })
673                    .sum();
674                prediction_at(fit, var_fit, mse, df, level)
675            })
676            .collect(),
677    )
678}
679
680// ---------------------------------------------------------------------------
681// Multicollinearity diagnostics (public)
682// ---------------------------------------------------------------------------
683
684/// Computes the Variance Inflation Factor for each predictor.
685///
686/// `VIF_j = 1 / (1 - R²_j)`, where `R²_j` is the coefficient of determination
687/// from regressing predictor `j` on all other predictors. Standard thresholds
688/// are `VIF > 5` (moderate multicollinearity) and `VIF > 10` (severe).
689///
690/// # Returns
691///
692/// `None` if `predictors` is empty, lengths differ, fewer than `p+1`
693/// observations, or any predictor contains a non-finite value. With a single
694/// predictor, returns `Some(vec![1.0])` since VIF is undefined for one
695/// regressor.
696///
697/// # References
698///
699/// Marquardt (1970). "Generalized inverses, ridge regression."
700/// Belsley, Kuh, Welsch (1980). *Regression Diagnostics*.
701///
702/// # Examples
703///
704/// ```
705/// use u_analytics::regression::vif;
706///
707/// let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
708/// let x2 = [8.0, 1.0, 6.0, 3.0, 5.0, 7.0, 2.0, 4.0];
709/// let v = vif(&[&x1, &x2]).unwrap();
710/// assert!(v[0] < 2.0); // independent predictors
711/// ```
712pub fn vif(predictors: &[&[f64]]) -> Option<Vec<f64>> {
713    let p = predictors.len();
714    if p == 0 {
715        return None;
716    }
717    let n = predictors[0].len();
718    if !predictors.iter().all(|c| c.len() == n) {
719        return None;
720    }
721    if n <= p {
722        return None;
723    }
724    if predictors.iter().any(|c| c.iter().any(|v| !v.is_finite())) {
725        return None;
726    }
727    Some(compute_vif(predictors))
728}
729
730/// Computes the 2-norm condition number of the sample covariance matrix.
731///
732/// `cond(Σ) = λ_max / λ_min`, where `λ` are the eigenvalues of the unbiased
733/// sample covariance matrix of the (column-major) predictors. Standard
734/// threshold: `cond > 30` indicates multicollinearity worth investigating;
735/// `cond > 100` is severe (Belsley 1991).
736///
737/// # Returns
738///
739/// `None` for empty input, length mismatch, fewer than `p+1` observations,
740/// non-finite values, or eigenvalue decomposition failure. Returns
741/// `f64::INFINITY` when the smallest eigenvalue is at most
742/// `p * f64::EPSILON` times the largest (numerically singular).
743///
744/// # References
745///
746/// Belsley (1991). *Conditioning Diagnostics*.
747///
748/// # Examples
749///
750/// ```
751/// use u_analytics::regression::condition_number;
752///
753/// let x1 = [1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0];
754/// let x2 = [1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, -1.0];
755/// let c = condition_number(&[&x1, &x2]).unwrap();
756/// assert!(c < 5.0); // near-orthogonal columns: cond should be small
757/// ```
758pub fn condition_number(predictors: &[&[f64]]) -> Option<f64> {
759    use u_numflow::matrix::Matrix;
760    let p = predictors.len();
761    if p == 0 {
762        return None;
763    }
764    let n = predictors[0].len();
765    if !predictors.iter().all(|c| c.len() == n) {
766        return None;
767    }
768    if n <= p {
769        return None;
770    }
771    if predictors.iter().any(|c| c.iter().any(|v| !v.is_finite())) {
772        return None;
773    }
774
775    // Sample covariance matrix (unbiased, denominator n-1)
776    let mut means = vec![0.0_f64; p];
777    for (j, col) in predictors.iter().enumerate() {
778        means[j] = col.iter().sum::<f64>() / n as f64;
779    }
780    let mut cov = vec![0.0_f64; p * p];
781    let denom = (n - 1) as f64;
782    for r in 0..p {
783        let col_r = predictors[r];
784        let mean_r = means[r];
785        for c in r..p {
786            let col_c = predictors[c];
787            let mean_c = means[c];
788            let sum: f64 = col_r
789                .iter()
790                .zip(col_c.iter())
791                .map(|(&a, &b)| (a - mean_r) * (b - mean_c))
792                .sum();
793            let v = sum / denom;
794            cov[r * p + c] = v;
795            cov[c * p + r] = v;
796        }
797    }
798
799    let mat = Matrix::new(p, p, cov).ok()?;
800    let (eigenvalues, _eigenvectors) = mat.eigen_symmetric().ok()?;
801    let lambda_max = eigenvalues
802        .iter()
803        .copied()
804        .fold(f64::NEG_INFINITY, f64::max);
805    let lambda_min = eigenvalues.iter().copied().fold(f64::INFINITY, f64::min);
806
807    if !lambda_max.is_finite() || lambda_max <= 0.0 {
808        return None;
809    }
810    // Numerically singular is judged against the matrix's own scale (the
811    // tolerance NumPy's `matrix_rank` uses), not against a fixed number: the
812    // same data in a smaller unit has a covariance 1e-18 as large and is no
813    // less well conditioned.
814    if lambda_min <= lambda_max * p as f64 * f64::EPSILON {
815        return Some(f64::INFINITY);
816    }
817    Some(lambda_max / lambda_min)
818}
819
820#[cfg(test)]
821mod tests {
822    use super::*;
823
824    // -----------------------------------------------------------------------
825    // Simple regression tests
826    // -----------------------------------------------------------------------
827
828    #[test]
829    fn simple_perfect_fit() {
830        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
831        let y = [3.0, 5.0, 7.0, 9.0, 11.0]; // y = 1 + 2x
832        let r = simple_linear_regression(&x, &y).expect("should compute");
833        assert!((r.slope - 2.0).abs() < 1e-10);
834        assert!((r.intercept - 1.0).abs() < 1e-10);
835        assert!((r.r_squared - 1.0).abs() < 1e-10);
836    }
837
838    #[test]
839    fn simple_with_noise() {
840        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
841        let y = [2.1, 3.9, 6.1, 7.9, 10.1]; // y ≈ 2x + 0.1
842        let r = simple_linear_regression(&x, &y).expect("should compute");
843        assert!((r.slope - 2.0).abs() < 0.1);
844        assert!(r.r_squared > 0.99);
845        assert_eq!(r.residuals.len(), 5);
846        assert_eq!(r.fitted.len(), 5);
847    }
848
849    #[test]
850    fn simple_residuals_sum_to_zero() {
851        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
852        let y = [2.1, 3.9, 6.1, 7.9, 10.1];
853        let r = simple_linear_regression(&x, &y).expect("should compute");
854        let sum: f64 = r.residuals.iter().sum();
855        assert!(sum.abs() < 1e-10, "residuals sum = {sum}");
856    }
857
858    #[test]
859    fn simple_f_equals_t_squared() {
860        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
861        let y = [2.1, 3.9, 6.1, 7.9, 10.1];
862        let r = simple_linear_regression(&x, &y).expect("should compute");
863        assert!(
864            (r.f_statistic - r.slope_t * r.slope_t).abs() < 1e-8,
865            "F = {}, t² = {}",
866            r.f_statistic,
867            r.slope_t * r.slope_t
868        );
869    }
870
871    #[test]
872    fn simple_significant_slope() {
873        let x: Vec<f64> = (0..20).map(|i| i as f64).collect();
874        let y: Vec<f64> = x.iter().map(|&xi| 3.0 + 2.0 * xi).collect();
875        let r = simple_linear_regression(&x, &y).expect("should compute");
876        assert!(r.slope_p < 1e-10, "slope p = {}", r.slope_p);
877        assert!(r.f_p_value < 1e-10, "F p = {}", r.f_p_value);
878    }
879
880    #[test]
881    fn simple_negative_slope() {
882        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
883        let y = [10.0, 8.0, 6.0, 4.0, 2.0]; // y = 12 - 2x
884        let r = simple_linear_regression(&x, &y).expect("should compute");
885        assert!((r.slope + 2.0).abs() < 1e-10);
886        assert!((r.intercept - 12.0).abs() < 1e-10);
887    }
888
889    #[test]
890    fn simple_edge_cases() {
891        assert!(simple_linear_regression(&[1.0, 2.0], &[3.0, 4.0]).is_none()); // n < 3
892        assert!(simple_linear_regression(&[1.0, 2.0, 3.0], &[4.0, 5.0]).is_none()); // mismatch
893        assert!(simple_linear_regression(&[5.0, 5.0, 5.0], &[1.0, 2.0, 3.0]).is_none()); // zero var
894        assert!(simple_linear_regression(&[1.0, f64::NAN, 3.0], &[4.0, 5.0, 6.0]).is_none());
895    }
896
897    /// Verifies exact OLS numeric reference from the spec.
898    ///
899    /// x = [1,2,3,4,5], y = [2,4,5,4,5]
900    /// x̄=3, ȳ=4
901    /// Σ(xᵢ-x̄)(yᵢ-ȳ) = 6, Σ(xᵢ-x̄)² = 10
902    /// β̂₁ = 6/10 = 0.6, β̂₀ = 4 - 0.6·3 = 2.2
903    /// SS_tot = 6, SS_res = 2.4, R² = 1 - 2.4/6 = 0.6
904    ///
905    /// Reference: Draper & Smith (1998), Applied Regression Analysis, 3rd ed.
906    #[test]
907    fn simple_numeric_reference_ols() {
908        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
909        let y = [2.0, 4.0, 5.0, 4.0, 5.0];
910        let r = simple_linear_regression(&x, &y).expect("should compute");
911
912        assert!(
913            (r.slope - 0.6).abs() < 1e-10,
914            "β̂₁ expected 0.6, got {}",
915            r.slope
916        );
917        assert!(
918            (r.intercept - 2.2).abs() < 1e-10,
919            "β̂₀ expected 2.2, got {}",
920            r.intercept
921        );
922        assert!(
923            (r.r_squared - 0.6).abs() < 1e-3,
924            "R² expected 0.6, got {}",
925            r.r_squared
926        );
927
928        // Verify fitted values
929        // ŷ₁=2.8, ŷ₂=3.4, ŷ₃=4.0, ŷ₄=4.6, ŷ₅=5.2
930        let expected_fitted = [2.8, 3.4, 4.0, 4.6, 5.2];
931        for (i, (&fi, &ef)) in r.fitted.iter().zip(expected_fitted.iter()).enumerate() {
932            assert!(
933                (fi - ef).abs() < 1e-10,
934                "ŷ_{} expected {}, got {}",
935                i + 1,
936                ef,
937                fi
938            );
939        }
940    }
941
942    #[test]
943    fn simple_adjusted_r_squared() {
944        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
945        let y = [2.1, 3.9, 6.1, 7.9, 10.1];
946        let r = simple_linear_regression(&x, &y).expect("should compute");
947        // For perfect fit, adjusted R² ≈ R²
948        assert!(r.adjusted_r_squared <= r.r_squared);
949        assert!(r.adjusted_r_squared > 0.98);
950    }
951
952    // -----------------------------------------------------------------------
953    // Multiple regression tests
954    // -----------------------------------------------------------------------
955
956    #[test]
957    fn multiple_perfect_fit() {
958        // y = 1 + 2*x1 + 3*x2
959        let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
960        let x2 = [2.0, 1.0, 3.0, 2.0, 4.0, 3.0, 5.0, 4.0, 6.0, 5.0];
961        let y: Vec<f64> = x1
962            .iter()
963            .zip(x2.iter())
964            .map(|(&a, &b)| 1.0 + 2.0 * a + 3.0 * b)
965            .collect();
966        let r = multiple_linear_regression(&[&x1, &x2], &y).expect("should compute");
967
968        assert!(
969            (r.coefficients[0] - 1.0).abs() < 1e-8,
970            "β₀ = {}",
971            r.coefficients[0]
972        );
973        assert!(
974            (r.coefficients[1] - 2.0).abs() < 1e-8,
975            "β₁ = {}",
976            r.coefficients[1]
977        );
978        assert!(
979            (r.coefficients[2] - 3.0).abs() < 1e-8,
980            "β₂ = {}",
981            r.coefficients[2]
982        );
983        assert!((r.r_squared - 1.0).abs() < 1e-8);
984    }
985
986    #[test]
987    fn multiple_with_noise() {
988        let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
989        let x2 = [2.0, 1.0, 3.0, 2.0, 4.0, 3.0, 5.0, 4.0];
990        let y = [5.1, 5.0, 9.2, 8.9, 13.1, 12.0, 17.2, 15.9];
991        let r = multiple_linear_regression(&[&x1, &x2], &y).expect("should compute");
992        assert!(r.r_squared > 0.95);
993        assert_eq!(r.coefficients.len(), 3);
994        assert_eq!(r.std_errors.len(), 3);
995        assert_eq!(r.residuals.len(), 8);
996        assert_eq!(r.vif.len(), 2);
997    }
998
999    #[test]
1000    fn multiple_residuals_sum_to_zero() {
1001        let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
1002        let x2 = [2.0, 1.0, 3.0, 2.0, 4.0, 3.0, 5.0, 4.0];
1003        let y = [5.1, 5.0, 9.2, 8.9, 13.1, 12.0, 17.2, 15.9];
1004        let r = multiple_linear_regression(&[&x1, &x2], &y).expect("should compute");
1005        let sum: f64 = r.residuals.iter().sum();
1006        assert!(sum.abs() < 1e-8, "residuals sum = {sum}");
1007    }
1008
1009    #[test]
1010    fn multiple_single_predictor_matches_simple() {
1011        let x = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
1012        let y: Vec<f64> = x
1013            .iter()
1014            .map(|&xi| 3.0 + 2.5 * xi + 0.1 * (xi - 5.0))
1015            .collect();
1016
1017        let simple = simple_linear_regression(&x, &y).expect("simple");
1018        let multi = multiple_linear_regression(&[&x], &y).expect("multiple");
1019
1020        assert!(
1021            (simple.slope - multi.coefficients[1]).abs() < 1e-8,
1022            "slope: {} vs {}",
1023            simple.slope,
1024            multi.coefficients[1]
1025        );
1026        assert!(
1027            (simple.intercept - multi.coefficients[0]).abs() < 1e-8,
1028            "intercept: {} vs {}",
1029            simple.intercept,
1030            multi.coefficients[0]
1031        );
1032        assert!((simple.r_squared - multi.r_squared).abs() < 1e-8);
1033    }
1034
1035    #[test]
1036    fn multiple_edge_cases() {
1037        let x1 = [1.0, 2.0];
1038        let y = [3.0, 4.0];
1039        assert!(multiple_linear_regression(&[&x1], &y).is_none()); // n < p+2
1040
1041        let x2 = [1.0, 2.0, 3.0];
1042        let y2 = [4.0, 5.0];
1043        assert!(multiple_linear_regression(&[&x2], &y2).is_none()); // length mismatch
1044    }
1045
1046    #[test]
1047    fn multiple_vif_independent_predictors() {
1048        // Independent predictors → VIF ≈ 1
1049        let x1 = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0];
1050        let x2 = [0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
1051        let y: Vec<f64> = x1
1052            .iter()
1053            .zip(x2.iter())
1054            .map(|(&a, &b)| 1.0 + 2.0 * a + 3.0 * b)
1055            .collect();
1056        let r = multiple_linear_regression(&[&x1, &x2], &y).expect("should compute");
1057        for (i, &v) in r.vif.iter().enumerate() {
1058            assert!(
1059                v < 2.0,
1060                "VIF[{i}] = {v}, expected near 1.0 for independent predictors"
1061            );
1062        }
1063    }
1064
1065    #[test]
1066    fn multiple_vif_correlated_predictors() {
1067        // Highly but not perfectly correlated predictors → higher VIF
1068        let x1: Vec<f64> = (0..20).map(|i| i as f64).collect();
1069        // Add small perturbation to break perfect collinearity
1070        let noise = [
1071            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,
1072            0.1, -0.1, 0.2, -0.2,
1073        ];
1074        let x2: Vec<f64> = x1
1075            .iter()
1076            .zip(noise.iter())
1077            .map(|(&v, &n)| v * 0.9 + 1.0 + n)
1078            .collect();
1079        let y: Vec<f64> = x1
1080            .iter()
1081            .zip(x2.iter())
1082            .map(|(&a, &b)| 1.0 + a + b)
1083            .collect();
1084        let r = multiple_linear_regression(&[&x1, &x2], &y).expect("should compute");
1085        // VIF > 5 for highly correlated predictors
1086        assert!(
1087            r.vif[0] > 5.0,
1088            "VIF[0] = {}, expected > 5.0 for correlated predictors",
1089            r.vif[0]
1090        );
1091    }
1092
1093    // -----------------------------------------------------------------------
1094    // Prediction tests
1095    // -----------------------------------------------------------------------
1096
1097    #[test]
1098    fn predict_simple_basic() {
1099        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
1100        let y = [3.0, 5.0, 7.0, 9.0, 11.0]; // y = 1 + 2x
1101        let model = simple_linear_regression(&x, &y).expect("should compute");
1102        let pred = predict_simple(&model, &[6.0, 7.0]);
1103        assert!((pred[0] - 13.0).abs() < 1e-10);
1104        assert!((pred[1] - 15.0).abs() < 1e-10);
1105    }
1106
1107    #[test]
1108    fn predict_multiple_basic() {
1109        let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
1110        let x2 = [2.0, 1.0, 3.0, 2.0, 4.0, 3.0, 5.0, 4.0, 6.0, 5.0];
1111        let y: Vec<f64> = x1
1112            .iter()
1113            .zip(x2.iter())
1114            .map(|(&a, &b)| 1.0 + 2.0 * a + 3.0 * b)
1115            .collect();
1116        let model = multiple_linear_regression(&[&x1, &x2], &y).expect("should compute");
1117
1118        let new_x1 = [11.0];
1119        let new_x2 = [6.0];
1120        let pred = predict_multiple(&model, &[&new_x1, &new_x2]).expect("should predict");
1121        let expected = 1.0 + 2.0 * 11.0 + 3.0 * 6.0;
1122        assert!(
1123            (pred[0] - expected).abs() < 1e-6,
1124            "pred = {}, expected = {}",
1125            pred[0],
1126            expected
1127        );
1128    }
1129
1130    // -----------------------------------------------------------------------
1131    // Multicollinearity diagnostics — public vif() / condition_number()
1132    // -----------------------------------------------------------------------
1133
1134    #[test]
1135    fn vif_pub_independent_predictors() {
1136        let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
1137        let x2 = [8.0, 1.0, 6.0, 3.0, 5.0, 7.0, 2.0, 4.0];
1138        let v = vif(&[&x1, &x2]).expect("should compute");
1139        for r in &v {
1140            assert!(*r < 2.0, "VIF should be near 1.0 for independent, got {r}");
1141        }
1142    }
1143
1144    #[test]
1145    fn vif_pub_collinear_predictors() {
1146        let x1: Vec<f64> = (0..20).map(|i| i as f64).collect();
1147        let x2: Vec<f64> = x1.iter().map(|&v| v * 2.0 + 0.001).collect();
1148        let v = vif(&[&x1[..], &x2[..]]).expect("should compute");
1149        assert!(
1150            v[0] > 50.0,
1151            "VIF should explode under near-collinearity, got {}",
1152            v[0]
1153        );
1154    }
1155
1156    #[test]
1157    fn vif_pub_rejects_short_input() {
1158        let x = [1.0, 2.0];
1159        assert!(vif(&[&x[..], &x[..]]).is_none());
1160    }
1161
1162    #[test]
1163    fn vif_pub_rejects_non_finite() {
1164        let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
1165        let x2 = [1.0, 2.0, f64::NAN, 4.0, 5.0, 6.0];
1166        assert!(vif(&[&x1[..], &x2[..]]).is_none());
1167    }
1168
1169    #[test]
1170    fn condition_number_orthogonal() {
1171        let x1 = [1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0];
1172        let x2 = [1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, -1.0];
1173        let c = condition_number(&[&x1[..], &x2[..]]).expect("should compute");
1174        assert!(c < 5.0, "near-orthogonal: cond should be small, got {c}");
1175    }
1176
1177    #[test]
1178    fn condition_number_collinear_explodes() {
1179        let x1: Vec<f64> = (0..20).map(|i| i as f64).collect();
1180        let x2: Vec<f64> = x1.iter().map(|&v| v * 2.0 + 1e-6).collect();
1181        let c = condition_number(&[&x1[..], &x2[..]]).expect("should compute");
1182        assert!(
1183            c > 1e5 || c.is_infinite(),
1184            "collinear: cond should be huge, got {c}"
1185        );
1186    }
1187
1188    #[test]
1189    fn condition_number_rejects_short_input() {
1190        let x = [1.0, 2.0];
1191        assert!(condition_number(&[&x[..], &x[..]]).is_none());
1192    }
1193
1194    #[test]
1195    fn predict_multiple_wrong_predictors() {
1196        let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
1197        let x2 = [2.0, 1.0, 3.0, 2.0, 4.0, 3.0, 5.0, 4.0, 6.0, 5.0];
1198        let y: Vec<f64> = x1.iter().zip(x2.iter()).map(|(&a, &b)| a + b).collect();
1199        let model = multiple_linear_regression(&[&x1, &x2], &y).expect("should compute");
1200
1201        // Wrong number of predictors
1202        assert!(predict_multiple(&model, &[&[1.0]]).is_none());
1203    }
1204}
1205
1206#[cfg(test)]
1207mod proptests {
1208    use super::*;
1209    use proptest::prelude::*;
1210
1211    proptest! {
1212        #[test]
1213        fn simple_r_squared_bounded(
1214            data in proptest::collection::vec(-1e3_f64..1e3, 5..=30)
1215                .prop_flat_map(|x| {
1216                    let n = x.len();
1217                    (Just(x), proptest::collection::vec(-1e3_f64..1e3, n..=n))
1218                })
1219        ) {
1220            let (x, y) = data;
1221            if let Some(r) = simple_linear_regression(&x, &y) {
1222                prop_assert!(r.r_squared >= -0.01 && r.r_squared <= 1.01,
1223                    "R² = {}", r.r_squared);
1224            }
1225        }
1226
1227        #[test]
1228        fn simple_residuals_orthogonal_to_x(
1229            data in proptest::collection::vec(-1e3_f64..1e3, 5..=30)
1230                .prop_flat_map(|x| {
1231                    let n = x.len();
1232                    (Just(x), proptest::collection::vec(-1e3_f64..1e3, n..=n))
1233                })
1234        ) {
1235            let (x, y) = data;
1236            if let Some(r) = simple_linear_regression(&x, &y) {
1237                // Σ(xᵢ · eᵢ) should be near zero (OLS normal equation)
1238                let dot: f64 = x.iter().zip(r.residuals.iter()).map(|(&xi, &ei)| xi * ei).sum();
1239                let norm = r.residuals.iter().map(|e| e * e).sum::<f64>().sqrt();
1240                let x_norm = x.iter().map(|xi| xi * xi).sum::<f64>().sqrt();
1241                if norm > 1e-10 && x_norm > 1e-10 {
1242                    prop_assert!((dot / (norm * x_norm)).abs() < 1e-6,
1243                        "residuals not orthogonal to x: dot={dot}");
1244                }
1245            }
1246        }
1247
1248        #[test]
1249        fn multiple_r_squared_bounded(
1250            x1 in proptest::collection::vec(-1e3_f64..1e3, 8..=20),
1251            x2_seed in proptest::collection::vec(-1e3_f64..1e3, 8..=20),
1252            y_seed in proptest::collection::vec(-1e3_f64..1e3, 8..=20),
1253        ) {
1254            let n = x1.len().min(x2_seed.len()).min(y_seed.len());
1255            let x2 = &x2_seed[..n];
1256            let y = &y_seed[..n];
1257            let x1 = &x1[..n];
1258            if let Some(r) = multiple_linear_regression(&[x1, x2], y) {
1259                prop_assert!(r.r_squared >= -0.01 && r.r_squared <= 1.01,
1260                    "R² = {}", r.r_squared);
1261            }
1262        }
1263    }
1264}
1265
1266/// Slope, intercept and F p-values far in the tail against SciPy 1.17.1
1267/// (`scipy.stats.linregress`; the intercept's and the F test's from `t.sf` and
1268/// `f.sf`) -- the smallest is 8e-33.
1269#[cfg(test)]
1270mod scipy_tail_reference {
1271    use super::*;
1272
1273    fn close(got: f64, want: f64, what: &str) {
1274        assert!(
1275            (got - want).abs() <= 1e-9 * want.abs(),
1276            "{what}: got {got:e}, SciPy {want:e}"
1277        );
1278    }
1279
1280    #[test]
1281    fn simple_regression_p_values_keep_their_tail() {
1282        let x: Vec<f64> = (1..=20).map(f64::from).collect();
1283        let y = [
1284            5.3999999999999995,
1285            7.0,
1286            9.4,
1287            11.8,
1288            13.2,
1289            15.8,
1290            17.6,
1291            19.8,
1292            22.250000000000004,
1293            23.75,
1294            26.25,
1295            28.150000000000002,
1296            30.55,
1297            32.050000000000004,
1298            34.55,
1299            36.7,
1300            38.550000000000004,
1301            41.1,
1302            42.699999999999996,
1303            45.0,
1304        ];
1305        let r = simple_linear_regression(&x, &y).expect("valid");
1306        close(r.slope_p, 8.203356920391111e-33, "slope p");
1307        close(r.intercept_p, 1.5689270135401997e-16, "intercept p");
1308        close(r.f_p_value, 8.203356920397853e-33, "F p");
1309    }
1310    /// A fit and a condition number are properties of the data, not of the unit
1311    /// it is written in: the predictors in a unit a billion times smaller give
1312    /// the same R², p-values and conditioning, and coefficients that scale
1313    /// with the unit.
1314    #[test]
1315    fn multiple_regression_and_conditioning_do_not_depend_on_the_unit() {
1316        let n = 24;
1317        let x1: Vec<f64> = (0..n)
1318            .map(|i| (i as f64 * 0.7).sin() * 3.0 + i as f64 * 0.1)
1319            .collect();
1320        let x2: Vec<f64> = (0..n)
1321            .map(|i| (i as f64 * 1.3).cos() + 0.5 * x1[i])
1322            .collect();
1323        let y: Vec<f64> = (0..n)
1324            .map(|i| 1.0 + 2.0 * x1[i] - 0.5 * x2[i] + (i as f64 * 2.1).sin() * 0.3)
1325            .collect();
1326        let base = multiple_linear_regression(&[&x1, &x2], &y).expect("valid");
1327        let cond = condition_number(&[&x1, &x2]).expect("valid");
1328        for factor in [1e-9, 1e6] {
1329            let s1: Vec<f64> = x1.iter().map(|v| v * factor).collect();
1330            let s2: Vec<f64> = x2.iter().map(|v| v * factor).collect();
1331            let r = multiple_linear_regression(&[&s1, &s2], &y)
1332                .unwrap_or_else(|| panic!("a fit in units of {factor:e} was refused"));
1333            close(r.r_squared, base.r_squared, "R2");
1334            close(r.f_p_value, base.f_p_value, "F p");
1335            close(r.coefficients[0], base.coefficients[0], "intercept");
1336            for j in 0..2 {
1337                close(
1338                    r.coefficients[j + 1] * factor,
1339                    base.coefficients[j + 1],
1340                    "slope",
1341                );
1342                close(r.p_values[j + 1], base.p_values[j + 1], "slope p");
1343                close(r.vif[j], base.vif[j], "vif");
1344            }
1345            close(
1346                condition_number(&[&s1, &s2]).expect("valid"),
1347                cond,
1348                "condition number",
1349            );
1350        }
1351    }
1352}
1353
1354/// Prediction intervals and the coefficient covariance against statsmodels
1355/// 0.15.0 (`OLS(...).fit().get_prediction(...).summary_frame(alpha)`,
1356/// `cov_params()`) on seeded data rounded to 3 places: a simple regression at
1357/// 90 % with points left of, inside and far right of the data, and three
1358/// predictors on scales from 1 to 5000 at 95 %.
1359#[cfg(test)]
1360#[allow(clippy::approx_constant)] // seeded data, not mathematical constants
1361mod statsmodels_prediction_reference {
1362    use super::*;
1363
1364    // Generated by pi_ref.py - statsmodels 0.15.0, seed 20261028.
1365    const SX: &[f64] = &[
1366        0.321, 3.382, 13.09, 0.433, 11.207, 1.243, 13.577, 12.607, 3.653, 3.965, 11.199, 16.295,
1367    ];
1368    const SY: &[f64] = &[
1369        1.864, 10.159, 26.906, 4.994, 22.783, 9.397, 26.273, 24.293, 9.363, 9.846, 23.056, 29.43,
1370    ];
1371    const SXN: &[f64] = &[-5.0, 0.0, 10.0, 31.5];
1372    const S_MEAN: &[f64] = &[
1373        -4.004473932920135,
1374        4.156565438454745,
1375        20.478644181204505,
1376        55.57111347811649,
1377    ];
1378    const S_MEAN_SE: &[f64] = &[
1379        1.0916157424918431,
1380        0.7485088578492345,
1381        0.4868022389382572,
1382        1.9451041579807988,
1383    ];
1384    const S_MEAN_CI_LOWER: &[f64] = &[
1385        -5.982985027235801,
1386        2.799922233522837,
1387        19.596334048631235,
1388        52.04568781195695,
1389    ];
1390    const S_MEAN_CI_UPPER: &[f64] = &[
1391        -2.0259628386044684,
1392        5.513208643386653,
1393        21.360954313777775,
1394        59.09653914427602,
1395    ];
1396    const S_OBS_CI_LOWER: &[f64] = &[
1397        -7.441261322990979,
1398        1.0360715352868972,
1399        17.533228661407467,
1400        51.06271824900933,
1401    ];
1402    const S_OBS_CI_UPPER: &[f64] = &[
1403        -0.5676865428492905,
1404        7.277059341622593,
1405        23.424059701001543,
1406        60.079508707223646,
1407    ];
1408    const MX1: &[f64] = &[
1409        8.195, 0.642, 0.336, 6.144, 0.722, 9.91, 3.078, 2.241, 6.569, 9.172, 5.051, 8.676, 4.217,
1410        2.188, 6.705,
1411    ];
1412    const MX2: &[f64] = &[
1413        0.949, -2.284, 1.833, -0.66, -4.189, -3.649, -0.08, 1.587, 3.075, 4.395, 2.358, -4.012,
1414        -0.279, 4.502, 3.984,
1415    ];
1416    const MX3: &[f64] = &[
1417        3826.5, 3834.4, 3708.9, 1362.4, 2662.0, 2973.7, 3001.6, 4864.0, 2948.0, 1882.4, 2032.4,
1418        4144.4, 2666.3, 2747.4, 2715.4,
1419    ];
1420    const MY: &[f64] = &[
1421        28.737, 14.267, 13.838, 17.156, 10.139, 31.8, 15.884, 19.999, 21.378, 20.225, 16.187,
1422        33.594, 18.621, 10.903, 19.907,
1423    ];
1424    const MN1: &[f64] = &[5.0, 12.0];
1425    const MN2: &[f64] = &[0.0, -6.0];
1426    const MN3: &[f64] = &[3000.0, 800.0];
1427    const M_MEAN: &[f64] = &[19.808760915895164, 28.616648153275186];
1428    const M_MEAN_SE: &[f64] = &[0.3007541072279165, 1.2540843118704124];
1429    const M_MEAN_CI_LOWER: &[f64] = &[19.14680558904991, 25.856427193344672];
1430    const M_MEAN_CI_UPPER: &[f64] = &[20.470716242740416, 31.3768691132057];
1431    const M_OBS_CI_LOWER: &[f64] = &[17.202883874579435, 24.878837290302403];
1432    const M_OBS_CI_UPPER: &[f64] = &[22.414637957210893, 32.354459016247965];
1433    const M_COV: &[f64] = &[
1434        1.7052113874211179,
1435        -0.07001041367117572,
1436        -0.03185830538231558,
1437        -0.0004156285010374516,
1438        -0.07001041367117572,
1439        0.009234030588502234,
1440        0.000334157657603741,
1441        8.061392712052953e-06,
1442        -0.03185830538231558,
1443        0.000334157657603741,
1444        0.0109414882400463,
1445        8.173035100524671e-06,
1446        -0.0004156285010374516,
1447        8.061392712052953e-06,
1448        8.173035100524671e-06,
1449        1.229360269395184e-07,
1450    ];
1451
1452    fn close(got: f64, want: f64, what: &str) {
1453        let tol = 1e-9 * want.abs().max(1.0);
1454        assert!((got - want).abs() <= tol, "{what}: {got} vs {want}");
1455    }
1456
1457    #[test]
1458    fn simple_intervals_match_statsmodels() {
1459        let model = simple_linear_regression(SX, SY).expect("fits");
1460        let p = predict_simple_intervals(&model, SXN, 0.90).expect("intervals");
1461        for (i, r) in p.iter().enumerate() {
1462            close(r.fit, S_MEAN[i], "fit");
1463            close(r.se_fit, S_MEAN_SE[i], "se_fit");
1464            close(r.confidence_lower, S_MEAN_CI_LOWER[i], "mean lower");
1465            close(r.confidence_upper, S_MEAN_CI_UPPER[i], "mean upper");
1466            close(r.prediction_lower, S_OBS_CI_LOWER[i], "obs lower");
1467            close(r.prediction_upper, S_OBS_CI_UPPER[i], "obs upper");
1468        }
1469    }
1470
1471    #[test]
1472    fn multiple_intervals_and_covariance_match_statsmodels() {
1473        let model = multiple_linear_regression(&[MX1, MX2, MX3], MY).expect("fits");
1474        for (got, want) in model.covariance.iter().zip(M_COV) {
1475            close(*got, *want, "covariance");
1476        }
1477        let p = predict_multiple_intervals(&model, &[MN1, MN2, MN3], 0.95).expect("intervals");
1478        for (i, r) in p.iter().enumerate() {
1479            close(r.fit, M_MEAN[i], "fit");
1480            close(r.se_fit, M_MEAN_SE[i], "se_fit");
1481            close(r.confidence_lower, M_MEAN_CI_LOWER[i], "mean lower");
1482            close(r.confidence_upper, M_MEAN_CI_UPPER[i], "mean upper");
1483            close(r.prediction_lower, M_OBS_CI_LOWER[i], "obs lower");
1484            close(r.prediction_upper, M_OBS_CI_UPPER[i], "obs upper");
1485        }
1486    }
1487}