Skip to main content

scirs2_stats/regression/
regularized.rs

1//! Regularized regression implementations
2
3use crate::error::{StatsError, StatsResult};
4use crate::regression::stat_tests::{f_test_p_value, t_test_p_value};
5use crate::regression::utils::*;
6use crate::regression::RegressionResults;
7use scirs2_core::ndarray::{s, Array1, Array2, ArrayView1, ArrayView2};
8use scirs2_core::numeric::Float;
9use scirs2_linalg::{inv, lstsq};
10use std::collections::HashSet;
11
12// Type alias for complex return type
13type PreprocessingResult<F> = (Array2<F>, F, Array1<F>, Array1<F>);
14
15/// Perform ridge regression (L2 regularization).
16///
17/// Ridge regression adds a penalty term to the sum of squared residuals,
18/// which can help reduce overfitting and handle multicollinearity.
19///
20/// # Arguments
21///
22/// * `x` - Independent variables (design matrix)
23/// * `y` - Dependent variable
24/// * `alpha` - Regularization strength (default: 1.0)
25/// * `fit_intercept` - Whether to fit an intercept term (default: true)
26/// * `normalize` - Whether to normalize the data before fitting (default: false)
27/// * `tol` - Convergence tolerance (default: 1e-4)
28/// * `max_iter` - Maximum number of iterations (default: 1000)
29/// * `conf_level` - Confidence level for confidence intervals (default: 0.95)
30///
31/// # Returns
32///
33/// A RegressionResults struct with the regression results.
34///
35/// # Examples
36///
37/// ```
38/// use scirs2_core::ndarray::{array, Array2};
39/// use scirs2_stats::ridge_regression;
40///
41/// // Create a design matrix with 3 variables
42/// let x = Array2::from_shape_vec((5, 3), vec![
43///     1.0, 2.0, 3.0,
44///     2.0, 3.0, 4.0,
45///     3.0, 4.0, 5.0,
46///     4.0, 5.0, 6.0,
47///     5.0, 6.0, 7.0,
48/// ]).expect("Operation failed");
49///
50/// // Target values
51/// let y = array![10.0, 15.0, 20.0, 25.0, 30.0];
52///
53/// // Perform ridge regression with alpha=0.1
54/// let result = ridge_regression(&x.view(), &y.view(), Some(0.1), None, None, None, None, None).expect("Operation failed");
55///
56/// // Check that we get some coefficients
57/// assert!(result.coefficients.len() > 0);
58/// ```
59#[allow(clippy::too_many_arguments)]
60#[allow(dead_code)]
61pub fn ridge_regression<F>(
62    x: &ArrayView2<F>,
63    y: &ArrayView1<F>,
64    alpha: Option<F>,
65    fit_intercept: Option<bool>,
66    normalize: Option<bool>,
67    tol: Option<F>,
68    max_iter: Option<usize>,
69    conf_level: Option<F>,
70) -> StatsResult<RegressionResults<F>>
71where
72    F: Float
73        + std::iter::Sum<F>
74        + std::ops::Div<Output = F>
75        + std::fmt::Debug
76        + std::fmt::Display
77        + 'static
78        + scirs2_core::numeric::NumAssign
79        + scirs2_core::numeric::One
80        + scirs2_core::ndarray::ScalarOperand
81        + Send
82        + Sync,
83{
84    // Check input dimensions
85    if x.nrows() != y.len() {
86        return Err(StatsError::DimensionMismatch(format!(
87            "Input x has {} rows but y has length {}",
88            x.nrows(),
89            y.len()
90        )));
91    }
92
93    let n = x.nrows();
94    let p_features = x.ncols();
95
96    // Set default parameters
97    let alpha = alpha.unwrap_or_else(|| F::from(1.0).expect("Failed to convert constant to float"));
98    let fit_intercept = fit_intercept.unwrap_or(true);
99    let normalize = normalize.unwrap_or(false);
100    let tol = tol.unwrap_or_else(|| F::from(1e-4).expect("Failed to convert constant to float"));
101    let max_iter = max_iter.unwrap_or(1000);
102    let conf_level =
103        conf_level.unwrap_or_else(|| F::from(0.95).expect("Failed to convert constant to float"));
104
105    if alpha < F::zero() {
106        return Err(StatsError::InvalidArgument(
107            "alpha must be non-negative".to_string(),
108        ));
109    }
110
111    // Preprocess x and y
112    // `y_mean` is computed by `preprocessdata` but must NOT be folded back
113    // into the intercept by `transform_coefficients` -- see that function's
114    // doc comment. Kept `_`-prefixed here since preprocessdata's return
115    // shape is shared plumbing used by all four regularized-regression
116    // entry points.
117    let (x_processed, _y_mean, x_mean, x_std) = preprocessdata(x, y, fit_intercept, normalize)?;
118
119    // Total number of coefficients (including _intercept if fitted)
120    let p = if fit_intercept {
121        p_features + 1
122    } else {
123        p_features
124    };
125
126    // We need at least 2 observations for meaningful regression
127    if n < 2 {
128        return Err(StatsError::InvalidArgument(
129            "At least 2 observations required for ridge regression".to_string(),
130        ));
131    }
132
133    // Solve the ridge regression problem
134    // We solve the linear system [X; sqrt(alpha)I] beta = [y; 0]
135
136    // Create the regularization matrix sqrt(alpha)I
137    let ridgesize = if fit_intercept { p_features } else { p };
138    let mut x_ridge = Array2::zeros((n + ridgesize, p));
139
140    // Copy X to the top part of the augmented matrix
141    for i in 0..n {
142        for j in 0..p {
143            x_ridge[[i, j]] = x_processed[[i, j]];
144        }
145    }
146
147    // Add sqrt(alpha)I to the bottom part
148    let sqrt_alpha = scirs2_core::numeric::Float::sqrt(alpha);
149    for i in 0..ridgesize {
150        let j = if fit_intercept { i + 1 } else { i }; // Skip _intercept if present
151        x_ridge[[n + i, j]] = sqrt_alpha;
152    }
153
154    // Create the augmented target vector [y; 0]
155    let mut y_ridge = Array1::zeros(n + ridgesize);
156    for i in 0..n {
157        y_ridge[i] = y[i];
158    }
159
160    // Solve the ridge regression problem
161    let coefficients = solve_ridge_system(&x_ridge.view(), &y_ridge.view(), tol, max_iter)?;
162
163    // If data was normalized/centered, transform coefficients back
164    let transformed_coefficients = if normalize || fit_intercept {
165        transform_coefficients(&coefficients, &x_mean, &x_std, fit_intercept)
166    } else {
167        coefficients.clone()
168    };
169
170    // Calculate fitted values and residuals
171    let x_design = if fit_intercept {
172        add_intercept(x)
173    } else {
174        x.to_owned()
175    };
176
177    let fitted_values = x_design.dot(&transformed_coefficients);
178    let residuals = y.to_owned() - &fitted_values;
179
180    // Calculate degrees of freedom
181    let df_model = p - 1; // Subtract 1 for _intercept
182    let df_residuals = n - p;
183
184    // Calculate sum of squares
185    let (_y_mean, ss_total, ss_residual, ss_explained) =
186        calculate_sum_of_squares(y, &residuals.view());
187
188    // Calculate R-squared and adjusted R-squared
189    let r_squared = ss_explained / ss_total;
190    let adj_r_squared = F::one()
191        - (F::one() - r_squared) * F::from(n - 1).expect("Failed to convert to float")
192            / F::from(df_residuals).expect("Failed to convert to float");
193
194    // Calculate mean squared error and residual standard error
195    let mse = ss_residual / F::from(df_residuals).expect("Failed to convert to float");
196    let residual_std_error = scirs2_core::numeric::Float::sqrt(mse);
197
198    // Calculate standard errors for coefficients (approximate)
199    let std_errors = match calculate_ridge_std_errors(
200        &x_design.view(),
201        &residuals.view(),
202        alpha,
203        df_residuals,
204    ) {
205        Ok(se) => se,
206        Err(_) => Array1::<F>::zeros(p),
207    };
208
209    // Calculate t-values
210    let t_values = calculate_t_values(&transformed_coefficients, &std_errors);
211
212    // Calculate real two-sided per-coefficient p-values from the Student's
213    // t-distribution (see `stat_tests::t_test_p_value`).
214    let p_values = t_values.mapv(|t| t_test_p_value(t, df_residuals));
215
216    // Calculate confidence intervals
217    let mut conf_intervals = Array2::<F>::zeros((p, 2));
218    let z = norm_ppf(
219        F::from(0.5).expect("Failed to convert constant to float") * (F::one() + conf_level),
220    );
221
222    for i in 0..p {
223        let margin = std_errors[i] * z;
224        conf_intervals[[i, 0]] = transformed_coefficients[i] - margin;
225        conf_intervals[[i, 1]] = transformed_coefficients[i] + margin;
226    }
227
228    // Calculate F-statistic
229    let f_statistic = if df_model > 0 && df_residuals > 0 {
230        (ss_explained / F::from(df_model).expect("Failed to convert to float"))
231            / (ss_residual / F::from(df_residuals).expect("Failed to convert to float"))
232    } else {
233        F::infinity()
234    };
235
236    // Calculate p-value for F-statistic using the real F(df_model, df_residuals)
237    // survival function (see `stat_tests::f_test_p_value`).
238    let f_p_value = f_test_p_value(f_statistic, df_model, df_residuals);
239
240    // Create and return the results structure
241    Ok(RegressionResults {
242        coefficients: transformed_coefficients,
243        std_errors,
244        t_values,
245        p_values,
246        conf_intervals,
247        r_squared,
248        adj_r_squared,
249        f_statistic,
250        f_p_value,
251        residual_std_error,
252        df_residuals,
253        residuals,
254        fitted_values,
255        inlier_mask: vec![true; n], // All points are inliers in ridge regression
256    })
257}
258
259/// Helper function to solve the ridge regression system
260#[allow(dead_code)]
261fn solve_ridge_system<F>(
262    x_ridge: &ArrayView2<F>,
263    y_ridge: &ArrayView1<F>,
264    _tol: F,
265    _max_iter: usize,
266) -> StatsResult<Array1<F>>
267where
268    F: Float
269        + std::iter::Sum<F>
270        + std::ops::Div<Output = F>
271        + 'static
272        + scirs2_core::numeric::NumAssign
273        + scirs2_core::numeric::One
274        + scirs2_core::ndarray::ScalarOperand
275        + std::fmt::Display
276        + Send
277        + Sync,
278{
279    match lstsq(x_ridge, y_ridge, None) {
280        Ok(result) => Ok(result.x),
281        Err(e) => Err(StatsError::ComputationError(format!(
282            "Least squares computation failed: {:?}",
283            e
284        ))),
285    }
286}
287
288/// Preprocess data for regularized regression
289#[allow(dead_code)]
290fn preprocessdata<F>(
291    x: &ArrayView2<F>,
292    y: &ArrayView1<F>,
293    fit_intercept: bool,
294    normalize: bool,
295) -> StatsResult<PreprocessingResult<F>>
296where
297    F: Float + std::iter::Sum<F> + 'static + std::fmt::Display,
298{
299    let n = x.nrows();
300    let p = x.ncols();
301
302    // Calculate y_mean if fitting _intercept
303    let y_mean = if fit_intercept {
304        y.iter().cloned().sum::<F>() / F::from(n).expect("Failed to convert to float")
305    } else {
306        F::zero()
307    };
308
309    // Calculate x_mean and x_std if normalizing or fitting _intercept
310    let mut x_mean = Array1::<F>::zeros(p);
311    let mut x_std = Array1::<F>::ones(p);
312
313    if fit_intercept || normalize {
314        for j in 0..p {
315            let col = x.column(j);
316            let mean =
317                col.iter().cloned().sum::<F>() / F::from(n).expect("Failed to convert to float");
318            x_mean[j] = mean;
319
320            if normalize {
321                let mut ss = F::zero();
322                for &val in col {
323                    ss = ss + scirs2_core::numeric::Float::powi(val - mean, 2);
324                }
325                let std_dev = scirs2_core::numeric::Float::sqrt(
326                    ss / F::from(n).expect("Failed to convert to float"),
327                );
328                x_std[j] = if std_dev > F::epsilon() {
329                    std_dev
330                } else {
331                    F::one()
332                };
333            }
334        }
335    }
336
337    // Create processed X matrix
338    let mut x_processed = if fit_intercept {
339        Array2::<F>::zeros((n, p + 1))
340    } else {
341        Array2::<F>::zeros((n, p))
342    };
343
344    // Add _intercept column if needed
345    if fit_intercept {
346        for i in 0..n {
347            x_processed[[i, 0]] = F::one();
348        }
349    }
350
351    // Copy and normalize X data
352    let offset = if fit_intercept { 1 } else { 0 };
353    for i in 0..n {
354        for j in 0..p {
355            let val = if normalize || fit_intercept {
356                (x[[i, j]] - x_mean[j]) / x_std[j]
357            } else {
358                x[[i, j]]
359            };
360            x_processed[[i, j + offset]] = val;
361        }
362    }
363
364    Ok((x_processed, y_mean, x_mean, x_std))
365}
366
367/// Transform coefficients back after fitting with normalized/centered data.
368///
369/// `preprocessdata` builds an *explicit* intercept-of-ones column (rather
370/// than centering `y` and dropping the intercept column), so
371/// `coefficients[0]` already *is* the true intercept in standardized-feature
372/// space once every feature coefficient's mean/std contribution is
373/// subtracted back out below -- `y`'s own mean must NOT be added on top of
374/// that (an earlier version of this function did add it back, which is
375/// wrong whenever `y` is not itself centered -- and `preprocessdata` never
376/// centers `y`, only `x`). Verified against a plain OLS fit on raw
377/// (unstandardized) data: reconstructing without the erroneous `+ y_mean`
378/// term reproduces the reference coefficients exactly, while adding it back
379/// silently shifted the intercept by `y_mean` and corrupted every
380/// downstream statistic (residuals, R², F-statistic, ...).
381#[allow(dead_code)]
382fn transform_coefficients<F>(
383    coefficients: &Array1<F>,
384    x_mean: &Array1<F>,
385    x_std: &Array1<F>,
386    fit_intercept: bool,
387) -> Array1<F>
388where
389    F: Float + 'static + std::fmt::Display,
390{
391    let _p = coefficients.len();
392    let p_features = x_mean.len();
393
394    let mut transformed = coefficients.clone();
395
396    if fit_intercept {
397        let mut _intercept = coefficients[0];
398
399        // Adjust _intercept for the effect of normalizing/centering
400        for j in 0..p_features {
401            _intercept = _intercept - coefficients[j + 1] * x_mean[j] / x_std[j];
402        }
403
404        transformed[0] = _intercept;
405
406        // Adjust feature coefficients for the scaling
407        for j in 0..p_features {
408            transformed[j + 1] = coefficients[j + 1] / x_std[j];
409        }
410    } else {
411        // Adjust feature coefficients for the scaling
412        for j in 0..p_features {
413            transformed[j] = coefficients[j] / x_std[j];
414        }
415    }
416
417    transformed
418}
419
420/// Calculate standard errors for ridge regression
421#[allow(dead_code)]
422fn calculate_ridge_std_errors<F>(
423    x: &ArrayView2<F>,
424    residuals: &ArrayView1<F>,
425    alpha: F,
426    df: usize,
427) -> StatsResult<Array1<F>>
428where
429    F: Float
430        + std::iter::Sum<F>
431        + std::ops::Div<Output = F>
432        + 'static
433        + scirs2_core::numeric::NumAssign
434        + scirs2_core::numeric::One
435        + scirs2_core::ndarray::ScalarOperand
436        + std::fmt::Display
437        + Send
438        + Sync,
439{
440    // Calculate the mean squared error of the residuals
441    let mse = residuals
442        .iter()
443        .map(|&r| scirs2_core::numeric::Float::powi(r, 2))
444        .sum::<F>()
445        / F::from(df).expect("Failed to convert to float");
446
447    // Calculate X'X
448    let xtx = x.t().dot(x);
449
450    // Add regularization term: X'X + alpha*I
451    let p = x.ncols();
452    let mut xtx_reg = xtx.clone();
453
454    for i in 0..p {
455        xtx_reg[[i, i]] += alpha;
456    }
457
458    // Invert (X'X + alpha*I) to get (X'X + alpha*I)^-1
459    let xtx_reg_inv = match inv(&xtx_reg.view(), None) {
460        Ok(inv_result) => inv_result,
461        Err(_) => {
462            // If inversion fails, return zeros for standard errors
463            return Ok(Array1::<F>::zeros(p));
464        }
465    };
466
467    // Calculate standard errors
468    // The diagonal elements of (X'X + alpha*I)^-1 * X'X * (X'X + alpha*I)^-1 * MSE are the variances
469    let std_errors = (xtx_reg_inv.dot(&xtx).dot(&xtx_reg_inv))
470        .diag()
471        .mapv(|v| scirs2_core::numeric::Float::sqrt(v * mse));
472
473    Ok(std_errors)
474}
475
476/// Perform lasso regression (L1 regularization).
477///
478/// Lasso regression adds an L1 penalty term to the sum of squared residuals,
479/// which can help with feature selection by driving some coefficients to zero.
480///
481/// # Arguments
482///
483/// * `x` - Independent variables (design matrix)
484/// * `y` - Dependent variable
485/// * `alpha` - Regularization strength (default: 1.0)
486/// * `fit_intercept` - Whether to fit an intercept term (default: true)
487/// * `normalize` - Whether to normalize the data before fitting (default: false)
488/// * `tol` - Convergence tolerance (default: 1e-4)
489/// * `max_iter` - Maximum number of iterations (default: 1000)
490/// * `conf_level` - Confidence level for confidence intervals (default: 0.95)
491///
492/// # Returns
493///
494/// A RegressionResults struct with the regression results.
495///
496/// # Examples
497///
498/// ```ignore
499/// use scirs2_core::ndarray::{array, Array2};
500/// use scirs2_stats::lasso_regression;
501///
502/// // Create a design matrix with 5 variables, where only the first 2 are relevant
503/// let x = Array2::from_shape_vec((10, 5), vec![
504///     1.0, 2.0, 0.1, 0.2, 0.3,
505///     2.0, 3.0, 0.2, 0.3, 0.4,
506///     3.0, 4.0, 0.3, 0.4, 0.5,
507///     4.0, 5.0, 0.4, 0.5, 0.6,
508///     5.0, 6.0, 0.5, 0.6, 0.7,
509///     6.0, 7.0, 0.6, 0.7, 0.8,
510///     7.0, 8.0, 0.7, 0.8, 0.9,
511///     8.0, 9.0, 0.8, 0.9, 1.0,
512///     9.0, 10.0, 0.9, 1.0, 1.1,
513///     10.0, 11.0, 1.0, 1.1, 1.2,
514/// ]).expect("Operation failed");
515///
516/// // Target values depend only on first two variables
517/// let y = array![5.0, 8.0, 11.0, 14.0, 17.0, 20.0, 23.0, 26.0, 29.0, 32.0];
518///
519/// // Perform lasso regression with alpha=0.1
520/// let result = lasso_regression(&x.view(), &y.view(), Some(0.1), None, None, None, None, None).expect("Operation failed");
521///
522/// // Check that we got coefficients
523/// assert!(result.coefficients.len() > 0);
524///
525/// // Typically, lasso would drive coefficients of irrelevant features toward zero
526/// ```
527#[allow(clippy::too_many_arguments)]
528#[allow(dead_code)]
529pub fn lasso_regression<F>(
530    x: &ArrayView2<F>,
531    y: &ArrayView1<F>,
532    alpha: Option<F>,
533    fit_intercept: Option<bool>,
534    normalize: Option<bool>,
535    tol: Option<F>,
536    max_iter: Option<usize>,
537    conf_level: Option<F>,
538) -> StatsResult<RegressionResults<F>>
539where
540    F: Float
541        + std::iter::Sum<F>
542        + std::ops::Div<Output = F>
543        + std::fmt::Debug
544        + std::fmt::Display
545        + 'static
546        + scirs2_core::numeric::NumAssign
547        + scirs2_core::numeric::One
548        + scirs2_core::ndarray::ScalarOperand
549        + Send
550        + Sync,
551{
552    // Check input dimensions
553    if x.nrows() != y.len() {
554        return Err(StatsError::DimensionMismatch(format!(
555            "Input x has {} rows but y has length {}",
556            x.nrows(),
557            y.len()
558        )));
559    }
560
561    let n = x.nrows();
562    let p_features = x.ncols();
563
564    // Set default parameters
565    let alpha = alpha.unwrap_or_else(|| F::from(1.0).expect("Failed to convert constant to float"));
566    let fit_intercept = fit_intercept.unwrap_or(true);
567    let normalize = normalize.unwrap_or(false);
568    let tol = tol.unwrap_or_else(|| F::from(1e-4).expect("Failed to convert constant to float"));
569    let max_iter = max_iter.unwrap_or(1000);
570    let conf_level =
571        conf_level.unwrap_or_else(|| F::from(0.95).expect("Failed to convert constant to float"));
572
573    if alpha < F::zero() {
574        return Err(StatsError::InvalidArgument(
575            "alpha must be non-negative".to_string(),
576        ));
577    }
578
579    // Preprocess x and y
580    // `y_mean` is computed by `preprocessdata` but must NOT be folded back
581    // into the intercept by `transform_coefficients` -- see that function's
582    // doc comment. Kept `_`-prefixed here since preprocessdata's return
583    // shape is shared plumbing used by all four regularized-regression
584    // entry points.
585    let (x_processed, _y_mean, x_mean, x_std) = preprocessdata(x, y, fit_intercept, normalize)?;
586
587    // Total number of coefficients (including _intercept if fitted)
588    let p = if fit_intercept {
589        p_features + 1
590    } else {
591        p_features
592    };
593
594    // We need at least 2 observations for meaningful regression
595    if n < 2 {
596        return Err(StatsError::InvalidArgument(
597            "At least 2 observations required for lasso regression".to_string(),
598        ));
599    }
600
601    // Initialize coefficients
602    let mut coefficients = Array1::<F>::zeros(p);
603
604    // Calculate X'X and X'y for faster computations
605    let xtx = x_processed.t().dot(&x_processed);
606    let xty = x_processed.t().dot(y);
607
608    // Coordinate descent algorithm for lasso
609    let mut converged = false;
610    let mut _iter = 0;
611
612    while !converged && _iter < max_iter {
613        converged = true;
614
615        // Save old coefficients for convergence check
616        let old_coefs = coefficients.clone();
617
618        // Update each coefficient in turn
619        for j in 0..p {
620            // Calculate partial residual
621            let r_partial = xty[j]
622                - xtx
623                    .row(j)
624                    .iter()
625                    .zip(coefficients.iter())
626                    .enumerate()
627                    .filter(|&(i_, _)| i_ != j)
628                    .map(|(_, (&xtx_ij, &coef_i))| xtx_ij * coef_i)
629                    .sum::<F>();
630
631            // Apply soft thresholding
632            let xtx_jj = xtx[[j, j]];
633            if xtx_jj < F::epsilon() {
634                coefficients[j] = F::zero();
635                continue;
636            }
637
638            if j == 0 && fit_intercept {
639                // No penalty for _intercept
640                coefficients[j] = r_partial / xtx_jj;
641            } else {
642                // Apply soft thresholding for L1 penalty
643                if crate::regression::utils::float_abs(r_partial) <= alpha {
644                    coefficients[j] = F::zero();
645                } else if r_partial > F::zero() {
646                    coefficients[j] = (r_partial - alpha) / xtx_jj;
647                } else {
648                    coefficients[j] = (r_partial + alpha) / xtx_jj;
649                }
650            }
651        }
652
653        // Check for convergence
654        let coef_diff = (&coefficients - &old_coefs)
655            .mapv(|x| scirs2_core::numeric::Float::abs(x))
656            .sum();
657        let coef_norm = old_coefs
658            .mapv(|x| scirs2_core::numeric::Float::abs(x))
659            .sum()
660            .max(F::epsilon());
661
662        if coef_diff / coef_norm < tol {
663            converged = true;
664        }
665
666        _iter += 1;
667    }
668
669    // If data was normalized/centered, transform coefficients back
670    let transformed_coefficients = if normalize || fit_intercept {
671        transform_coefficients(&coefficients, &x_mean, &x_std, fit_intercept)
672    } else {
673        coefficients.clone()
674    };
675
676    // Calculate fitted values and residuals
677    let x_design = if fit_intercept {
678        add_intercept(x)
679    } else {
680        x.to_owned()
681    };
682
683    let fitted_values = x_design.dot(&transformed_coefficients);
684    let residuals = y.to_owned() - &fitted_values;
685
686    // Calculate degrees of freedom
687    // For lasso, df = number of non-zero coefficients
688    let nonzero_coefs = transformed_coefficients
689        .iter()
690        .filter(|&&x| crate::regression::utils::float_abs(x) > F::epsilon())
691        .count();
692    let df_model = nonzero_coefs - if fit_intercept { 1 } else { 0 };
693    let df_residuals = n - nonzero_coefs;
694
695    // Calculate sum of squares
696    let (_y_mean, ss_total, ss_residual, ss_explained) =
697        calculate_sum_of_squares(y, &residuals.view());
698
699    // Calculate R-squared and adjusted R-squared
700    let r_squared = ss_explained / ss_total;
701    let adj_r_squared = F::one()
702        - (F::one() - r_squared) * F::from(n - 1).expect("Failed to convert to float")
703            / F::from(df_residuals).expect("Failed to convert to float");
704
705    // Calculate mean squared error and residual standard error
706    let mse = ss_residual / F::from(df_residuals).expect("Failed to convert to float");
707    let residual_std_error = scirs2_core::numeric::Float::sqrt(mse);
708
709    // Calculate standard errors for coefficients (approximate)
710    let std_errors = match calculate_lasso_std_errors(
711        &x_design.view(),
712        &residuals.view(),
713        &transformed_coefficients,
714        df_residuals,
715    ) {
716        Ok(se) => se,
717        Err(_) => Array1::<F>::zeros(p),
718    };
719
720    // Calculate t-values
721    let t_values = calculate_t_values(&transformed_coefficients, &std_errors);
722
723    // Calculate real two-sided per-coefficient p-values from the Student's
724    // t-distribution (see `stat_tests::t_test_p_value`).
725    let p_values = t_values.mapv(|t| t_test_p_value(t, df_residuals));
726
727    // Calculate confidence intervals
728    let mut conf_intervals = Array2::<F>::zeros((p, 2));
729    let z = norm_ppf(
730        F::from(0.5).expect("Failed to convert constant to float") * (F::one() + conf_level),
731    );
732
733    for i in 0..p {
734        let margin = std_errors[i] * z;
735        conf_intervals[[i, 0]] = transformed_coefficients[i] - margin;
736        conf_intervals[[i, 1]] = transformed_coefficients[i] + margin;
737    }
738
739    // Calculate F-statistic
740    let f_statistic = if df_model > 0 && df_residuals > 0 {
741        (ss_explained / F::from(df_model).expect("Failed to convert to float"))
742            / (ss_residual / F::from(df_residuals).expect("Failed to convert to float"))
743    } else {
744        F::infinity()
745    };
746
747    // Calculate p-value for F-statistic using the real F(df_model, df_residuals)
748    // survival function (see `stat_tests::f_test_p_value`).
749    let f_p_value = f_test_p_value(f_statistic, df_model, df_residuals);
750
751    // Create and return the results structure
752    Ok(RegressionResults {
753        coefficients: transformed_coefficients,
754        std_errors,
755        t_values,
756        p_values,
757        conf_intervals,
758        r_squared,
759        adj_r_squared,
760        f_statistic,
761        f_p_value,
762        residual_std_error,
763        df_residuals,
764        residuals,
765        fitted_values,
766        inlier_mask: vec![true; n], // All points are inliers in lasso regression
767    })
768}
769
770/// Calculate standard errors for lasso regression
771#[allow(dead_code)]
772fn calculate_lasso_std_errors<F>(
773    x: &ArrayView2<F>,
774    residuals: &ArrayView1<F>,
775    coefficients: &Array1<F>,
776    df: usize,
777) -> StatsResult<Array1<F>>
778where
779    F: Float
780        + std::iter::Sum<F>
781        + std::ops::Div<Output = F>
782        + 'static
783        + scirs2_core::numeric::NumAssign
784        + scirs2_core::numeric::One
785        + scirs2_core::ndarray::ScalarOperand
786        + std::fmt::Display
787        + Send
788        + Sync,
789{
790    // Calculate the mean squared error of the residuals
791    let mse = residuals
792        .iter()
793        .map(|&r| scirs2_core::numeric::Float::powi(r, 2))
794        .sum::<F>()
795        / F::from(df).expect("Failed to convert to float");
796
797    // Find non-zero coefficients
798    let p = coefficients.len();
799    let mut active_set = Vec::new();
800
801    for j in 0..p {
802        if crate::regression::utils::float_abs(coefficients[j]) > F::epsilon() {
803            active_set.push(j);
804        }
805    }
806
807    // If no active features, return zeros
808    if active_set.is_empty() {
809        return Ok(Array1::<F>::zeros(p));
810    }
811
812    // Calculate X_active'X_active for active features
813    let n_active = active_set.len();
814    let mut xtx_active = Array2::<F>::zeros((n_active, n_active));
815
816    for (i, &idx_i) in active_set.iter().enumerate() {
817        for (j, &idx_j) in active_set.iter().enumerate() {
818            let x_i = x.column(idx_i);
819            let x_j = x.column(idx_j);
820
821            xtx_active[[i, j]] = x_i.iter().zip(x_j.iter()).map(|(&xi, &xj)| xi * xj).sum();
822        }
823    }
824
825    // Invert X_active'X_active
826    let xtx_active_inv = match inv(&xtx_active.view(), None) {
827        Ok(inv_result) => inv_result,
828        Err(_) => {
829            // If inversion fails, return zeros for standard errors
830            return Ok(Array1::<F>::zeros(p));
831        }
832    };
833
834    // Create full standard error vector
835    let mut std_errors = Array1::<F>::zeros(p);
836
837    for (i, &idx) in active_set.iter().enumerate() {
838        std_errors[idx] = scirs2_core::numeric::Float::sqrt(xtx_active_inv[[i, i]] * mse);
839    }
840
841    Ok(std_errors)
842}
843
844/// Perform elastic net regression (L1 + L2 regularization).
845///
846/// Elastic net combines L1 and L2 penalties, offering a compromise between
847/// lasso and ridge regression.
848///
849/// # Arguments
850///
851/// * `x` - Independent variables (design matrix)
852/// * `y` - Dependent variable
853/// * `alpha` - Total regularization strength (default: 1.0)
854/// * `l1_ratio` - Ratio of L1 penalty (default: 0.5, 0 = ridge, 1 = lasso)
855/// * `fit_intercept` - Whether to fit an intercept term (default: true)
856/// * `normalize` - Whether to normalize the data before fitting (default: false)
857/// * `tol` - Convergence tolerance (default: 1e-4)
858/// * `max_iter` - Maximum number of iterations (default: 1000)
859/// * `conf_level` - Confidence level for confidence intervals (default: 0.95)
860///
861/// # Returns
862///
863/// A RegressionResults struct with the regression results.
864///
865/// # Examples
866///
867/// ```ignore
868/// use scirs2_core::ndarray::{array, Array2};
869/// use scirs2_stats::elastic_net;
870///
871/// // Create a design matrix with 5 variables
872/// let x = Array2::from_shape_vec((10, 5), vec![
873///     1.0, 2.0, 0.1, 0.2, 0.3,
874///     2.0, 3.0, 0.2, 0.3, 0.4,
875///     3.0, 4.0, 0.3, 0.4, 0.5,
876///     4.0, 5.0, 0.4, 0.5, 0.6,
877///     5.0, 6.0, 0.5, 0.6, 0.7,
878///     6.0, 7.0, 0.6, 0.7, 0.8,
879///     7.0, 8.0, 0.7, 0.8, 0.9,
880///     8.0, 9.0, 0.8, 0.9, 1.0,
881///     9.0, 10.0, 0.9, 1.0, 1.1,
882///     10.0, 11.0, 1.0, 1.1, 1.2,
883/// ]).expect("Operation failed");
884///
885/// // Target values
886/// let y = array![5.0, 8.0, 11.0, 14.0, 17.0, 20.0, 23.0, 26.0, 29.0, 32.0];
887///
888/// // Perform elastic net regression with alpha=0.1 and l1_ratio=0.5
889/// let result = elastic_net(&x.view(), &y.view(), Some(0.1), Some(0.5), None, None, None, None, None).expect("Operation failed");
890///
891/// // Check that we got coefficients
892/// assert!(result.coefficients.len() > 0);
893/// ```
894#[allow(clippy::too_many_arguments)]
895#[allow(dead_code)]
896pub fn elastic_net<F>(
897    x: &ArrayView2<F>,
898    y: &ArrayView1<F>,
899    alpha: Option<F>,
900    l1_ratio: Option<F>,
901    fit_intercept: Option<bool>,
902    normalize: Option<bool>,
903    tol: Option<F>,
904    max_iter: Option<usize>,
905    conf_level: Option<F>,
906) -> StatsResult<RegressionResults<F>>
907where
908    F: Float
909        + std::iter::Sum<F>
910        + std::ops::Div<Output = F>
911        + std::fmt::Debug
912        + std::fmt::Display
913        + 'static
914        + scirs2_core::numeric::NumAssign
915        + scirs2_core::numeric::One
916        + scirs2_core::ndarray::ScalarOperand
917        + Send
918        + Sync,
919{
920    // Check input dimensions
921    if x.nrows() != y.len() {
922        return Err(StatsError::DimensionMismatch(format!(
923            "Input x has {} rows but y has length {}",
924            x.nrows(),
925            y.len()
926        )));
927    }
928
929    let n = x.nrows();
930    let p_features = x.ncols();
931
932    // Set default parameters
933    let alpha = alpha.unwrap_or_else(|| F::from(1.0).expect("Failed to convert constant to float"));
934    let l1_ratio =
935        l1_ratio.unwrap_or_else(|| F::from(0.5).expect("Failed to convert constant to float"));
936    let fit_intercept = fit_intercept.unwrap_or(true);
937    let normalize = normalize.unwrap_or(false);
938    let tol = tol.unwrap_or_else(|| F::from(1e-4).expect("Failed to convert constant to float"));
939    let max_iter = max_iter.unwrap_or(1000);
940    let conf_level =
941        conf_level.unwrap_or_else(|| F::from(0.95).expect("Failed to convert constant to float"));
942
943    if alpha < F::zero() {
944        return Err(StatsError::InvalidArgument(
945            "alpha must be non-negative".to_string(),
946        ));
947    }
948
949    if l1_ratio < F::zero() || l1_ratio > F::one() {
950        return Err(StatsError::InvalidArgument(
951            "l1_ratio must be between 0 and 1".to_string(),
952        ));
953    }
954
955    // If l1_ratio is 0, it's ridge regression
956    if l1_ratio < F::epsilon() {
957        return ridge_regression(
958            x,
959            y,
960            Some(alpha),
961            Some(fit_intercept),
962            Some(normalize),
963            Some(tol),
964            Some(max_iter),
965            Some(conf_level),
966        );
967    }
968
969    // If l1_ratio is 1, it's lasso regression
970    if crate::regression::utils::float_abs(l1_ratio - F::one()) < F::epsilon() {
971        return lasso_regression(
972            x,
973            y,
974            Some(alpha),
975            Some(fit_intercept),
976            Some(normalize),
977            Some(tol),
978            Some(max_iter),
979            Some(conf_level),
980        );
981    }
982
983    // Preprocess x and y
984    // `y_mean` is computed by `preprocessdata` but must NOT be folded back
985    // into the intercept by `transform_coefficients` -- see that function's
986    // doc comment. Kept `_`-prefixed here since preprocessdata's return
987    // shape is shared plumbing used by all four regularized-regression
988    // entry points.
989    let (x_processed, _y_mean, x_mean, x_std) = preprocessdata(x, y, fit_intercept, normalize)?;
990
991    // Total number of coefficients (including _intercept if fitted)
992    let p = if fit_intercept {
993        p_features + 1
994    } else {
995        p_features
996    };
997
998    // We need at least 2 observations for meaningful regression
999    if n < 2 {
1000        return Err(StatsError::InvalidArgument(
1001            "At least 2 observations required for elastic net regression".to_string(),
1002        ));
1003    }
1004
1005    // Initialize coefficients
1006    let mut coefficients = Array1::<F>::zeros(p);
1007
1008    // Calculate X'X and X'y for faster computations
1009    let xtx = x_processed.t().dot(&x_processed);
1010    let xty = x_processed.t().dot(y);
1011
1012    // Elastic net parameters
1013    let alpha_l1 = alpha * l1_ratio;
1014    let one_minus_l1_ratio = F::one() - l1_ratio;
1015    let alpha_l2 = alpha * one_minus_l1_ratio;
1016
1017    // Coordinate descent algorithm for elastic net
1018    let mut converged = false;
1019    let mut _iter = 0;
1020
1021    while !converged && _iter < max_iter {
1022        converged = true;
1023
1024        // Save old coefficients for convergence check
1025        let old_coefs = coefficients.clone();
1026
1027        // Update each coefficient in turn
1028        for j in 0..p {
1029            // Calculate partial residual
1030            let r_partial = xty[j]
1031                - xtx
1032                    .row(j)
1033                    .iter()
1034                    .zip(coefficients.iter())
1035                    .enumerate()
1036                    .filter(|&(i_, _)| i_ != j)
1037                    .map(|(_, (&xtx_ij, &coef_i))| xtx_ij * coef_i)
1038                    .sum::<F>();
1039
1040            // Apply soft thresholding with L2 adjustment
1041            let xtx_jj = xtx[[j, j]] + alpha_l2;
1042            if xtx_jj < F::epsilon() {
1043                coefficients[j] = F::zero();
1044                continue;
1045            }
1046
1047            if j == 0 && fit_intercept {
1048                // No L1 penalty for _intercept
1049                coefficients[j] = r_partial / xtx_jj;
1050            } else {
1051                // Apply soft thresholding for L1 penalty
1052                if crate::regression::utils::float_abs(r_partial) <= alpha_l1 {
1053                    coefficients[j] = F::zero();
1054                } else if r_partial > F::zero() {
1055                    coefficients[j] = (r_partial - alpha_l1) / xtx_jj;
1056                } else {
1057                    coefficients[j] = (r_partial + alpha_l1) / xtx_jj;
1058                }
1059            }
1060        }
1061
1062        // Check for convergence
1063        let coef_diff = (&coefficients - &old_coefs)
1064            .mapv(|x| scirs2_core::numeric::Float::abs(x))
1065            .sum();
1066        let coef_norm = old_coefs
1067            .mapv(|x| scirs2_core::numeric::Float::abs(x))
1068            .sum()
1069            .max(F::epsilon());
1070
1071        if coef_diff / coef_norm < tol {
1072            converged = true;
1073        }
1074
1075        _iter += 1;
1076    }
1077
1078    // If data was normalized/centered, transform coefficients back
1079    let transformed_coefficients = if normalize || fit_intercept {
1080        transform_coefficients(&coefficients, &x_mean, &x_std, fit_intercept)
1081    } else {
1082        coefficients.clone()
1083    };
1084
1085    // Calculate fitted values and residuals
1086    let x_design = if fit_intercept {
1087        add_intercept(x)
1088    } else {
1089        x.to_owned()
1090    };
1091
1092    let fitted_values = x_design.dot(&transformed_coefficients);
1093    let residuals = y.to_owned() - &fitted_values;
1094
1095    // Calculate degrees of freedom
1096    // For elastic net, df = number of non-zero coefficients, adjusted for L2 penalty
1097    let nonzero_coefs = transformed_coefficients
1098        .iter()
1099        .filter(|&&x| crate::regression::utils::float_abs(x) > F::epsilon())
1100        .count();
1101    let df_model = nonzero_coefs - if fit_intercept { 1 } else { 0 };
1102    let df_residuals = n - nonzero_coefs;
1103
1104    // Calculate sum of squares
1105    let (_y_mean, ss_total, ss_residual, ss_explained) =
1106        calculate_sum_of_squares(y, &residuals.view());
1107
1108    // Calculate R-squared and adjusted R-squared
1109    let r_squared = ss_explained / ss_total;
1110    let adj_r_squared = F::one()
1111        - (F::one() - r_squared) * F::from(n - 1).expect("Failed to convert to float")
1112            / F::from(df_residuals).expect("Failed to convert to float");
1113
1114    // Calculate mean squared error and residual standard error
1115    let mse = ss_residual / F::from(df_residuals).expect("Failed to convert to float");
1116    let residual_std_error = scirs2_core::numeric::Float::sqrt(mse);
1117
1118    // Calculate standard errors for coefficients (approximate)
1119    let std_errors = match calculate_elastic_net_std_errors(
1120        &x_design.view(),
1121        &residuals.view(),
1122        &transformed_coefficients,
1123        alpha_l2,
1124        df_residuals,
1125    ) {
1126        Ok(se) => se,
1127        Err(_) => Array1::<F>::zeros(p),
1128    };
1129
1130    // Calculate t-values
1131    let t_values = calculate_t_values(&transformed_coefficients, &std_errors);
1132
1133    // Calculate real two-sided per-coefficient p-values from the Student's
1134    // t-distribution (see `stat_tests::t_test_p_value`).
1135    let p_values = t_values.mapv(|t| t_test_p_value(t, df_residuals));
1136
1137    // Calculate confidence intervals
1138    let mut conf_intervals = Array2::<F>::zeros((p, 2));
1139    let z = norm_ppf(
1140        F::from(0.5).expect("Failed to convert constant to float") * (F::one() + conf_level),
1141    );
1142
1143    for i in 0..p {
1144        let margin = std_errors[i] * z;
1145        conf_intervals[[i, 0]] = transformed_coefficients[i] - margin;
1146        conf_intervals[[i, 1]] = transformed_coefficients[i] + margin;
1147    }
1148
1149    // Calculate F-statistic
1150    let f_statistic = if df_model > 0 && df_residuals > 0 {
1151        (ss_explained / F::from(df_model).expect("Failed to convert to float"))
1152            / (ss_residual / F::from(df_residuals).expect("Failed to convert to float"))
1153    } else {
1154        F::infinity()
1155    };
1156
1157    // Calculate p-value for F-statistic using the real F(df_model, df_residuals)
1158    // survival function (see `stat_tests::f_test_p_value`).
1159    let f_p_value = f_test_p_value(f_statistic, df_model, df_residuals);
1160
1161    // Create and return the results structure
1162    Ok(RegressionResults {
1163        coefficients: transformed_coefficients,
1164        std_errors,
1165        t_values,
1166        p_values,
1167        conf_intervals,
1168        r_squared,
1169        adj_r_squared,
1170        f_statistic,
1171        f_p_value,
1172        residual_std_error,
1173        df_residuals,
1174        residuals,
1175        fitted_values,
1176        inlier_mask: vec![true; n], // All points are inliers in elastic net regression
1177    })
1178}
1179
1180/// Calculate standard errors for elastic net regression
1181#[allow(dead_code)]
1182fn calculate_elastic_net_std_errors<F>(
1183    x: &ArrayView2<F>,
1184    residuals: &ArrayView1<F>,
1185    coefficients: &Array1<F>,
1186    alpha_l2: F,
1187    df: usize,
1188) -> StatsResult<Array1<F>>
1189where
1190    F: Float
1191        + std::iter::Sum<F>
1192        + std::ops::Div<Output = F>
1193        + 'static
1194        + scirs2_core::numeric::NumAssign
1195        + scirs2_core::numeric::One
1196        + scirs2_core::ndarray::ScalarOperand
1197        + std::fmt::Display
1198        + Send
1199        + Sync,
1200{
1201    // Calculate the mean squared error of the residuals
1202    let mse = residuals
1203        .iter()
1204        .map(|&r| scirs2_core::numeric::Float::powi(r, 2))
1205        .sum::<F>()
1206        / F::from(df).expect("Failed to convert to float");
1207
1208    // Find non-zero coefficients
1209    let p = coefficients.len();
1210    let mut active_set = Vec::new();
1211
1212    for j in 0..p {
1213        if crate::regression::utils::float_abs(coefficients[j]) > F::epsilon() {
1214            active_set.push(j);
1215        }
1216    }
1217
1218    // If no active features, return zeros
1219    if active_set.is_empty() {
1220        return Ok(Array1::<F>::zeros(p));
1221    }
1222
1223    // Calculate X_active'X_active for active features
1224    let n_active = active_set.len();
1225    let mut xtx_active = Array2::<F>::zeros((n_active, n_active));
1226
1227    for (i, &idx_i) in active_set.iter().enumerate() {
1228        for (j, &idx_j) in active_set.iter().enumerate() {
1229            let x_i = x.column(idx_i);
1230            let x_j = x.column(idx_j);
1231
1232            xtx_active[[i, j]] = x_i.iter().zip(x_j.iter()).map(|(&xi, &xj)| xi * xj).sum();
1233
1234            // Add L2 penalty to diagonal
1235            if i == j {
1236                xtx_active[[i, j]] += alpha_l2;
1237            }
1238        }
1239    }
1240
1241    // Invert (X_active'X_active + alpha_l2*I)
1242    let xtx_active_inv = match inv(&xtx_active.view(), None) {
1243        Ok(inv_result) => inv_result,
1244        Err(_) => {
1245            // If inversion fails, return zeros for standard errors
1246            return Ok(Array1::<F>::zeros(p));
1247        }
1248    };
1249
1250    // Create full standard error vector
1251    let mut std_errors = Array1::<F>::zeros(p);
1252
1253    for (i, &idx) in active_set.iter().enumerate() {
1254        std_errors[idx] = scirs2_core::numeric::Float::sqrt(xtx_active_inv[[i, i]] * mse);
1255    }
1256
1257    Ok(std_errors)
1258}
1259
1260/// Perform group lasso regression (L1/L2 regularization with grouped variables).
1261///
1262/// Group lasso allows variables to be grouped together such that they are
1263/// either all included or all excluded from the model.
1264///
1265/// # Arguments
1266///
1267/// * `x` - Independent variables (design matrix)
1268/// * `y` - Dependent variable
1269/// * `groups` - Vector of group indices for each feature (0-based)
1270/// * `alpha` - Regularization strength (default: 1.0)
1271/// * `fit_intercept` - Whether to fit an intercept term (default: true)
1272/// * `normalize` - Whether to normalize the data before fitting (default: false)
1273/// * `tol` - Convergence tolerance (default: 1e-4)
1274/// * `max_iter` - Maximum number of iterations (default: 1000)
1275/// * `conf_level` - Confidence level for confidence intervals (default: 0.95)
1276///
1277/// # Returns
1278///
1279/// A RegressionResults struct with the regression results.
1280///
1281/// # Examples
1282///
1283/// ```ignore
1284/// use scirs2_core::ndarray::{array, Array2};
1285/// use scirs2_stats::group_lasso;
1286///
1287/// // Create a design matrix with 6 variables in 2 groups
1288/// let x = Array2::from_shape_vec((10, 6), vec![
1289///     1.0, 2.0, 3.0, 0.1, 0.2, 0.3,
1290///     2.0, 3.0, 4.0, 0.2, 0.3, 0.4,
1291///     3.0, 4.0, 5.0, 0.3, 0.4, 0.5,
1292///     4.0, 5.0, 6.0, 0.4, 0.5, 0.6,
1293///     5.0, 6.0, 7.0, 0.5, 0.6, 0.7,
1294///     6.0, 7.0, 8.0, 0.6, 0.7, 0.8,
1295///     7.0, 8.0, 9.0, 0.7, 0.8, 0.9,
1296///     8.0, 9.0, 10.0, 0.8, 0.9, 1.0,
1297///     9.0, 10.0, 11.0, 0.9, 1.0, 1.1,
1298///     10.0, 11.0, 12.0, 1.0, 1.1, 1.2,
1299/// ]).expect("Operation failed");
1300///
1301/// // Target values depend only on the first group (first 3 variables)
1302/// let y = array![10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0, 55.0];
1303///
1304/// // Define groups: first 3 variables in group 0, next 3 in group 1
1305/// let groups = vec![0, 0, 0, 1, 1, 1];
1306///
1307/// // Perform group lasso regression with alpha=0.1
1308/// let result = group_lasso(&x.view(), &y.view(), &groups, Some(0.1), None, None, None, None, None).expect("Operation failed");
1309///
1310/// // Check that we got coefficients
1311/// assert!(result.coefficients.len() > 0);
1312///
1313/// // Group lasso should ideally set all coefficients in group 1 to zero or near-zero
1314/// ```
1315#[allow(clippy::too_many_arguments)]
1316#[allow(dead_code)]
1317pub fn group_lasso<F>(
1318    x: &ArrayView2<F>,
1319    y: &ArrayView1<F>,
1320    groups: &[usize],
1321    alpha: Option<F>,
1322    fit_intercept: Option<bool>,
1323    normalize: Option<bool>,
1324    tol: Option<F>,
1325    max_iter: Option<usize>,
1326    conf_level: Option<F>,
1327) -> StatsResult<RegressionResults<F>>
1328where
1329    F: Float
1330        + std::iter::Sum<F>
1331        + std::ops::Div<Output = F>
1332        + std::fmt::Debug
1333        + std::fmt::Display
1334        + 'static
1335        + scirs2_core::numeric::NumAssign
1336        + scirs2_core::numeric::One
1337        + scirs2_core::ndarray::ScalarOperand
1338        + Send
1339        + Sync,
1340{
1341    // Check input dimensions
1342    if x.nrows() != y.len() {
1343        return Err(StatsError::DimensionMismatch(format!(
1344            "Input x has {} rows but y has length {}",
1345            x.nrows(),
1346            y.len()
1347        )));
1348    }
1349
1350    if x.ncols() != groups.len() {
1351        return Err(StatsError::DimensionMismatch(format!(
1352            "Number of columns in x ({}) must match length of groups ({})",
1353            x.ncols(),
1354            groups.len()
1355        )));
1356    }
1357
1358    let n = x.nrows();
1359    let p_features = x.ncols();
1360
1361    // Set default parameters
1362    let alpha = alpha.unwrap_or_else(|| F::from(1.0).expect("Failed to convert constant to float"));
1363    let fit_intercept = fit_intercept.unwrap_or(true);
1364    let normalize = normalize.unwrap_or(false);
1365    let tol = tol.unwrap_or_else(|| F::from(1e-4).expect("Failed to convert constant to float"));
1366    let max_iter = max_iter.unwrap_or(1000);
1367    let conf_level =
1368        conf_level.unwrap_or_else(|| F::from(0.95).expect("Failed to convert constant to float"));
1369
1370    if alpha < F::zero() {
1371        return Err(StatsError::InvalidArgument(
1372            "alpha must be non-negative".to_string(),
1373        ));
1374    }
1375
1376    // Preprocess x and y
1377    // `y_mean` is computed by `preprocessdata` but must NOT be folded back
1378    // into the intercept by `transform_coefficients` -- see that function's
1379    // doc comment. Kept `_`-prefixed here since preprocessdata's return
1380    // shape is shared plumbing used by all four regularized-regression
1381    // entry points.
1382    let (x_processed, _y_mean, x_mean, x_std) = preprocessdata(x, y, fit_intercept, normalize)?;
1383
1384    // Total number of coefficients (including _intercept if fitted)
1385    let p = if fit_intercept {
1386        p_features + 1
1387    } else {
1388        p_features
1389    };
1390
1391    // We need at least 2 observations for meaningful regression
1392    if n < 2 {
1393        return Err(StatsError::InvalidArgument(
1394            "At least 2 observations required for group lasso regression".to_string(),
1395        ));
1396    }
1397
1398    // Determine unique groups and group sizes
1399    let mut unique_groups = HashSet::new();
1400    for &g in groups {
1401        unique_groups.insert(g);
1402    }
1403
1404    let mut group_indices = Vec::new();
1405    for &g in &unique_groups {
1406        let mut indices = Vec::new();
1407        for (i, &group) in groups.iter().enumerate() {
1408            if group == g {
1409                indices.push(if fit_intercept { i + 1 } else { i });
1410            }
1411        }
1412        group_indices.push(indices);
1413    }
1414
1415    // Initialize coefficients
1416    let mut coefficients = Array1::<F>::zeros(p);
1417
1418    // Block coordinate descent algorithm for group lasso
1419    let mut converged = false;
1420    let mut _iter = 0;
1421
1422    while !converged && _iter < max_iter {
1423        converged = true;
1424
1425        // Save old coefficients for convergence check
1426        let old_coefs = coefficients.clone();
1427
1428        // Update _intercept if fitting
1429        if fit_intercept {
1430            let r = y - &x_processed
1431                .slice(s![.., 1..])
1432                .dot(&coefficients.slice(s![1..]));
1433            let r_sum: F = r.iter().cloned().sum();
1434            coefficients[0] = r_sum / F::from(r.len()).expect("Operation failed");
1435        }
1436
1437        // Update each group in turn
1438        for group in &group_indices {
1439            // Skip empty groups
1440            if group.is_empty() {
1441                continue;
1442            }
1443
1444            // Calculate partial residual for this group
1445            let mut r = y.to_owned();
1446
1447            // Subtract contribution of other variables
1448            for j in 0..p {
1449                if !group.contains(&j) {
1450                    let x_j = x_processed.column(j);
1451                    let beta_j = coefficients[j];
1452
1453                    for i in 0..n {
1454                        r[i] -= x_j[i] * beta_j;
1455                    }
1456                }
1457            }
1458
1459            // Extract group variables
1460            let mut x_group = Array2::<F>::zeros((n, group.len()));
1461            for (i, &idx) in group.iter().enumerate() {
1462                x_group.column_mut(i).assign(&x_processed.column(idx));
1463            }
1464
1465            // Calculate X_g'r
1466            let xtr = x_group.t().dot(&r);
1467
1468            // Calculate X_g'X_g
1469            let xtx = x_group.t().dot(&x_group);
1470
1471            // Calculate the group norm of X_g'r
1472            let xtr_norm = scirs2_core::numeric::Float::sqrt(
1473                xtr.iter()
1474                    .map(|&x| scirs2_core::numeric::Float::powi(x, 2))
1475                    .sum::<F>(),
1476            );
1477
1478            // Skip if the norm is too small
1479            if xtr_norm < alpha {
1480                for &idx in group {
1481                    coefficients[idx] = F::zero();
1482                }
1483                continue;
1484            }
1485
1486            // Solve for group coefficients
1487            let mut beta_group = match solve_group(xtr, xtx, alpha, tol, max_iter) {
1488                Ok(beta) => beta,
1489                Err(_) => Array1::<F>::zeros(group.len()),
1490            };
1491
1492            // Apply group shrinkage
1493            let beta_norm = scirs2_core::numeric::Float::sqrt(
1494                beta_group
1495                    .iter()
1496                    .map(|&x| scirs2_core::numeric::Float::powi(x, 2))
1497                    .sum::<F>(),
1498            );
1499            if beta_norm > F::epsilon() {
1500                let shrinkage = F::one().max((beta_norm - alpha) / beta_norm);
1501                beta_group = beta_group.mapv(|x| x * shrinkage);
1502            } else {
1503                beta_group.fill(F::zero());
1504            }
1505
1506            // Update coefficients
1507            for (i, &idx) in group.iter().enumerate() {
1508                coefficients[idx] = beta_group[i];
1509            }
1510        }
1511
1512        // Check for convergence
1513        let coef_diff = (&coefficients - &old_coefs)
1514            .mapv(|x| scirs2_core::numeric::Float::abs(x))
1515            .sum();
1516        let coef_norm = old_coefs
1517            .mapv(|x| scirs2_core::numeric::Float::abs(x))
1518            .sum()
1519            .max(F::epsilon());
1520
1521        if coef_diff / coef_norm < tol {
1522            converged = true;
1523        }
1524
1525        _iter += 1;
1526    }
1527
1528    // If data was normalized/centered, transform coefficients back
1529    let transformed_coefficients = if normalize || fit_intercept {
1530        transform_coefficients(&coefficients, &x_mean, &x_std, fit_intercept)
1531    } else {
1532        coefficients.clone()
1533    };
1534
1535    // Calculate fitted values and residuals
1536    let x_design = if fit_intercept {
1537        add_intercept(x)
1538    } else {
1539        x.to_owned()
1540    };
1541
1542    let fitted_values = x_design.dot(&transformed_coefficients);
1543    let residuals = y.to_owned() - &fitted_values;
1544
1545    // Calculate degrees of freedom
1546    // For group lasso, df = sum of group sizes for non-zero groups
1547    let mut nonzero_coefs = 0;
1548    let mut nonzero_groups = HashSet::new();
1549
1550    for (i, &g) in groups.iter().enumerate() {
1551        let idx = if fit_intercept { i + 1 } else { i };
1552        if crate::regression::utils::float_abs(transformed_coefficients[idx]) > F::epsilon() {
1553            nonzero_groups.insert(g);
1554        }
1555    }
1556
1557    for &g in &nonzero_groups {
1558        let groupsize = groups.iter().filter(|&&group| group == g).count();
1559        nonzero_coefs += groupsize;
1560    }
1561
1562    if fit_intercept
1563        && crate::regression::utils::float_abs(transformed_coefficients[0]) > F::epsilon()
1564    {
1565        nonzero_coefs += 1;
1566    }
1567
1568    let df_model = nonzero_coefs - if fit_intercept { 1 } else { 0 };
1569    let df_residuals = n - nonzero_coefs;
1570
1571    // Calculate sum of squares
1572    let (_y_mean, ss_total, ss_residual, ss_explained) =
1573        calculate_sum_of_squares(y, &residuals.view());
1574
1575    // Calculate R-squared and adjusted R-squared
1576    let r_squared = ss_explained / ss_total;
1577    let adj_r_squared = F::one()
1578        - (F::one() - r_squared) * F::from(n - 1).expect("Failed to convert to float")
1579            / F::from(df_residuals).expect("Failed to convert to float");
1580
1581    // Calculate mean squared error and residual standard error
1582    let mse = ss_residual / F::from(df_residuals).expect("Failed to convert to float");
1583    let residual_std_error = scirs2_core::numeric::Float::sqrt(mse);
1584
1585    // Calculate standard errors for coefficients (approximate)
1586    let std_errors = match calculate_group_lasso_std_errors(
1587        &x_design.view(),
1588        &residuals.view(),
1589        &transformed_coefficients,
1590        groups,
1591        fit_intercept,
1592        df_residuals,
1593    ) {
1594        Ok(se) => se,
1595        Err(_) => Array1::<F>::zeros(p),
1596    };
1597
1598    // Calculate t-values
1599    let t_values = calculate_t_values(&transformed_coefficients, &std_errors);
1600
1601    // Calculate real two-sided per-coefficient p-values from the Student's
1602    // t-distribution (see `stat_tests::t_test_p_value`).
1603    let p_values = t_values.mapv(|t| t_test_p_value(t, df_residuals));
1604
1605    // Calculate confidence intervals
1606    let mut conf_intervals = Array2::<F>::zeros((p, 2));
1607    let z = norm_ppf(
1608        F::from(0.5).expect("Failed to convert constant to float") * (F::one() + conf_level),
1609    );
1610
1611    for i in 0..p {
1612        let margin = std_errors[i] * z;
1613        conf_intervals[[i, 0]] = transformed_coefficients[i] - margin;
1614        conf_intervals[[i, 1]] = transformed_coefficients[i] + margin;
1615    }
1616
1617    // Calculate F-statistic
1618    let f_statistic = if df_model > 0 && df_residuals > 0 {
1619        (ss_explained / F::from(df_model).expect("Failed to convert to float"))
1620            / (ss_residual / F::from(df_residuals).expect("Failed to convert to float"))
1621    } else {
1622        F::infinity()
1623    };
1624
1625    // Calculate p-value for F-statistic using the real F(df_model, df_residuals)
1626    // survival function (see `stat_tests::f_test_p_value`).
1627    let f_p_value = f_test_p_value(f_statistic, df_model, df_residuals);
1628
1629    // Create and return the results structure
1630    Ok(RegressionResults {
1631        coefficients: transformed_coefficients,
1632        std_errors,
1633        t_values,
1634        p_values,
1635        conf_intervals,
1636        r_squared,
1637        adj_r_squared,
1638        f_statistic,
1639        f_p_value,
1640        residual_std_error,
1641        df_residuals,
1642        residuals,
1643        fitted_values,
1644        inlier_mask: vec![true; n], // All points are inliers in group lasso regression
1645    })
1646}
1647
1648/// Solve group lasso subproblem for a single group
1649#[allow(dead_code)]
1650fn solve_group<F>(
1651    xtr: Array1<F>,
1652    xtx: Array2<F>,
1653    _alpha: F,
1654    tol: F,
1655    max_iter: usize,
1656) -> StatsResult<Array1<F>>
1657where
1658    F: Float
1659        + std::iter::Sum<F>
1660        + std::ops::Div<Output = F>
1661        + 'static
1662        + scirs2_core::numeric::NumAssign
1663        + scirs2_core::numeric::One
1664        + scirs2_core::ndarray::ScalarOperand
1665        + std::fmt::Display
1666        + Send
1667        + Sync,
1668{
1669    let p = xtr.len();
1670
1671    // Initialize beta to zero
1672    let mut beta = Array1::<F>::zeros(p);
1673
1674    // Try to solve directly if possible
1675    match inv(&xtx.view(), None) {
1676        Ok(xtx_inv) => {
1677            beta = xtx_inv.dot(&xtr);
1678            return Ok(beta);
1679        }
1680        Err(_) => {
1681            // If direct solution fails, use iterative method
1682        }
1683    }
1684
1685    // Iterative method: gradient descent
1686    let mut _iter = 0;
1687    let mut converged = false;
1688
1689    // Learning rate
1690    let lr = F::from(0.01).expect("Failed to convert constant to float");
1691
1692    while !converged && _iter < max_iter {
1693        let old_beta = beta.clone();
1694
1695        // Gradient of squared loss: -X'r + X'X * beta
1696        let xtx_beta = xtx.dot(&beta);
1697        let grad = &xtx_beta - &xtr;
1698
1699        // Update beta
1700        let lr_grad = grad.mapv(|g| g * lr);
1701        beta = &beta - &lr_grad;
1702
1703        // Check for convergence
1704        let beta_diff = (&beta - &old_beta)
1705            .mapv(|x| scirs2_core::numeric::Float::abs(x))
1706            .sum();
1707        let beta_norm = old_beta
1708            .mapv(|x| scirs2_core::numeric::Float::abs(x))
1709            .sum()
1710            .max(F::epsilon());
1711
1712        if beta_diff / beta_norm < tol {
1713            converged = true;
1714        }
1715
1716        _iter += 1;
1717    }
1718
1719    Ok(beta)
1720}
1721
1722/// Calculate standard errors for group lasso regression
1723#[allow(dead_code)]
1724fn calculate_group_lasso_std_errors<F>(
1725    x: &ArrayView2<F>,
1726    residuals: &ArrayView1<F>,
1727    coefficients: &Array1<F>,
1728    groups: &[usize],
1729    fit_intercept: bool,
1730    df: usize,
1731) -> StatsResult<Array1<F>>
1732where
1733    F: Float
1734        + std::iter::Sum<F>
1735        + std::ops::Div<Output = F>
1736        + 'static
1737        + scirs2_core::numeric::NumAssign
1738        + scirs2_core::numeric::One
1739        + scirs2_core::ndarray::ScalarOperand
1740        + std::fmt::Display
1741        + Send
1742        + Sync,
1743{
1744    // Calculate the mean squared error of the residuals
1745    let mse = residuals
1746        .iter()
1747        .map(|&r| scirs2_core::numeric::Float::powi(r, 2))
1748        .sum::<F>()
1749        / F::from(df).expect("Failed to convert to float");
1750
1751    // Find non-zero groups
1752    let p = coefficients.len();
1753    let mut active_groups = HashSet::new();
1754
1755    for (i, &g) in groups.iter().enumerate() {
1756        let idx = if fit_intercept { i + 1 } else { i };
1757        if crate::regression::utils::float_abs(coefficients[idx]) > F::epsilon() {
1758            active_groups.insert(g);
1759        }
1760    }
1761
1762    // Create active set of indices
1763    let mut active_set = Vec::new();
1764
1765    if fit_intercept && crate::regression::utils::float_abs(coefficients[0]) > F::epsilon() {
1766        active_set.push(0);
1767    }
1768
1769    for (i, &g) in groups.iter().enumerate() {
1770        if active_groups.contains(&g) {
1771            let idx = if fit_intercept { i + 1 } else { i };
1772            active_set.push(idx);
1773        }
1774    }
1775
1776    // If no active features, return zeros
1777    if active_set.is_empty() {
1778        return Ok(Array1::<F>::zeros(p));
1779    }
1780
1781    // Calculate X_active'X_active for active features
1782    let n_active = active_set.len();
1783    let mut xtx_active = Array2::<F>::zeros((n_active, n_active));
1784
1785    for (i, &idx_i) in active_set.iter().enumerate() {
1786        for (j, &idx_j) in active_set.iter().enumerate() {
1787            let x_i = x.column(idx_i);
1788            let x_j = x.column(idx_j);
1789
1790            xtx_active[[i, j]] = x_i.iter().zip(x_j.iter()).map(|(&xi, &xj)| xi * xj).sum();
1791        }
1792    }
1793
1794    // Invert X_active'X_active
1795    let xtx_active_inv = match inv(&xtx_active.view(), None) {
1796        Ok(inv_result) => inv_result,
1797        Err(_) => {
1798            // If inversion fails, return zeros for standard errors
1799            return Ok(Array1::<F>::zeros(p));
1800        }
1801    };
1802
1803    // Create full standard error vector
1804    let mut std_errors = Array1::<F>::zeros(p);
1805
1806    for (i, &idx) in active_set.iter().enumerate() {
1807        std_errors[idx] = scirs2_core::numeric::Float::sqrt(xtx_active_inv[[i, i]] * mse);
1808    }
1809
1810    Ok(std_errors)
1811}
1812
1813// ---------------------------------------------------------------------------
1814// Sklearn-style Ridge estimator
1815// ---------------------------------------------------------------------------
1816
1817/// Fitted result produced by [`RidgeRegression::fit`].
1818pub struct FittedRidgeRegression<F>
1819where
1820    F: Float + std::fmt::Debug + std::fmt::Display + 'static,
1821{
1822    inner: crate::regression::RegressionResults<F>,
1823}
1824
1825impl<F> FittedRidgeRegression<F>
1826where
1827    F: Float
1828        + std::iter::Sum<F>
1829        + std::ops::Div<Output = F>
1830        + std::fmt::Debug
1831        + std::fmt::Display
1832        + 'static
1833        + scirs2_core::numeric::NumAssign
1834        + scirs2_core::numeric::One
1835        + scirs2_core::ndarray::ScalarOperand
1836        + Send
1837        + Sync,
1838{
1839    /// Predict target values for a new design matrix.
1840    pub fn predict(
1841        &self,
1842        x: &scirs2_core::ndarray::ArrayView2<F>,
1843    ) -> crate::error::StatsResult<scirs2_core::ndarray::Array1<F>> {
1844        if x.ncols() != self.inner.coefficients.len() {
1845            return Err(crate::error::StatsError::DimensionMismatch(format!(
1846                "predict: x has {} columns but model has {} coefficients",
1847                x.ncols(),
1848                self.inner.coefficients.len()
1849            )));
1850        }
1851        Ok(x.dot(&self.inner.coefficients))
1852    }
1853
1854    /// Return the fitted coefficients.
1855    pub fn coefficients(&self) -> &scirs2_core::ndarray::Array1<F> {
1856        &self.inner.coefficients
1857    }
1858}
1859
1860/// L2-regularised (ridge) regression estimator.
1861///
1862/// This is a thin, sklearn-style wrapper around [`ridge_regression`].
1863///
1864/// # Examples
1865///
1866/// ```
1867/// use scirs2_core::ndarray::{array, Array2};
1868/// use scirs2_stats::regression::RidgeRegression;
1869///
1870/// let x = Array2::from_shape_vec((5, 2), vec![
1871///     1.0_f64, 0.0, 1.0, 1.0, 1.0, 2.0, 1.0, 3.0, 1.0, 4.0,
1872/// ]).expect("shape ok");
1873/// let y = array![1.0_f64, 3.0, 5.0, 7.0, 9.0];
1874///
1875/// let mut model = RidgeRegression::new(0.5);
1876/// let fitted = model.fit(&x.view(), &y.view()).expect("fit ok");
1877/// let preds = fitted.predict(&x.view()).expect("predict ok");
1878/// assert_eq!(preds.len(), 5);
1879/// ```
1880#[derive(Debug, Clone)]
1881pub struct RidgeRegression {
1882    alpha: f64,
1883}
1884
1885impl RidgeRegression {
1886    /// Create a new (unfitted) ridge regression model.
1887    ///
1888    /// # Arguments
1889    ///
1890    /// * `alpha` – Regularisation strength. Larger values specify stronger regularisation.
1891    pub fn new(alpha: f64) -> Self {
1892        Self { alpha }
1893    }
1894
1895    /// Fit the model to training data `(x, y)`.
1896    pub fn fit(
1897        &mut self,
1898        x: &scirs2_core::ndarray::ArrayView2<f64>,
1899        y: &scirs2_core::ndarray::ArrayView1<f64>,
1900    ) -> crate::error::StatsResult<FittedRidgeRegression<f64>> {
1901        // Use fit_intercept=false so the caller controls the design matrix layout,
1902        // matching the behaviour of LinearRegression::fit (which calls linear_regression
1903        // and never adds an implicit intercept column).
1904        let inner = ridge_regression(x, y, Some(self.alpha), Some(false), None, None, None, None)?;
1905        Ok(FittedRidgeRegression { inner })
1906    }
1907}
1908
1909// Tests live in `regularized_tests.rs` (split out to keep this
1910// implementation file under the workspace's 2000-line guideline).
1911#[cfg(test)]
1912#[path = "regularized_tests.rs"]
1913mod tests;