Skip to main content

dml_core/learner/
cv.rs

1//! Cross-validated linear learners, as scikit-learn's `LassoCV` and
2//! `LogisticRegressionCV`: the penalty is chosen by K-fold cross-validation
3//! on the training data, then the model is refitted on all of it. Used as
4//! nuisance learners, the tuning happens inside every cross-fitting fold.
5
6use 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
13/// numpy's `finfo(float64).resolution`, scikit-learn's floor for the alpha grid.
14const 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
28/// Test sets of scikit-learn's `KFold(cv)` without shuffling: contiguous
29/// blocks, the first `n % cv` of them one observation larger.
30pub(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
42/// Test sets of scikit-learn's `StratifiedKFold(cv)` without shuffling, for
43/// 0/1 labels: each class (numbered by order of first appearance) is dealt
44/// round-robin over the sorted labels, then assigned to folds in data order.
45pub(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    // Positions i, i + cv, i + 2cv, … of the sorted labels go to fold i; the
56    // first `n_first` sorted labels belong to the first class.
57    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
82/// `fit_fold(test set)` for every fold, in parallel with the `parallel`
83/// feature; results are in fold order either way.
84pub(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
107/// `n` points from `start` to `stop` evenly spaced on a log scale, computed
108/// as numpy's `geomspace`.
109fn 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
121/// Weighted mean (plain mean without weights).
122fn 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/// Lasso with the penalty chosen by K-fold cross-validation, as
136/// scikit-learn's `LassoCV`.
137///
138/// The candidate penalties are `n_alphas` values evenly spaced on a log scale
139/// from `α_max = max_j |x̃_jᵀ ỹ| / n` (the smallest penalty giving an
140/// all-zero solution) down to `eps · α_max`. For each fold the whole path is
141/// fitted with warm starts, the penalty with the lowest mean test MSE is
142/// chosen, and the Lasso is refitted on all the data. Folds are contiguous
143/// blocks (scikit-learn's default `KFold` without shuffling), so results
144/// reproduce `LassoCV(cv=5)`.
145#[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    /// 100 penalties down to `1e-3 · α_max`, 5 folds (scikit-learn's defaults).
172    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    /// Number of penalties on the automatic grid.
185    pub fn n_alphas(mut self, n_alphas: usize) -> Self {
186        self.n_alphas = n_alphas.max(1);
187        self
188    }
189
190    /// Ratio of the smallest to the largest penalty on the automatic grid.
191    pub fn eps(mut self, eps: f64) -> Self {
192        self.eps = eps;
193        self
194    }
195
196    /// Explicit candidate penalties instead of the automatic grid.
197    pub fn alphas(mut self, alphas: Vec<f64>) -> Self {
198        self.alphas = Some(alphas);
199        self
200    }
201
202    /// Number of cross-validation folds.
203    pub fn cv(mut self, cv: usize) -> Self {
204        self.cv = cv;
205        self
206    }
207
208    /// Coordinate-descent tolerance, as [`Lasso::tol`].
209    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    /// The chosen penalty.
220    pub fn alpha(&self) -> Option<f64> {
221        self.fitted.as_ref().map(|f| f.alpha)
222    }
223
224    /// The candidate penalties, in decreasing order.
225    pub fn alphas_path(&self) -> Option<&[f64]> {
226        self.fitted.as_ref().map(|f| f.alphas.as_slice())
227    }
228
229    /// Test MSE of every penalty on every fold, shape `(n_alphas, cv)`.
230    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        // Test MSE of every penalty, per fold.
281        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        // First penalty with the lowest mean MSE (numpy's argmin).
305        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    /// Weighted fits and weighted test MSEs, as `LassoCV.fit(sample_weight=w)`.
330    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/// Criterion for choosing `C` in [`LogisticRegressionCV`].
356#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
357pub enum CvScoring {
358    /// Mean log loss of the predicted probabilities (scikit-learn's
359    /// `"neg_log_loss"`, its default from version 1.11). The natural choice for
360    /// propensity scores, which enter DML scores as `1 / m(x)`.
361    #[default]
362    LogLoss,
363    /// Share of correctly classified observations (scikit-learn's default
364    /// before 1.11).
365    Accuracy,
366}
367
368/// L2-penalised logistic regression with `C` chosen by stratified K-fold
369/// cross-validation, as scikit-learn's `LogisticRegressionCV`.
370///
371/// The candidates are `n_cs` values of `C` evenly spaced on a log scale
372/// between `1e-4` and `1e4` (or given explicitly). Each is fitted on every
373/// fold (warm-started along the path), the `C` with the best total score is
374/// chosen and the model is refitted on all the data. Folds follow
375/// scikit-learn's `StratifiedKFold` without shuffling.
376#[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    /// Ten values of `C` from `1e-4` to `1e4`, 5 folds, log-loss scoring.
401    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    /// `n` values of `C` evenly spaced on a log scale from `1e-4` to `1e4`.
413    pub fn n_cs(mut self, n: usize) -> Self {
414        self.cs = geomspace(1e-4, 1e4, n.max(1));
415        self
416    }
417
418    /// Explicit candidate values of `C`, tried in the given order.
419    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    /// Newton tolerance, as [`LogisticRegression::tol`].
435    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    /// The candidate values of `C`.
446    pub fn cs_path(&self) -> &[f64] {
447        &self.cs
448    }
449
450    /// The chosen `C`.
451    pub fn c(&self) -> Option<f64> {
452        self.fitted.as_ref().map(|f| f.c)
453    }
454
455    /// Score of every `C` on every fold (higher is better; the log loss enters
456    /// negated), shape `(cv, n_cs)`.
457    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        // Per fold: the score and solution of every C, warm-started along the path.
512        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        // First C with the highest total score (numpy's argmax).
535        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        // Refit on all data, started from the folds' mean solution (as scikit-learn).
538        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        // KFold(3).split(range(8)) -> [0, 1, 2], [3, 4, 5], [6, 7]
579        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        // StratifiedKFold(3).split(X, y) test sets for this y, from scikit-learn 1.9.1.
588        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()); // fresh copies are unfitted
622    }
623}