1use crate::error::FdarError;
4use crate::explain_generic::{FpcPredictor, TaskType};
5use crate::matrix::FdMatrix;
6
7use super::knn::knn_predict_loo;
8use super::lda::{lda_params, lda_predict};
9use super::qda::{build_qda_params, qda_predict};
10use super::{
11 build_feature_matrix, compute_accuracy, confusion_matrix, remap_labels, ClassifCvResult,
12 ClassifResult,
13};
14use crate::linalg::{cholesky_d, mahalanobis_sq};
15
16use super::cv::fclassif_cv;
17
18#[derive(Debug, Clone, PartialEq)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21#[non_exhaustive]
22pub enum ClassifMethod {
23 Lda {
25 class_means: Vec<Vec<f64>>,
26 cov_chol: Vec<f64>,
27 priors: Vec<f64>,
28 n_classes: usize,
29 },
30 Qda {
32 class_means: Vec<Vec<f64>>,
33 class_chols: Vec<Vec<f64>>,
34 class_log_dets: Vec<f64>,
35 priors: Vec<f64>,
36 n_classes: usize,
37 },
38 Knn {
40 training_scores: FdMatrix,
41 training_labels: Vec<usize>,
42 k: usize,
43 n_classes: usize,
44 },
45}
46
47#[derive(Debug, Clone, PartialEq)]
49#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
50#[non_exhaustive]
51pub struct ClassifFit {
52 pub result: ClassifResult,
54 pub fpca_mean: Vec<f64>,
56 pub fpca_rotation: FdMatrix,
58 pub fpca_scores: FdMatrix,
60 pub ncomp: usize,
62 pub method: ClassifMethod,
64 pub fpca_int_weights: Vec<f64>,
66}
67
68#[must_use = "expensive computation whose result should not be discarded"]
78pub fn fclassif_lda_fit(
79 data: &FdMatrix,
80 y: &[usize],
81 scalar_covariates: Option<&FdMatrix>,
82 ncomp: usize,
83) -> Result<ClassifFit, FdarError> {
84 let n = data.nrows();
85 if n == 0 || y.len() != n {
86 return Err(FdarError::InvalidDimension {
87 parameter: "data/y",
88 expected: "n > 0 and y.len() == n".to_string(),
89 actual: format!("n={}, y.len()={}", n, y.len()),
90 });
91 }
92 if ncomp == 0 {
93 return Err(FdarError::InvalidParameter {
94 parameter: "ncomp",
95 message: "must be > 0".to_string(),
96 });
97 }
98
99 let (labels, g) = remap_labels(y);
100 if g < 2 {
101 return Err(FdarError::InvalidParameter {
102 parameter: "y",
103 message: format!("need at least 2 classes, got {g}"),
104 });
105 }
106
107 let (features, mean, rotation, int_weights) = build_feature_matrix(data, None, ncomp)?;
110 let _ = scalar_covariates; let d = features.ncols();
112 let (class_means, cov, priors) = lda_params(&features, &labels, g);
113 let chol = cholesky_d(&cov, d)?;
114
115 let predicted = lda_predict(&features, &class_means, &chol, &priors, g);
116 let accuracy = compute_accuracy(&labels, &predicted);
117 let confusion = confusion_matrix(&labels, &predicted, g);
118
119 Ok(ClassifFit {
120 result: ClassifResult {
121 predicted,
122 probabilities: None,
123 accuracy,
124 confusion,
125 n_classes: g,
126 ncomp: d,
127 },
128 fpca_mean: mean.clone(),
129 fpca_rotation: rotation,
130 fpca_scores: features,
131 ncomp: d,
132 method: ClassifMethod::Lda {
133 class_means,
134 cov_chol: chol,
135 priors,
136 n_classes: g,
137 },
138 fpca_int_weights: int_weights,
139 })
140}
141
142#[must_use = "expensive computation whose result should not be discarded"]
152pub fn fclassif_qda_fit(
153 data: &FdMatrix,
154 y: &[usize],
155 scalar_covariates: Option<&FdMatrix>,
156 ncomp: usize,
157) -> Result<ClassifFit, FdarError> {
158 let n = data.nrows();
159 if n == 0 || y.len() != n {
160 return Err(FdarError::InvalidDimension {
161 parameter: "data/y",
162 expected: "n > 0 and y.len() == n".to_string(),
163 actual: format!("n={}, y.len()={}", n, y.len()),
164 });
165 }
166 if ncomp == 0 {
167 return Err(FdarError::InvalidParameter {
168 parameter: "ncomp",
169 message: "must be > 0".to_string(),
170 });
171 }
172
173 let (labels, g) = remap_labels(y);
174 if g < 2 {
175 return Err(FdarError::InvalidParameter {
176 parameter: "y",
177 message: format!("need at least 2 classes, got {g}"),
178 });
179 }
180
181 let (features, mean, rotation, int_weights) = build_feature_matrix(data, None, ncomp)?;
183 let _ = scalar_covariates;
184 let (class_means, class_chols, class_log_dets, priors) =
185 build_qda_params(&features, &labels, g)?;
186
187 let predicted = qda_predict(
188 &features,
189 &class_means,
190 &class_chols,
191 &class_log_dets,
192 &priors,
193 g,
194 );
195 let accuracy = compute_accuracy(&labels, &predicted);
196 let confusion = confusion_matrix(&labels, &predicted, g);
197 let d = features.ncols();
198
199 Ok(ClassifFit {
200 result: ClassifResult {
201 predicted,
202 probabilities: None,
203 accuracy,
204 confusion,
205 n_classes: g,
206 ncomp: d,
207 },
208 fpca_mean: mean.clone(),
209 fpca_rotation: rotation,
210 fpca_scores: features,
211 ncomp: d,
212 method: ClassifMethod::Qda {
213 class_means,
214 class_chols,
215 class_log_dets,
216 priors,
217 n_classes: g,
218 },
219 fpca_int_weights: int_weights,
220 })
221}
222
223#[must_use = "expensive computation whose result should not be discarded"]
233pub fn fclassif_knn_fit(
234 data: &FdMatrix,
235 y: &[usize],
236 scalar_covariates: Option<&FdMatrix>,
237 ncomp: usize,
238 k_nn: usize,
239) -> Result<ClassifFit, FdarError> {
240 let n = data.nrows();
241 if n == 0 || y.len() != n {
242 return Err(FdarError::InvalidDimension {
243 parameter: "data/y",
244 expected: "n > 0 and y.len() == n".to_string(),
245 actual: format!("n={}, y.len()={}", n, y.len()),
246 });
247 }
248 if ncomp == 0 {
249 return Err(FdarError::InvalidParameter {
250 parameter: "ncomp",
251 message: "must be > 0".to_string(),
252 });
253 }
254 if k_nn == 0 {
255 return Err(FdarError::InvalidParameter {
256 parameter: "k_nn",
257 message: "must be > 0".to_string(),
258 });
259 }
260
261 let (labels, g) = remap_labels(y);
262 if g < 2 {
263 return Err(FdarError::InvalidParameter {
264 parameter: "y",
265 message: format!("need at least 2 classes, got {g}"),
266 });
267 }
268
269 let (features, mean, rotation, int_weights) = build_feature_matrix(data, None, ncomp)?;
271 let _ = scalar_covariates;
272 let d = features.ncols();
273
274 let predicted = knn_predict_loo(&features, &labels, g, d, k_nn);
275 let accuracy = compute_accuracy(&labels, &predicted);
276 let confusion = confusion_matrix(&labels, &predicted, g);
277
278 Ok(ClassifFit {
279 result: ClassifResult {
280 predicted,
281 probabilities: None,
282 accuracy,
283 confusion,
284 n_classes: g,
285 ncomp: d,
286 },
287 fpca_mean: mean.clone(),
288 fpca_rotation: rotation,
289 fpca_scores: features.clone(),
290 ncomp: d,
291 method: ClassifMethod::Knn {
292 training_scores: features,
293 training_labels: labels,
294 k: k_nn,
295 n_classes: g,
296 },
297 fpca_int_weights: int_weights,
298 })
299}
300
301impl FpcPredictor for ClassifFit {
306 fn fpca_mean(&self) -> &[f64] {
307 &self.fpca_mean
308 }
309
310 fn fpca_rotation(&self) -> &FdMatrix {
311 &self.fpca_rotation
312 }
313
314 fn ncomp(&self) -> usize {
315 self.ncomp
316 }
317
318 fn training_scores(&self) -> &FdMatrix {
319 &self.fpca_scores
320 }
321
322 fn fpca_weights(&self) -> &[f64] {
323 &self.fpca_int_weights
324 }
325
326 fn task_type(&self) -> TaskType {
327 match &self.method {
328 ClassifMethod::Lda { n_classes, .. }
329 | ClassifMethod::Qda { n_classes, .. }
330 | ClassifMethod::Knn { n_classes, .. } => {
331 if *n_classes == 2 {
332 TaskType::BinaryClassification
333 } else {
334 TaskType::MulticlassClassification(*n_classes)
335 }
336 }
337 }
338 }
339
340 fn predict_from_scores(&self, scores: &[f64], _scalar_covariates: Option<&[f64]>) -> f64 {
341 match &self.method {
342 ClassifMethod::Lda {
343 class_means,
344 cov_chol,
345 priors,
346 n_classes,
347 } => {
348 let g = *n_classes;
349 let d = scores.len();
350 if g == 2 {
351 let score0 = priors[0].max(1e-15).ln()
353 - 0.5 * mahalanobis_sq(scores, &class_means[0], cov_chol, d);
354 let score1 = priors[1].max(1e-15).ln()
355 - 0.5 * mahalanobis_sq(scores, &class_means[1], cov_chol, d);
356 let max_s = score0.max(score1);
357 let exp0 = (score0 - max_s).exp();
358 let exp1 = (score1 - max_s).exp();
359 exp1 / (exp0 + exp1)
360 } else {
361 let mut best_class = 0;
363 let mut best_score = f64::NEG_INFINITY;
364 for c in 0..g {
365 let maha = mahalanobis_sq(scores, &class_means[c], cov_chol, d);
366 let s = priors[c].max(1e-15).ln() - 0.5 * maha;
367 if s > best_score {
368 best_score = s;
369 best_class = c;
370 }
371 }
372 best_class as f64
373 }
374 }
375 ClassifMethod::Qda {
376 class_means,
377 class_chols,
378 class_log_dets,
379 priors,
380 n_classes,
381 } => {
382 let g = *n_classes;
383 let d = scores.len();
384 if g == 2 {
385 let score0 = priors[0].max(1e-15).ln()
387 - 0.5
388 * (class_log_dets[0]
389 + mahalanobis_sq(scores, &class_means[0], &class_chols[0], d));
390 let score1 = priors[1].max(1e-15).ln()
391 - 0.5
392 * (class_log_dets[1]
393 + mahalanobis_sq(scores, &class_means[1], &class_chols[1], d));
394 let max_s = score0.max(score1);
395 let exp0 = (score0 - max_s).exp();
396 let exp1 = (score1 - max_s).exp();
397 exp1 / (exp0 + exp1)
398 } else {
399 let mut best_class = 0;
400 let mut best_score = f64::NEG_INFINITY;
401 for c in 0..g {
402 let maha = mahalanobis_sq(scores, &class_means[c], &class_chols[c], d);
403 let s = priors[c].max(1e-15).ln() - 0.5 * (class_log_dets[c] + maha);
404 if s > best_score {
405 best_score = s;
406 best_class = c;
407 }
408 }
409 best_class as f64
410 }
411 }
412 ClassifMethod::Knn {
413 training_scores,
414 training_labels,
415 k,
416 n_classes,
417 } => {
418 let g = *n_classes;
419 let n_train = training_scores.nrows();
420 let d = scores.len();
421 let k_nn = (*k).min(n_train);
422
423 let mut dists: Vec<(f64, usize)> = (0..n_train)
424 .map(|j| {
425 let d_sq: f64 = (0..d)
426 .map(|c| (scores[c] - training_scores[(j, c)]).powi(2))
427 .sum();
428 (d_sq, training_labels[j])
429 })
430 .collect();
431 dists.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
432
433 let mut votes = vec![0usize; g];
434 for &(_, label) in dists.iter().take(k_nn) {
435 if label < g {
436 votes[label] += 1;
437 }
438 }
439
440 if g == 2 {
441 votes[1] as f64 / k_nn as f64
443 } else {
444 votes
446 .iter()
447 .enumerate()
448 .max_by_key(|&(_, &v)| v)
449 .map_or(0.0, |(c, _)| c as f64)
450 }
451 }
452 }
453 }
454}
455
456pub(crate) fn classif_predict_probs(fit: &ClassifFit, scores: &FdMatrix) -> Vec<Vec<f64>> {
465 let n = scores.nrows();
466 let d = scores.ncols();
467 match &fit.method {
468 ClassifMethod::Lda {
469 class_means,
470 cov_chol,
471 priors,
472 n_classes,
473 } => {
474 let g = *n_classes;
475 (0..n)
476 .map(|i| {
477 let x: Vec<f64> = (0..d).map(|j| scores[(i, j)]).collect();
478 let disc: Vec<f64> = (0..g)
479 .map(|c| {
480 priors[c].max(1e-15).ln()
481 - 0.5 * mahalanobis_sq(&x, &class_means[c], cov_chol, d)
482 })
483 .collect();
484 softmax(&disc)
485 })
486 .collect()
487 }
488 ClassifMethod::Qda {
489 class_means,
490 class_chols,
491 class_log_dets,
492 priors,
493 n_classes,
494 } => {
495 let g = *n_classes;
496 (0..n)
497 .map(|i| {
498 let x: Vec<f64> = (0..d).map(|j| scores[(i, j)]).collect();
499 let disc: Vec<f64> = (0..g)
500 .map(|c| {
501 priors[c].max(1e-15).ln()
502 - 0.5
503 * (class_log_dets[c]
504 + mahalanobis_sq(&x, &class_means[c], &class_chols[c], d))
505 })
506 .collect();
507 softmax(&disc)
508 })
509 .collect()
510 }
511 ClassifMethod::Knn {
512 training_scores,
513 training_labels,
514 k,
515 n_classes,
516 } => {
517 let g = *n_classes;
518 let n_train = training_scores.nrows();
519 let k_nn = (*k).min(n_train);
520 (0..n)
521 .map(|i| {
522 let x: Vec<f64> = (0..d).map(|j| scores[(i, j)]).collect();
523 let mut dists: Vec<(f64, usize)> = (0..n_train)
524 .map(|j| {
525 let d_sq: f64 = (0..d)
526 .map(|c| (x[c] - training_scores[(j, c)]).powi(2))
527 .sum();
528 (d_sq, training_labels[j])
529 })
530 .collect();
531 dists
532 .sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
533 let mut votes = vec![0usize; g];
534 for &(_, label) in dists.iter().take(k_nn) {
535 if label < g {
536 votes[label] += 1;
537 }
538 }
539 votes.iter().map(|&v| v as f64 / k_nn as f64).collect()
540 })
541 .collect()
542 }
543 }
544}
545
546fn softmax(scores: &[f64]) -> Vec<f64> {
548 let max_s = scores.iter().copied().fold(f64::NEG_INFINITY, f64::max);
549 let exps: Vec<f64> = scores.iter().map(|&s| (s - max_s).exp()).collect();
550 let sum: f64 = exps.iter().sum();
551 exps.iter().map(|&e| e / sum).collect()
552}
553
554#[derive(Debug, Clone, PartialEq)]
559pub struct ClassifCvConfig {
560 pub method: String,
562 pub ncomp: usize,
564 pub nfold: usize,
566 pub seed: u64,
568}
569
570impl Default for ClassifCvConfig {
571 fn default() -> Self {
572 Self {
573 method: "lda".to_string(),
574 ncomp: 3,
575 nfold: 5,
576 seed: 42,
577 }
578 }
579}
580
581#[must_use = "expensive computation whose result should not be discarded"]
590pub fn fclassif_cv_with_config(
591 data: &FdMatrix,
592 argvals: &[f64],
593 y: &[usize],
594 scalar_covariates: Option<&FdMatrix>,
595 config: &ClassifCvConfig,
596) -> Result<ClassifCvResult, FdarError> {
597 fclassif_cv(
598 data,
599 argvals,
600 y,
601 scalar_covariates,
602 &config.method,
603 config.ncomp,
604 config.nfold,
605 config.seed,
606 )
607}