1use 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#[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 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
124pub(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
138pub(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
145fn 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 _ => None,
178 }
179}
180
181pub(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
203fn 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
225fn 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
246fn 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
274fn 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 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
323pub(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 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 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 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 #[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}