Skip to main content

regression_diagnostics/regularized/
elastic_net.rs

1use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
2
3use crate::error::{RegressionError, Result};
4use crate::linalg::dmatrix_from_rows;
5
6/// A fitted **elastic-net** regression model and its diagnostics.
7///
8/// Elastic net minimizes
9///
10/// `(1/2n)‖y − Xβ‖² + λ[ α‖β_pen‖₁ + ½(1 − α)‖β_pen‖² ]`,
11///
12/// blending the lasso (`α = 1`) and ridge (`α → 0`) penalties. The `α` mixing
13/// parameter controls sparsity-versus-grouping: pure lasso arbitrarily picks one
14/// of a set of correlated predictors, while a little ridge (`α < 1`) shares the
15/// coefficient across the group. Fit by **cyclic coordinate descent** with
16/// soft-thresholding (glmnet-style).
17///
18/// # Intercept and scaling
19///
20/// Identical conventions to [`LassoFit`](super::LassoFit): a detected constant
21/// column is an **unpenalized intercept**, predictors are **standardized
22/// internally**, and `λ` is on the standardized `(1/2n)`-objective scale.
23///
24/// # Effective degrees of freedom
25///
26/// Unlike lasso, where the degrees of freedom are just the active-set size, the
27/// ridge part shrinks the surviving coefficients, so the elastic net spends
28/// *fewer* than `|active set|` degrees of freedom. This type reports the
29/// shrinkage-aware trace
30///
31/// `df = tr[ Z_A (Z_Aᵀ Z_A + n·λ(1 − α) I)⁻¹ Z_Aᵀ ] (+1 for the intercept)`,
32///
33/// over the standardized active columns `Z_A`. It reduces to `|active set|` at
34/// `α = 1` (recovering the lasso count) and to the full ridge effective df when
35/// nothing is zeroed. The information criteria use it.
36#[derive(Debug, Clone)]
37pub struct ElasticNetFit {
38    x: Array2<f64>,
39    y: Array1<f64>,
40    lambda: f64,
41    alpha: f64,
42    coefficients: Array1<f64>,
43    fitted: Array1<f64>,
44    residuals: Array1<f64>,
45    rss: f64,
46    intercept_col: Option<usize>,
47    n_nonzero: usize,
48    effective_df: f64,
49    iterations: usize,
50    n: usize,
51    p: usize,
52}
53
54impl ElasticNetFit {
55    /// Fit elastic-net regression of `y` on `X` with penalty `lambda ≥ 0` and
56    /// mixing `alpha ∈ [0, 1]` (default tolerance `1e-7`, up to `10_000` sweeps).
57    ///
58    /// `alpha = 1` is pure lasso (equivalent to [`LassoFit`](super::LassoFit));
59    /// `alpha = 0` is pure ridge on the standardized scale.
60    ///
61    /// # Errors
62    ///
63    /// * [`RegressionError::EmptyInput`] / [`RegressionError::ShapeMismatch`].
64    /// * [`RegressionError::InvalidParameter`] if `lambda < 0` or `alpha ∉ [0, 1]`.
65    /// * [`RegressionError::NotConverged`] if coordinate descent does not
66    ///   converge within the iteration budget.
67    pub fn new(x: Array2<f64>, y: Array1<f64>, lambda: f64, alpha: f64) -> Result<Self> {
68        Self::with_options(x, y, lambda, alpha, 1e-7, 10_000)
69    }
70
71    /// Like [`ElasticNetFit::new`] with an explicit tolerance and sweep cap.
72    pub fn with_options(
73        x: Array2<f64>,
74        y: Array1<f64>,
75        lambda: f64,
76        alpha: f64,
77        tol: f64,
78        max_iter: usize,
79    ) -> Result<Self> {
80        if x.nrows() == 0 || x.ncols() == 0 {
81            return Err(RegressionError::EmptyInput { what: "X" });
82        }
83        if y.len() != x.nrows() {
84            return Err(RegressionError::ShapeMismatch {
85                what: "y length vs X rows",
86                expected: x.nrows(),
87                got: y.len(),
88            });
89        }
90        if lambda < 0.0 || lambda.is_nan() {
91            return Err(RegressionError::InvalidParameter {
92                msg: format!("elastic-net lambda must be >= 0, got {lambda}"),
93            });
94        }
95        if !(0.0..=1.0).contains(&alpha) {
96            return Err(RegressionError::InvalidParameter {
97                msg: format!("elastic-net alpha must be in [0, 1], got {alpha}"),
98            });
99        }
100
101        let n = x.nrows();
102        let p = x.ncols();
103        let intercept_col = detect_constant_column(&x);
104        let has_intercept = intercept_col.is_some();
105        let pred: Vec<usize> = (0..p).filter(|&j| Some(j) != intercept_col).collect();
106        let q = pred.len();
107
108        let nf = n as f64;
109        let y_mean = if has_intercept { y.sum() / nf } else { 0.0 };
110
111        // Standardize predictors (population sd), exactly as lasso does.
112        let mut means = vec![0.0; q];
113        let mut sds = vec![1.0; q];
114        for (k, &j) in pred.iter().enumerate() {
115            let col = x.column(j);
116            let m = if has_intercept { col.sum() / nf } else { 0.0 };
117            means[k] = m;
118            let sd = (col.iter().map(|v| (v - m).powi(2)).sum::<f64>() / nf).sqrt();
119            sds[k] = if sd > 0.0 { sd } else { 1.0 };
120        }
121        let mut z = vec![0.0f64; n * q];
122        for i in 0..n {
123            for (k, &j) in pred.iter().enumerate() {
124                z[i * q + k] = (x[(i, j)] - means[k]) / sds[k];
125            }
126        }
127        let yc: Vec<f64> = (0..n).map(|i| y[i] - y_mean).collect();
128
129        // Coordinate descent with the elastic-net update:
130        //   β_k = soft(ρ_k, λα) / (1 + λ(1−α)),  ρ_k = (1/n) z_kᵀ r + β_k.
131        let l1 = lambda * alpha;
132        let l2 = lambda * (1.0 - alpha);
133        let mut beta = vec![0.0f64; q];
134        let mut r = yc.clone();
135        let mut iterations = 0usize;
136        let mut converged = false;
137        while iterations < max_iter {
138            iterations += 1;
139            let mut max_delta = 0.0f64;
140            for k in 0..q {
141                let mut zr = 0.0;
142                for i in 0..n {
143                    zr += z[i * q + k] * r[i];
144                }
145                let rho = zr / nf + beta[k];
146                let new = soft_threshold(rho, l1) / (1.0 + l2);
147                let delta = new - beta[k];
148                if delta != 0.0 {
149                    for i in 0..n {
150                        r[i] -= z[i * q + k] * delta;
151                    }
152                    beta[k] = new;
153                    max_delta = max_delta.max(delta.abs());
154                }
155            }
156            if max_delta < tol {
157                converged = true;
158                break;
159            }
160        }
161        if !converged {
162            return Err(RegressionError::NotConverged {
163                iterations,
164                msg: "elastic-net coordinate descent did not reach tolerance".into(),
165            });
166        }
167
168        // Back to original scale.
169        let mut coefficients = Array1::<f64>::zeros(p);
170        let mut slopes_orig = vec![0.0; q];
171        for (k, &j) in pred.iter().enumerate() {
172            let b = beta[k] / sds[k];
173            slopes_orig[k] = b;
174            coefficients[j] = b;
175        }
176        if let Some(c) = intercept_col {
177            coefficients[c] = y_mean - (0..q).map(|k| means[k] * slopes_orig[k]).sum::<f64>();
178        }
179
180        let fitted = x.dot(&coefficients);
181        let residuals = &y - &fitted;
182        let rss: f64 = residuals.iter().map(|e| e * e).sum();
183
184        // Active set (standardized indices).
185        let active: Vec<usize> = (0..q).filter(|&k| beta[k].abs() > 1e-12).collect();
186        let n_nonzero = active.len();
187        let effective_df = elastic_net_df(&z, n, q, &active, nf * l2)
188            + if has_intercept { 1.0 } else { 0.0 };
189
190        Ok(Self {
191            x,
192            y,
193            lambda,
194            alpha,
195            coefficients,
196            fitted,
197            residuals,
198            rss,
199            intercept_col,
200            n_nonzero,
201            effective_df,
202            iterations,
203            n,
204            p,
205        })
206    }
207
208    /// The penalty `λ` this model was fit with.
209    pub fn lambda(&self) -> f64 {
210        self.lambda
211    }
212
213    /// The mixing parameter `α` (1 = lasso, 0 = ridge).
214    pub fn alpha(&self) -> f64 {
215        self.alpha
216    }
217
218    /// Number of observations.
219    pub fn n_observations(&self) -> usize {
220        self.n
221    }
222
223    /// Number of coefficients (design columns, intercept included).
224    pub fn n_parameters(&self) -> usize {
225        self.p
226    }
227
228    /// Whether an unpenalized intercept is present.
229    pub fn has_intercept(&self) -> bool {
230        self.intercept_col.is_some()
231    }
232
233    /// Coordinate-descent sweeps taken to converge.
234    pub fn iterations(&self) -> usize {
235        self.iterations
236    }
237
238    /// The design matrix as fitted.
239    pub fn design_matrix(&self) -> ArrayView2<'_, f64> {
240        self.x.view()
241    }
242
243    /// Elastic-net coefficients, aligned to the design columns. Coefficients
244    /// shrunk out are exactly `0.0`.
245    pub fn coefficients(&self) -> ArrayView1<'_, f64> {
246        self.coefficients.view()
247    }
248
249    /// Fitted values `ŷ = Xβ`.
250    pub fn fitted_values(&self) -> ArrayView1<'_, f64> {
251        self.fitted.view()
252    }
253
254    /// Residuals `y − ŷ`.
255    pub fn residuals(&self) -> ArrayView1<'_, f64> {
256        self.residuals.view()
257    }
258
259    /// Residual sum of squares.
260    pub fn residual_sum_of_squares(&self) -> f64 {
261        self.rss
262    }
263
264    /// Response vector.
265    pub fn response(&self) -> ArrayView1<'_, f64> {
266        self.y.view()
267    }
268
269    /// Indices of the design columns with non-zero coefficients — the **active
270    /// set** (excludes the intercept).
271    pub fn active_set(&self) -> Vec<usize> {
272        (0..self.p)
273            .filter(|&j| Some(j) != self.intercept_col && self.coefficients[j].abs() > 1e-12)
274            .collect()
275    }
276
277    /// Number of non-zero penalized coefficients (active-set size).
278    pub fn n_nonzero(&self) -> usize {
279        self.n_nonzero
280    }
281
282    /// Shrinkage-aware **effective degrees of freedom** (see the type docs):
283    /// the active-set trace under the ridge part, plus one for the intercept.
284    pub fn effective_df(&self) -> f64 {
285        self.effective_df
286    }
287
288    /// Gaussian log-likelihood at the fitted residual variance.
289    pub fn log_likelihood(&self) -> f64 {
290        let n = self.n as f64;
291        -0.5 * n * ((2.0 * std::f64::consts::PI).ln() + 1.0 + (self.rss / n).ln())
292    }
293
294    /// AIC using the effective degrees of freedom as the parameter count.
295    pub fn aic(&self) -> f64 {
296        -2.0 * self.log_likelihood() + 2.0 * self.effective_df
297    }
298
299    /// BIC using the effective degrees of freedom.
300    pub fn bic(&self) -> f64 {
301        -2.0 * self.log_likelihood() + (self.n as f64).ln() * self.effective_df
302    }
303}
304
305/// Trace of the ridge hat matrix on the standardized active columns:
306/// `tr[ Z_A (Z_Aᵀ Z_A + ridge·I)⁻¹ Z_Aᵀ ] = tr[ (Z_AᵀZ_A + ridge·I)⁻¹ Z_AᵀZ_A ]`,
307/// where `ridge = n·λ(1−α)`. Equals `|A|` when `ridge = 0`.
308fn elastic_net_df(z: &[f64], n: usize, q: usize, active: &[usize], ridge: f64) -> f64 {
309    let a = active.len();
310    if a == 0 {
311        return 0.0;
312    }
313    if ridge <= 0.0 {
314        return a as f64; // projection onto the active columns
315    }
316    // Gram matrix G = Z_AᵀZ_A over the active columns.
317    let mut g = Array2::<f64>::zeros((a, a));
318    for (ii, &ki) in active.iter().enumerate() {
319        for (jj, &kj) in active.iter().enumerate() {
320            let mut s = 0.0;
321            for row in 0..n {
322                s += z[row * q + ki] * z[row * q + kj];
323            }
324            g[(ii, jj)] = s;
325        }
326    }
327    // M = G + ridge·I.
328    let mut m = g.clone();
329    for d in 0..a {
330        m[(d, d)] += ridge;
331    }
332    let m_dm = dmatrix_from_rows(a, a, m.as_standard_layout().as_slice().unwrap());
333    let inv = match m_dm.try_inverse() {
334        Some(inv) => inv,
335        None => return a as f64,
336    };
337    // trace(M⁻¹ G) = Σ_ij inv[i,j] · G[j,i].
338    let mut tr = 0.0;
339    for i in 0..a {
340        for j in 0..a {
341            tr += inv[(i, j)] * g[(j, i)];
342        }
343    }
344    tr
345}
346
347/// Soft-thresholding operator `sign(a)·max(|a| − λ, 0)`.
348fn soft_threshold(a: f64, lambda: f64) -> f64 {
349    if a > lambda {
350        a - lambda
351    } else if a < -lambda {
352        a + lambda
353    } else {
354        0.0
355    }
356}
357
358/// Detect the first constant column (treated as the intercept).
359fn detect_constant_column(x: &Array2<f64>) -> Option<usize> {
360    for (j, col) in x.columns().into_iter().enumerate() {
361        let first = col[0];
362        let scale = first.abs().max(1.0);
363        if col.iter().all(|&v| (v - first).abs() <= 1e-12 * scale) {
364            return Some(j);
365        }
366    }
367    None
368}