Skip to main content

fdars_core/classification/
cv.rs

1//! Cross-validation for functional classification.
2
3use crate::error::FdarError;
4use crate::iter_maybe_parallel;
5use crate::matrix::FdMatrix;
6use crate::regression::fdata_to_pc_1d;
7#[cfg(feature = "parallel")]
8use rayon::iter::ParallelIterator;
9
10use super::lda::{lda_params, lda_predict};
11use super::qda::{build_qda_params, qda_predict};
12use super::{remap_labels, ClassifCvResult};
13use crate::linalg::cholesky_d;
14
15/// K-fold cross-validated error rate for functional classification.
16///
17/// # Arguments
18/// * `data` — Functional data (n × m)
19/// * `argvals` — Evaluation points
20/// * `y` — Class labels
21/// * `scalar_covariates` — Optional scalar covariates
22/// * `method` — "lda", "qda", "knn", "kernel", "dd"
23/// * `ncomp` — Number of FPC components (for lda/qda/knn)
24/// * `nfold` — Number of CV folds
25/// * `seed` — Random seed for fold assignment
26///
27/// # Errors
28///
29/// Returns [`FdarError::InvalidParameter`] if `nfold < 2` or `nfold > n`.
30/// Returns [`FdarError::InvalidParameter`] if `y` contains fewer than 2 distinct classes.
31///
32/// # Examples
33///
34/// ```
35/// use fdars_core::matrix::FdMatrix;
36/// use fdars_core::classification::cv::fclassif_cv;
37///
38/// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
39/// let data = FdMatrix::from_column_major(
40///     (0..100).map(|i| (i as f64 * 0.1).sin()).collect(),
41///     10, 10,
42/// ).unwrap();
43/// let y = vec![0, 0, 0, 0, 0, 1, 1, 1, 1, 1];
44/// let result = fclassif_cv(&data, &argvals, &y, None, "lda", 2, 3, 42).unwrap();
45/// assert!(result.error_rate >= 0.0 && result.error_rate <= 1.0);
46/// ```
47#[must_use = "expensive computation whose result should not be discarded"]
48pub fn fclassif_cv(
49    data: &FdMatrix,
50    argvals: &[f64],
51    y: &[usize],
52    scalar_covariates: Option<&FdMatrix>,
53    method: &str,
54    ncomp: usize,
55    nfold: usize,
56    seed: u64,
57) -> Result<ClassifCvResult, FdarError> {
58    let n = data.nrows();
59    if n < nfold || nfold < 2 {
60        return Err(FdarError::InvalidParameter {
61            parameter: "nfold",
62            message: format!("need 2 <= nfold <= n, got nfold={nfold}, n={n}"),
63        });
64    }
65
66    let (labels, g) = remap_labels(y);
67    if g < 2 {
68        return Err(FdarError::InvalidParameter {
69            parameter: "y",
70            message: format!("need at least 2 classes, got {g}"),
71        });
72    }
73
74    // Assign folds
75    let folds = assign_folds(n, nfold, seed);
76
77    let fold_errors: Vec<f64> = iter_maybe_parallel!(0..nfold)
78        .map(|fold| {
79            let (train_idx, test_idx) = fold_split(&folds, fold);
80            let train_data = extract_class_data(data, &train_idx);
81            let test_data = extract_class_data(data, &test_idx);
82            let train_labels: Vec<usize> = train_idx.iter().map(|&i| labels[i]).collect();
83            let test_labels: Vec<usize> = test_idx.iter().map(|&i| labels[i]).collect();
84
85            let train_cov = scalar_covariates.map(|c| extract_class_data(c, &train_idx));
86            let test_cov = scalar_covariates.map(|c| extract_class_data(c, &test_idx));
87
88            let predictions = cv_fold_predict(
89                &train_data,
90                &test_data,
91                argvals,
92                &train_labels,
93                g,
94                train_cov.as_ref(),
95                test_cov.as_ref(),
96                method,
97                ncomp,
98            );
99
100            let n_test = test_labels.len();
101            match predictions {
102                Some(pred) => {
103                    let wrong = pred
104                        .iter()
105                        .zip(&test_labels)
106                        .filter(|(&p, &t)| p != t)
107                        .count();
108                    wrong as f64 / n_test as f64
109                }
110                None => 1.0,
111            }
112        })
113        .collect();
114
115    let error_rate = fold_errors.iter().sum::<f64>() / nfold as f64;
116
117    Ok(ClassifCvResult {
118        error_rate,
119        fold_errors,
120        best_ncomp: ncomp,
121    })
122}
123
124/// Assign observations to folds.
125pub(super) fn assign_folds(n: usize, nfold: usize, seed: u64) -> Vec<usize> {
126    use rand::prelude::*;
127    let mut rng = StdRng::seed_from_u64(seed);
128    let mut indices: Vec<usize> = (0..n).collect();
129    indices.shuffle(&mut rng);
130
131    let mut folds = vec![0usize; n];
132    for (rank, &idx) in indices.iter().enumerate() {
133        folds[idx] = rank % nfold;
134    }
135    folds
136}
137
138/// Split indices into train and test for given fold.
139pub(super) fn fold_split(folds: &[usize], fold: usize) -> (Vec<usize>, Vec<usize>) {
140    let train: Vec<usize> = (0..folds.len()).filter(|&i| folds[i] != fold).collect();
141    let test: Vec<usize> = (0..folds.len()).filter(|&i| folds[i] == fold).collect();
142    (train, test)
143}
144
145/// Predict on test set for one CV fold.
146fn cv_fold_predict(
147    train_data: &FdMatrix,
148    test_data: &FdMatrix,
149    _argvals: &[f64],
150    train_labels: &[usize],
151    g: usize,
152    train_cov: Option<&FdMatrix>,
153    test_cov: Option<&FdMatrix>,
154    method: &str,
155    ncomp: usize,
156) -> Option<Vec<usize>> {
157    let m = train_data.ncols();
158    let argvals: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1).max(1) as f64).collect();
159    let fpca = fdata_to_pc_1d(train_data, ncomp, &argvals).ok()?;
160    match method {
161        "lda" => {
162            let predictions =
163                project_and_classify_lda(test_data, &fpca, train_labels, g, train_cov, test_cov);
164            Some(predictions)
165        }
166        "qda" => {
167            let predictions =
168                project_and_classify_qda(test_data, &fpca, train_labels, g, train_cov, test_cov);
169            Some(predictions)
170        }
171        "knn" => {
172            let predictions =
173                project_and_classify_knn(test_data, &fpca, train_labels, g, train_cov, test_cov, 5);
174            Some(predictions)
175        }
176        // kernel and dd classifiers don't support out-of-sample prediction on new data
177        _ => None,
178    }
179}
180
181/// Project test data onto FPCA basis (mean-center, multiply by rotation with weights).
182pub(super) fn project_test_onto_fpca(
183    test_data: &FdMatrix,
184    fpca: &crate::regression::FpcaResult,
185) -> FdMatrix {
186    let n_test = test_data.nrows();
187    let m = test_data.ncols();
188    let d_pc = fpca.scores.ncols();
189    let mut test_features = FdMatrix::zeros(n_test, d_pc);
190    for i in 0..n_test {
191        for k in 0..d_pc {
192            let mut score = 0.0;
193            for j in 0..m {
194                score +=
195                    (test_data[(i, j)] - fpca.mean[j]) * fpca.rotation[(j, k)] * fpca.weights[j];
196            }
197            test_features[(i, k)] = score;
198        }
199    }
200    test_features
201}
202
203/// Append scalar covariates to FPCA scores to form augmented feature matrix.
204fn append_scalar_covariates(scores: &FdMatrix, scalar_covariates: Option<&FdMatrix>) -> FdMatrix {
205    match scalar_covariates {
206        None => scores.clone(),
207        Some(cov) => {
208            let n = scores.nrows();
209            let d_pc = scores.ncols();
210            let d_cov = cov.ncols();
211            let mut features = FdMatrix::zeros(n, d_pc + d_cov);
212            for i in 0..n {
213                for j in 0..d_pc {
214                    features[(i, j)] = scores[(i, j)];
215                }
216                for j in 0..d_cov {
217                    features[(i, d_pc + j)] = cov[(i, j)];
218                }
219            }
220            features
221        }
222    }
223}
224
225/// Project test data onto training FPCA and classify with LDA.
226fn project_and_classify_lda(
227    test_data: &FdMatrix,
228    fpca: &crate::regression::FpcaResult,
229    train_labels: &[usize],
230    g: usize,
231    train_cov: Option<&FdMatrix>,
232    test_cov: Option<&FdMatrix>,
233) -> Vec<usize> {
234    let test_pc = project_test_onto_fpca(test_data, fpca);
235    let test_features = append_scalar_covariates(&test_pc, test_cov);
236
237    let train_features = append_scalar_covariates(&fpca.scores, train_cov);
238    let (class_means, cov, priors) = lda_params(&train_features, train_labels, g);
239    let d = train_features.ncols();
240    match cholesky_d(&cov, d) {
241        Ok(chol) => lda_predict(&test_features, &class_means, &chol, &priors, g),
242        Err(_) => vec![0; test_data.nrows()],
243    }
244}
245
246/// Project test data onto training FPCA and classify with QDA.
247fn project_and_classify_qda(
248    test_data: &FdMatrix,
249    fpca: &crate::regression::FpcaResult,
250    train_labels: &[usize],
251    g: usize,
252    train_cov: Option<&FdMatrix>,
253    test_cov: Option<&FdMatrix>,
254) -> Vec<usize> {
255    let n_test = test_data.nrows();
256    let test_pc = project_test_onto_fpca(test_data, fpca);
257    let test_features = append_scalar_covariates(&test_pc, test_cov);
258
259    let train_features = append_scalar_covariates(&fpca.scores, train_cov);
260
261    match build_qda_params(&train_features, train_labels, g) {
262        Ok((class_means, class_chols, class_log_dets, priors)) => qda_predict(
263            &test_features,
264            &class_means,
265            &class_chols,
266            &class_log_dets,
267            &priors,
268            g,
269        ),
270        Err(_) => vec![0; n_test],
271    }
272}
273
274/// Project test data and classify with k-NN.
275fn project_and_classify_knn(
276    test_data: &FdMatrix,
277    fpca: &crate::regression::FpcaResult,
278    train_labels: &[usize],
279    g: usize,
280    train_cov: Option<&FdMatrix>,
281    test_cov: Option<&FdMatrix>,
282    k_nn: usize,
283) -> Vec<usize> {
284    let n_test = test_data.nrows();
285    let n_train = fpca.scores.nrows();
286
287    let test_pc = project_test_onto_fpca(test_data, fpca);
288    let test_features = append_scalar_covariates(&test_pc, test_cov);
289    let train_features = append_scalar_covariates(&fpca.scores, train_cov);
290    let d = train_features.ncols();
291
292    (0..n_test)
293        .map(|i| {
294            // Distances to all training points in augmented feature space
295            let mut dists: Vec<(f64, usize)> = (0..n_train)
296                .map(|t| {
297                    let d_sq: f64 = (0..d)
298                        .map(|k| (test_features[(i, k)] - train_features[(t, k)]).powi(2))
299                        .sum();
300                    (d_sq, train_labels[t])
301                })
302                .collect();
303            let k_eff = k_nn.min(n_train);
304            if k_eff > 0 && k_eff < dists.len() {
305                dists.select_nth_unstable_by(k_eff - 1, |a, b| {
306                    a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)
307                });
308            }
309
310            let mut votes = vec![0usize; g];
311            for &(_, label) in dists.iter().take(k_eff) {
312                votes[label] += 1;
313            }
314            votes
315                .iter()
316                .enumerate()
317                .max_by_key(|&(_, &v)| v)
318                .map_or(0, |(c, _)| c)
319        })
320        .collect()
321}
322
323/// Extract rows corresponding to given indices into a new FdMatrix.
324pub(super) fn extract_class_data(data: &FdMatrix, indices: &[usize]) -> FdMatrix {
325    let nc = indices.len();
326    let m = data.ncols();
327    let mut result = FdMatrix::zeros(nc, m);
328    for (ri, &i) in indices.iter().enumerate() {
329        for j in 0..m {
330            result[(ri, j)] = data[(i, j)];
331        }
332    }
333    result
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    /// Build a small deterministic classification dataset: n observations,
341    /// m evaluation points, 2 well-separated classes (first n/2 are class 0,
342    /// rest are class 1), argvals on [0, 1].
343    fn make_test_data(n: usize, m: usize) -> (FdMatrix, Vec<f64>, Vec<usize>) {
344        let argvals: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1).max(1) as f64).collect();
345        let mut raw = vec![0.0f64; n * m];
346        // Column-major: element (i, j) is at index i + j * n
347        for i in 0..n {
348            let class_offset = if i < n / 2 { 0.0 } else { 5.0 };
349            for j in 0..m {
350                // Simple bump function shifted by class_offset — well-separated classes
351                raw[i + j * n] = class_offset + (argvals[j] * std::f64::consts::PI).sin();
352            }
353        }
354        let data = FdMatrix::from_column_major(raw, n, m).unwrap();
355        let labels: Vec<usize> = (0..n).map(|i| if i < n / 2 { 0 } else { 1 }).collect();
356        (data, argvals, labels)
357    }
358
359    /// Verify that `fclassif_cv` produces bit-for-bit identical results when called
360    /// twice with the same seed and arguments, regardless of whether the `parallel`
361    /// feature is enabled.  This proves the collect-in-order determinism contract
362    /// for the parallelized fold loop.
363    #[test]
364    fn test_fclassif_cv_parallel_matches_sequential() {
365        let n = 20;
366        let m = 10;
367        let ncomp = 2;
368        let nfold = 5;
369        let seed = 42u64;
370
371        let (data, argvals, labels) = make_test_data(n, m);
372
373        let res_a = fclassif_cv(&data, &argvals, &labels, None, "lda", ncomp, nfold, seed)
374            .expect("fclassif_cv call A failed");
375        let res_b = fclassif_cv(&data, &argvals, &labels, None, "lda", ncomp, nfold, seed)
376            .expect("fclassif_cv call B failed");
377
378        assert_eq!(
379            res_a.fold_errors.len(),
380            res_b.fold_errors.len(),
381            "fold_errors length mismatch"
382        );
383        for (i, (&a, &b)) in res_a
384            .fold_errors
385            .iter()
386            .zip(res_b.fold_errors.iter())
387            .enumerate()
388        {
389            assert_eq!(
390                a, b,
391                "fold_errors[{i}] not bit-for-bit identical: {a} vs {b}"
392            );
393        }
394        assert_eq!(
395            res_a.error_rate, res_b.error_rate,
396            "error_rate not bit-for-bit identical"
397        );
398    }
399}