1use ndarray::{Array1, Array2, ArrayView1, ArrayView2, Axis};
7
8use super::lasso::{lasso_path, Lasso};
9use super::logistic::LogisticRegression;
10use super::{Classifier, Regressor};
11use crate::error::{DmlError, Result};
12
13const RESOLUTION: f64 = 1e-15;
15
16pub(super) fn check_cv(n: usize, cv: usize) -> Result<()> {
17 if cv < 2 {
18 return Err(DmlError::InvalidArgument("cv must be at least 2".into()));
19 }
20 if n < cv {
21 return Err(DmlError::InvalidArgument(format!(
22 "cannot cross-validate {n} observations with {cv} folds"
23 )));
24 }
25 Ok(())
26}
27
28pub(super) fn kfold_test_sets(n: usize, cv: usize) -> Vec<Vec<usize>> {
31 let mut start = 0;
32 (0..cv)
33 .map(|k| {
34 let size = n / cv + usize::from(k < n % cv);
35 let block = (start..start + size).collect();
36 start += size;
37 block
38 })
39 .collect()
40}
41
42pub(super) fn stratified_test_sets(y: ArrayView1<'_, f64>, cv: usize) -> Result<Vec<Vec<usize>>> {
46 let n = y.len();
47 let first = y[0];
48 let n_first = y.iter().filter(|&&v| v == first).count();
49 let counts = [n_first, n - n_first];
50 if counts.iter().all(|&c| c < cv) {
51 return Err(DmlError::InvalidArgument(format!(
52 "cv = {cv} is larger than the number of members in each class"
53 )));
54 }
55 let dealt = |i: usize, upto: usize| if i < upto { (upto - 1 - i) / cv + 1 } else { 0 };
58 let mut fold_of = vec![0usize; n];
59 for (class, &count) in counts.iter().enumerate() {
60 let folds: Vec<usize> = (0..cv)
61 .flat_map(|k| {
62 let in_first = dealt(k, n_first);
63 let m = if class == 0 {
64 in_first
65 } else {
66 dealt(k, n) - in_first
67 };
68 std::iter::repeat(k).take(m)
69 })
70 .collect();
71 debug_assert_eq!(folds.len(), count);
72 let members = (0..n).filter(|&i| (y[i] == first) == (class == 0));
73 for (i, k) in members.zip(folds) {
74 fold_of[i] = k;
75 }
76 }
77 Ok((0..cv)
78 .map(|k| (0..n).filter(|&i| fold_of[i] == k).collect())
79 .collect())
80}
81
82pub(super) fn map_folds<T: Send>(
85 folds: &[Vec<usize>],
86 fit_fold: impl Fn(&[usize]) -> Result<T> + Sync,
87) -> Result<Vec<T>> {
88 #[cfg(feature = "parallel")]
89 {
90 use rayon::prelude::*;
91 folds.par_iter().map(|test| fit_fold(test)).collect()
92 }
93 #[cfg(not(feature = "parallel"))]
94 {
95 folds.iter().map(|test| fit_fold(test)).collect()
96 }
97}
98
99pub(super) fn complement(n: usize, test: &[usize]) -> Vec<usize> {
100 let mut in_test = vec![false; n];
101 for &i in test {
102 in_test[i] = true;
103 }
104 (0..n).filter(|&i| !in_test[i]).collect()
105}
106
107fn geomspace(start: f64, stop: f64, n: usize) -> Vec<f64> {
110 if n == 1 {
111 return vec![start];
112 }
113 let (a, b) = (start.log10(), stop.log10());
114 let step = (b - a) / (n - 1) as f64;
115 let mut out: Vec<f64> = (0..n).map(|i| 10f64.powf(a + i as f64 * step)).collect();
116 out[0] = start;
117 out[n - 1] = stop;
118 out
119}
120
121fn average(values: impl Iterator<Item = f64>, w: Option<ArrayView1<'_, f64>>) -> f64 {
123 match w {
124 None => {
125 let (sum, count) = values.fold((0.0, 0usize), |(s, c), v| (s + v, c + 1));
126 sum / count as f64
127 }
128 Some(w) => {
129 let sum: f64 = values.zip(w.iter()).map(|(v, &wi)| v * wi).sum();
130 sum / w.sum()
131 }
132 }
133}
134
135#[derive(Debug, Clone)]
146pub struct LassoCV {
147 n_alphas: usize,
148 eps: f64,
149 alphas: Option<Vec<f64>>,
150 cv: usize,
151 tol: f64,
152 max_iter: usize,
153 fitted: Option<LassoCvFit>,
154}
155
156#[derive(Debug, Clone)]
157struct LassoCvFit {
158 alpha: f64,
159 alphas: Vec<f64>,
160 mse_path: Array2<f64>,
161 model: Lasso,
162}
163
164impl Default for LassoCV {
165 fn default() -> Self {
166 Self::new()
167 }
168}
169
170impl LassoCV {
171 pub fn new() -> Self {
173 Self {
174 n_alphas: 100,
175 eps: 1e-3,
176 alphas: None,
177 cv: 5,
178 tol: 1e-4,
179 max_iter: 100_000,
180 fitted: None,
181 }
182 }
183
184 pub fn n_alphas(mut self, n_alphas: usize) -> Self {
186 self.n_alphas = n_alphas.max(1);
187 self
188 }
189
190 pub fn eps(mut self, eps: f64) -> Self {
192 self.eps = eps;
193 self
194 }
195
196 pub fn alphas(mut self, alphas: Vec<f64>) -> Self {
198 self.alphas = Some(alphas);
199 self
200 }
201
202 pub fn cv(mut self, cv: usize) -> Self {
204 self.cv = cv;
205 self
206 }
207
208 pub fn tol(mut self, tol: f64) -> Self {
210 self.tol = tol;
211 self
212 }
213
214 pub fn max_iter(mut self, max_iter: usize) -> Self {
215 self.max_iter = max_iter;
216 self
217 }
218
219 pub fn alpha(&self) -> Option<f64> {
221 self.fitted.as_ref().map(|f| f.alpha)
222 }
223
224 pub fn alphas_path(&self) -> Option<&[f64]> {
226 self.fitted.as_ref().map(|f| f.alphas.as_slice())
227 }
228
229 pub fn mse_path(&self) -> Option<&Array2<f64>> {
231 self.fitted.as_ref().map(|f| &f.mse_path)
232 }
233
234 pub fn coef(&self) -> Option<&Array1<f64>> {
235 self.fitted.as_ref().and_then(|f| f.model.coef())
236 }
237
238 pub fn intercept(&self) -> f64 {
239 self.fitted.as_ref().map_or(0.0, |f| f.model.intercept())
240 }
241
242 fn grid(
243 &self,
244 x: ArrayView2<'_, f64>,
245 y: ArrayView1<'_, f64>,
246 w: Option<ArrayView1<'_, f64>>,
247 ) -> Result<Vec<f64>> {
248 if let Some(alphas) = &self.alphas {
249 if alphas.is_empty() || alphas.iter().any(|a| !(a.is_finite() && *a >= 0.0)) {
250 return Err(DmlError::InvalidArgument(
251 "alphas must be non-empty, finite and non-negative".into(),
252 ));
253 }
254 let mut a = alphas.clone();
255 a.sort_by(|p, q| q.total_cmp(p));
256 return Ok(a);
257 }
258 let c = super::center(x, y, w, true)?;
259 let alpha_max =
260 c.xc.t()
261 .dot(&c.yc)
262 .iter()
263 .fold(0.0_f64, |m, v| m.max(v.abs()))
264 / x.nrows() as f64;
265 if alpha_max <= RESOLUTION {
266 return Ok(vec![RESOLUTION; self.n_alphas]);
267 }
268 Ok(geomspace(alpha_max, alpha_max * self.eps, self.n_alphas))
269 }
270
271 fn fit_impl(
272 &mut self,
273 x: ArrayView2<'_, f64>,
274 y: ArrayView1<'_, f64>,
275 w: Option<ArrayView1<'_, f64>>,
276 ) -> Result<()> {
277 let n = x.nrows();
278 check_cv(n, self.cv)?;
279 let alphas = self.grid(x, y, w)?;
280 let fold_mse = map_folds(&kfold_test_sets(n, self.cv), |test| {
282 let train = complement(n, test);
283 let w_train = w.map(|w| w.select(Axis(0), &train));
284 let w_test = w.map(|w| w.select(Axis(0), test));
285 let path = lasso_path(
286 x.select(Axis(0), &train).view(),
287 y.select(Axis(0), &train).view(),
288 w_train.as_ref().map(|w| w.view()),
289 &alphas,
290 self.tol,
291 self.max_iter,
292 )?;
293 let (x_test, y_test) = (x.select(Axis(0), test), y.select(Axis(0), test));
294 Ok(path
295 .iter()
296 .map(|(coef, intercept)| {
297 let pred = x_test.dot(coef) + *intercept;
298 let sq = pred.iter().zip(&y_test).map(|(p, t)| (p - t).powi(2));
299 average(sq, w_test.as_ref().map(|w| w.view()))
300 })
301 .collect::<Vec<f64>>())
302 })?;
303 let mse_path = Array2::from_shape_fn((alphas.len(), self.cv), |(a, k)| fold_mse[k][a]);
304 let mean_mse = mse_path.mean_axis(Axis(1)).expect("cv >= 2");
306 let best = (0..alphas.len()).fold(0, |b, a| if mean_mse[a] < mean_mse[b] { a } else { b });
307 let mut model = Lasso::new(alphas[best])
308 .tol(self.tol)
309 .max_iter(self.max_iter);
310 match w {
311 Some(w) => model.fit_weighted(x, y, w)?,
312 None => model.fit(x, y)?,
313 }
314 self.fitted = Some(LassoCvFit {
315 alpha: alphas[best],
316 alphas,
317 mse_path,
318 model,
319 });
320 Ok(())
321 }
322}
323
324impl Regressor for LassoCV {
325 fn fit(&mut self, x: ArrayView2<'_, f64>, y: ArrayView1<'_, f64>) -> Result<()> {
326 self.fit_impl(x, y, None)
327 }
328
329 fn fit_weighted(
331 &mut self,
332 x: ArrayView2<'_, f64>,
333 y: ArrayView1<'_, f64>,
334 w: ArrayView1<'_, f64>,
335 ) -> Result<()> {
336 self.fit_impl(x, y, Some(w))
337 }
338
339 fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array1<f64>> {
340 self.fitted
341 .as_ref()
342 .ok_or(DmlError::NotFitted)?
343 .model
344 .predict(x)
345 }
346
347 fn fresh(&self) -> Box<dyn Regressor> {
348 Box::new(Self {
349 fitted: None,
350 ..self.clone()
351 })
352 }
353}
354
355#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
357pub enum CvScoring {
358 #[default]
362 LogLoss,
363 Accuracy,
366}
367
368#[derive(Debug, Clone)]
377pub struct LogisticRegressionCV {
378 cs: Vec<f64>,
379 cv: usize,
380 scoring: CvScoring,
381 tol: f64,
382 max_iter: usize,
383 fitted: Option<LogitCvFit>,
384}
385
386#[derive(Debug, Clone)]
387struct LogitCvFit {
388 c: f64,
389 scores: Array2<f64>,
390 model: LogisticRegression,
391}
392
393impl Default for LogisticRegressionCV {
394 fn default() -> Self {
395 Self::new()
396 }
397}
398
399impl LogisticRegressionCV {
400 pub fn new() -> Self {
402 Self {
403 cs: geomspace(1e-4, 1e4, 10),
404 cv: 5,
405 scoring: CvScoring::LogLoss,
406 tol: 1e-10,
407 max_iter: 100,
408 fitted: None,
409 }
410 }
411
412 pub fn n_cs(mut self, n: usize) -> Self {
414 self.cs = geomspace(1e-4, 1e4, n.max(1));
415 self
416 }
417
418 pub fn cs(mut self, cs: Vec<f64>) -> Self {
420 self.cs = cs;
421 self
422 }
423
424 pub fn cv(mut self, cv: usize) -> Self {
425 self.cv = cv;
426 self
427 }
428
429 pub fn scoring(mut self, scoring: CvScoring) -> Self {
430 self.scoring = scoring;
431 self
432 }
433
434 pub fn tol(mut self, tol: f64) -> Self {
436 self.tol = tol;
437 self
438 }
439
440 pub fn max_iter(mut self, max_iter: usize) -> Self {
441 self.max_iter = max_iter;
442 self
443 }
444
445 pub fn cs_path(&self) -> &[f64] {
447 &self.cs
448 }
449
450 pub fn c(&self) -> Option<f64> {
452 self.fitted.as_ref().map(|f| f.c)
453 }
454
455 pub fn scores(&self) -> Option<&Array2<f64>> {
458 self.fitted.as_ref().map(|f| &f.scores)
459 }
460
461 pub fn coef(&self) -> Option<&Array1<f64>> {
462 self.fitted.as_ref().and_then(|f| f.model.coef())
463 }
464
465 pub fn intercept(&self) -> f64 {
466 self.fitted.as_ref().map_or(0.0, |f| f.model.intercept())
467 }
468
469 fn score(&self, prob: &Array1<f64>, y: &Array1<f64>) -> f64 {
470 match self.scoring {
471 CvScoring::LogLoss => {
472 let eps = f64::EPSILON;
473 -average(
474 prob.iter().zip(y).map(|(&p, &t)| {
475 let p = p.clamp(eps, 1.0 - eps);
476 -(t * p.ln() + (1.0 - t) * (1.0 - p).ln())
477 }),
478 None,
479 )
480 }
481 CvScoring::Accuracy => average(
482 prob.iter()
483 .zip(y)
484 .map(|(&p, &t)| f64::from((p > 0.5) == (t == 1.0))),
485 None,
486 ),
487 }
488 }
489}
490
491impl Classifier for LogisticRegressionCV {
492 fn fit(&mut self, x: ArrayView2<'_, f64>, y: ArrayView1<'_, f64>) -> Result<()> {
493 let n = x.nrows();
494 check_cv(n, self.cv)?;
495 if y.len() != n {
496 return Err(DmlError::DimensionMismatch(
497 "LogisticRegressionCV::fit x/y rows".into(),
498 ));
499 }
500 if self.cs.is_empty() || self.cs.iter().any(|c| c.is_nan() || *c <= 0.0) {
501 return Err(DmlError::InvalidArgument(
502 "the candidate values of C must be positive".into(),
503 ));
504 }
505 if !y.iter().all(|&v| v == 0.0 || v == 1.0) {
506 return Err(DmlError::InvalidArgument(
507 "LogisticRegressionCV labels must be 0 or 1".into(),
508 ));
509 }
510 let n_cs = self.cs.len();
511 let per_fold = map_folds(&stratified_test_sets(y, self.cv)?, |test| {
513 let train = complement(n, test);
514 let (x_train, y_train) = (x.select(Axis(0), &train), y.select(Axis(0), &train));
515 let (x_test, y_test) = (x.select(Axis(0), test), y.select(Axis(0), test));
516 let mut init: Option<Array1<f64>> = None;
517 let mut fold = Vec::with_capacity(n_cs);
518 for &c in &self.cs {
519 let mut m = LogisticRegression::new(c)
520 .tol(self.tol)
521 .max_iter(self.max_iter);
522 m.fit_from(x_train.view(), y_train.view(), init.as_ref())?;
523 let score = self.score(&m.predict_proba(x_test.view())?, &y_test);
524 init = m.params();
525 fold.push((score, init.clone().expect("fitted")));
526 }
527 Ok(fold)
528 })?;
529 let scores = Array2::from_shape_fn((self.cv, n_cs), |(k, j)| per_fold[k][j].0);
530 let params: Vec<Vec<Array1<f64>>> = per_fold
531 .into_iter()
532 .map(|fold| fold.into_iter().map(|(_, b)| b).collect())
533 .collect();
534 let total = scores.sum_axis(Axis(0));
536 let best = (0..n_cs).fold(0, |b, j| if total[j] > total[b] { j } else { b });
537 let init = params
539 .iter()
540 .map(|p| p[best].clone())
541 .reduce(|a, b| a + b)
542 .map(|s| s / self.cv as f64);
543 let mut model = LogisticRegression::new(self.cs[best])
544 .tol(self.tol)
545 .max_iter(self.max_iter);
546 model.fit_from(x, y, init.as_ref())?;
547 self.fitted = Some(LogitCvFit {
548 c: self.cs[best],
549 scores,
550 model,
551 });
552 Ok(())
553 }
554
555 fn predict_proba(&self, x: ArrayView2<'_, f64>) -> Result<Array1<f64>> {
556 self.fitted
557 .as_ref()
558 .ok_or(DmlError::NotFitted)?
559 .model
560 .predict_proba(x)
561 }
562
563 fn fresh(&self) -> Box<dyn Classifier> {
564 Box::new(Self {
565 fitted: None,
566 ..self.clone()
567 })
568 }
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574 use ndarray::array;
575
576 #[test]
577 fn kfold_blocks_match_scikit_learn() {
578 assert_eq!(
580 kfold_test_sets(8, 3),
581 vec![vec![0, 1, 2], vec![3, 4, 5], vec![6, 7]]
582 );
583 }
584
585 #[test]
586 fn stratified_folds_match_scikit_learn() {
587 let y = array![1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0];
589 assert_eq!(
590 stratified_test_sets(y.view(), 3).unwrap(),
591 vec![vec![0, 1, 2, 3], vec![4, 5, 6], vec![7, 8, 9]]
592 );
593 }
594
595 #[test]
596 fn geomspace_hits_both_ends() {
597 let g = geomspace(1e-4, 1e4, 10);
598 assert_eq!((g[0], g[9]), (1e-4, 1e4));
599 assert!((g[5] / g[4] - 10f64.powf(8.0 / 9.0)).abs() < 1e-12);
600 }
601
602 #[test]
603 fn lasso_cv_picks_a_grid_penalty_and_refits() {
604 let x = Array2::from_shape_fn((60, 4), |(i, j)| {
605 (((i * 7 + j * 5) % 13) as f64 - 6.0) / 4.0
606 });
607 let y: Array1<f64> = x
608 .rows()
609 .into_iter()
610 .enumerate()
611 .map(|(i, r)| 1.0 + 2.0 * r[0] - r[2] + ((i % 5) as f64 - 2.0) * 0.1)
612 .collect();
613 let mut m = LassoCV::new().n_alphas(20);
614 m.fit(x.view(), y.view()).unwrap();
615 let alpha = m.alpha().unwrap();
616 assert!(m.alphas_path().unwrap().contains(&alpha));
617 assert_eq!(m.mse_path().unwrap().dim(), (20, 5));
618 let mut direct = Lasso::new(alpha);
619 direct.fit(x.view(), y.view()).unwrap();
620 assert_eq!(direct.coef(), m.coef());
621 assert!(m.fresh().predict(x.view()).is_err()); }
623}