regression_diagnostics/regularized/
elastic_net.rs1use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
2
3use crate::error::{RegressionError, Result};
4use crate::linalg::dmatrix_from_rows;
5
6#[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 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 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 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 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 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 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 pub fn lambda(&self) -> f64 {
210 self.lambda
211 }
212
213 pub fn alpha(&self) -> f64 {
215 self.alpha
216 }
217
218 pub fn n_observations(&self) -> usize {
220 self.n
221 }
222
223 pub fn n_parameters(&self) -> usize {
225 self.p
226 }
227
228 pub fn has_intercept(&self) -> bool {
230 self.intercept_col.is_some()
231 }
232
233 pub fn iterations(&self) -> usize {
235 self.iterations
236 }
237
238 pub fn design_matrix(&self) -> ArrayView2<'_, f64> {
240 self.x.view()
241 }
242
243 pub fn coefficients(&self) -> ArrayView1<'_, f64> {
246 self.coefficients.view()
247 }
248
249 pub fn fitted_values(&self) -> ArrayView1<'_, f64> {
251 self.fitted.view()
252 }
253
254 pub fn residuals(&self) -> ArrayView1<'_, f64> {
256 self.residuals.view()
257 }
258
259 pub fn residual_sum_of_squares(&self) -> f64 {
261 self.rss
262 }
263
264 pub fn response(&self) -> ArrayView1<'_, f64> {
266 self.y.view()
267 }
268
269 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 pub fn n_nonzero(&self) -> usize {
279 self.n_nonzero
280 }
281
282 pub fn effective_df(&self) -> f64 {
285 self.effective_df
286 }
287
288 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 pub fn aic(&self) -> f64 {
296 -2.0 * self.log_likelihood() + 2.0 * self.effective_df
297 }
298
299 pub fn bic(&self) -> f64 {
301 -2.0 * self.log_likelihood() + (self.n as f64).ln() * self.effective_df
302 }
303}
304
305fn 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; }
316 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 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 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
347fn 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
358fn 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}