Skip to main content

fdars_core/classification/
fit.rs

1//! ClassifFit: fitted classification model with explainability support.
2
3use 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/// Classification method with stored parameters for prediction.
19#[derive(Debug, Clone, PartialEq)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21#[non_exhaustive]
22pub enum ClassifMethod {
23    /// Linear Discriminant Analysis.
24    Lda {
25        class_means: Vec<Vec<f64>>,
26        cov_chol: Vec<f64>,
27        priors: Vec<f64>,
28        n_classes: usize,
29    },
30    /// Quadratic Discriminant Analysis.
31    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    /// k-Nearest Neighbors.
39    Knn {
40        training_scores: FdMatrix,
41        training_labels: Vec<usize>,
42        k: usize,
43        n_classes: usize,
44    },
45}
46
47/// A fitted classification model that retains FPCA components for explainability.
48#[derive(Debug, Clone, PartialEq)]
49#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
50#[non_exhaustive]
51pub struct ClassifFit {
52    /// Classification result (predicted labels, accuracy, confusion matrix).
53    pub result: ClassifResult,
54    /// FPCA mean function (length m).
55    pub fpca_mean: Vec<f64>,
56    /// FPCA rotation matrix (m × ncomp).
57    pub fpca_rotation: FdMatrix,
58    /// FPCA scores (n × ncomp).
59    pub fpca_scores: FdMatrix,
60    /// Number of FPC components used.
61    pub ncomp: usize,
62    /// The classification method with stored parameters.
63    pub method: ClassifMethod,
64    /// Integration weights from FPCA (length m).
65    pub fpca_int_weights: Vec<f64>,
66}
67
68/// FPC + LDA classification, retaining FPCA and LDA parameters for explainability.
69///
70/// # Errors
71///
72/// Returns [`FdarError::InvalidDimension`] if `data` has zero rows or `y.len() != n`.
73/// Returns [`FdarError::InvalidParameter`] if `ncomp` is zero.
74/// Returns [`FdarError::InvalidParameter`] if `y` contains fewer than 2 distinct classes.
75/// Returns [`FdarError::ComputationFailed`] if the SVD decomposition in FPCA fails.
76/// Returns [`FdarError::ComputationFailed`] if the pooled covariance Cholesky factorization fails.
77#[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    // _fit variants use FPCA-only features (no scalar_covariates) so that stored
108    // dimensions are consistent with FpcPredictor::project() / predict_from_scores().
109    let (features, mean, rotation, int_weights) = build_feature_matrix(data, None, ncomp)?;
110    let _ = scalar_covariates; // acknowledged but not used — see docstring
111    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/// FPC + QDA classification, retaining FPCA and QDA parameters for explainability.
143///
144/// # Errors
145///
146/// Returns [`FdarError::InvalidDimension`] if `data` has zero rows or `y.len() != n`.
147/// Returns [`FdarError::InvalidParameter`] if `ncomp` is zero.
148/// Returns [`FdarError::InvalidParameter`] if `y` contains fewer than 2 distinct classes.
149/// Returns [`FdarError::ComputationFailed`] if the SVD decomposition in FPCA fails.
150/// Returns [`FdarError::ComputationFailed`] if a per-class covariance Cholesky factorization fails.
151#[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    // _fit variants use FPCA-only features — see fclassif_lda_fit comment.
182    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/// FPC + k-NN classification, retaining FPCA and training data for explainability.
224///
225/// # Errors
226///
227/// Returns [`FdarError::InvalidDimension`] if `data` has zero rows or `y.len() != n`.
228/// Returns [`FdarError::InvalidParameter`] if `ncomp` is zero.
229/// Returns [`FdarError::InvalidParameter`] if `k_nn` is zero.
230/// Returns [`FdarError::InvalidParameter`] if `y` contains fewer than 2 distinct classes.
231/// Returns [`FdarError::ComputationFailed`] if the SVD decomposition in FPCA fails.
232#[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    // _fit variants use FPCA-only features — see fclassif_lda_fit comment.
270    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
301// ---------------------------------------------------------------------------
302// FpcPredictor impl for ClassifFit
303// ---------------------------------------------------------------------------
304
305impl 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                    // Return P(Y=1) via softmax of discriminant scores
352                    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                    // Return predicted class as f64
362                    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                    // Return P(Y=1) via softmax of discriminant scores
386                    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                    // Return proportion voting for class 1 as probability
442                    votes[1] as f64 / k_nn as f64
443                } else {
444                    // Return majority vote class as f64
445                    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
456// ---------------------------------------------------------------------------
457// Class probability vectors (for conformal prediction sets)
458// ---------------------------------------------------------------------------
459
460/// Compute full class probability vectors for each observation.
461///
462/// Returns `n × g` probability vectors suitable for conformal classification.
463/// For each observation, the probabilities sum to 1.
464pub(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
546/// Softmax of a vector of log-scores → probabilities.
547fn 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// ---------------------------------------------------------------------------
555// ─── Config-based API ───────────────────────────────────────────────────────
556
557/// Configuration for [`fclassif_cv`].
558#[derive(Debug, Clone, PartialEq)]
559pub struct ClassifCvConfig {
560    /// Classification method name (one of "lda", "qda", "knn", "kernel", "dd").
561    pub method: String,
562    /// Number of FPC components.
563    pub ncomp: usize,
564    /// Number of cross-validation folds.
565    pub nfold: usize,
566    /// Random seed for fold assignment.
567    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/// Cross-validated classification using a configuration struct.
582///
583/// Equivalent to [`fclassif_cv`] but bundles method parameters in [`ClassifCvConfig`].
584///
585/// # Errors
586///
587/// Returns [`FdarError::InvalidParameter`] if `config.nfold < 2` or `config.nfold > n`.
588/// Returns [`FdarError::InvalidParameter`] if `y` contains fewer than 2 distinct classes.
589#[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}