Skip to main content

fdars_core/shapelet/
classifier.rs

1//! Bundled shapelet-transform classifier: discover → transform → classify.
2//!
3//! This is the end-to-end pipeline matching sktime's `ShapeletTransformClassifier`.
4//! [`shapelet_classifier_fit`] discovers a [`ShapeletSet`] from labeled training
5//! curves (Phase 58), transforms the training set into an `n×K` shapelet-distance
6//! feature matrix (Phase 59), and fits an existing fdars classifier on those
7//! features (k-NN by default, LDA optionally). [`ShapeletClassifierFit::predict`]
8//! transforms new curves through the identical stored shapelets and classifies
9//! them with the stored inner model — no re-discovery, no re-normalization.
10//!
11//! # Divergence from the inner classifier's usual input
12//!
13//! The inner classifiers ([`fclassif_knn_fit`], [`fclassif_lda_fit`]) run FPCA on
14//! their `data` argument. Here `data` is the `n×K` **shapelet-distance** matrix,
15//! not functional evaluation points — so the inner FPCA is applied to distance
16//! features rather than curves. With the default `ncomp = None` we use `ncomp = K`
17//! (full rank, clamped to `min(K, n-1)`), so the FPCA rotation is a full-rank
18//! orthonormal change of basis that preserves all feature information; the k-NN /
19//! LDA decision then operates on an information-preserving rotation of the raw
20//! shapelet-distance features. sktime uses a RotationForest on the transformed
21//! features; fdars deliberately reuses its existing k-NN / LDA machinery instead.
22//!
23//! # Train/test discipline
24//!
25//! Shapelet quality is computed on the training split only (correct by the
26//! Hills/Lines design). Never pass test data into [`shapelet_classifier_fit`], and
27//! never report [`ShapeletClassifierFit::train_accuracy`] as a generalization
28//! estimate — evaluate on a held-out split via [`ShapeletClassifierFit::predict`].
29
30use crate::classification::{fclassif_knn_fit, fclassif_lda_fit, ClassifFit};
31use crate::error::FdarError;
32use crate::explain_generic::{FpcPredictor, TaskType};
33use crate::matrix::FdMatrix;
34use crate::shapelet::discovery::{ShapeletDiscoveryConfig, ShapeletSet};
35use crate::shapelet::transform::{shapelet_transform_fit, ShapeletTransformFit};
36
37/// The inner classifier the shapelet-transform pipeline trains on the `n×K`
38/// distance-feature matrix.
39///
40/// Defaults to canonical 1-nearest-neighbor (Hills/Lines).
41#[derive(Debug, Clone, PartialEq)]
42#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
43#[non_exhaustive]
44pub enum ShapeletClassifier {
45    /// k-nearest-neighbors on the shapelet-distance features.
46    Knn {
47        /// Number of neighbors.
48        k: usize,
49    },
50    /// Linear discriminant analysis on the shapelet-distance features.
51    Lda,
52}
53
54impl Default for ShapeletClassifier {
55    fn default() -> Self {
56        Self::Knn { k: 1 }
57    }
58}
59
60/// Configuration for [`shapelet_classifier_fit`].
61///
62/// Construct via `ShapeletClassifierConfig::default()`, then assign the fields you need (e.g. `let mut c = ShapeletClassifierConfig::default(); c.field = …;`). This struct is `#[non_exhaustive]`, so external crates cannot build it with a struct literal — not even functional-update `..Default::default()` form.
63#[non_exhaustive]
64#[derive(Debug, Clone, PartialEq, Default)]
65#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66pub struct ShapeletClassifierConfig {
67    /// Shapelet discovery configuration (length range, candidate cap, K, seed).
68    pub discovery: ShapeletDiscoveryConfig,
69    /// Inner classifier trained on the `n×K` distance features.
70    pub classifier: ShapeletClassifier,
71    /// FPCA components for the inner classifier on the `K`-column feature matrix.
72    ///
73    /// `None` (the default) uses `ncomp = K` (full rank, clamped to `min(K, n-1)`),
74    /// so the inner FPCA is an information-preserving rotation of the raw
75    /// shapelet-distance features. See the module docs for the divergence note.
76    pub ncomp: Option<usize>,
77}
78
79/// A fitted shapelet-transform classifier: the discovered shapelet transform plus
80/// the inner classifier trained on the `n×K` distance features.
81///
82/// Stores the (already z-normalized) shapelets and the fitted inner [`ClassifFit`]
83/// so that [`predict`](Self::predict) reuses the identical shapelets, normalization,
84/// and FPCA rotation as the fit.
85#[derive(Debug, Clone, PartialEq)]
86#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
87#[non_exhaustive]
88pub struct ShapeletClassifierFit {
89    /// The fitted shapelet transform (discovered shapelets + training features).
90    pub transform: ShapeletTransformFit,
91    /// The inner classifier fitted on the `n×K` shapelet-distance features.
92    pub classifier: ClassifFit,
93    /// The configuration used to produce this fit.
94    pub config: ShapeletClassifierConfig,
95    /// Distinct original labels in sorted order; index = remapped class produced by
96    /// the inner classifier, value = the caller's original label. Used to map inner
97    /// predictions back to the caller's label space.
98    pub classes: Vec<usize>,
99}
100
101impl ShapeletClassifierFit {
102    /// The fitted shapelet set (already z-normalized, ordered by quality).
103    #[must_use]
104    pub fn shapelets(&self) -> &ShapeletSet {
105        self.transform.shapelets()
106    }
107
108    /// The fitted shapelet transform.
109    #[must_use]
110    pub fn transform(&self) -> &ShapeletTransformFit {
111        &self.transform
112    }
113
114    /// The inner classifier fitted on the shapelet-distance features.
115    #[must_use]
116    pub fn classifier(&self) -> &ClassifFit {
117        &self.classifier
118    }
119
120    /// Training-set accuracy of the inner classifier on the shapelet-distance
121    /// features.
122    ///
123    /// **This is not a generalization estimate.** The shapelets were selected to
124    /// separate the training classes; evaluate on a held-out split via
125    /// [`predict`](Self::predict) instead.
126    #[must_use]
127    pub fn train_accuracy(&self) -> f64 {
128        self.classifier.result.accuracy
129    }
130
131    /// Predict class labels for out-of-sample curves.
132    ///
133    /// Transforms `new_data` through the stored shapelets (identical sequences,
134    /// stored z-normalization) into an `n_new×K` distance-feature matrix, then
135    /// classifies each row with the stored inner [`ClassifFit`] by reusing the
136    /// [`FpcPredictor`] projection path (project features → FPC scores →
137    /// `predict_from_scores`). Predictions are mapped back to the caller's original
138    /// label space.
139    ///
140    /// # Errors
141    ///
142    /// - [`FdarError::InvalidDimension`] (propagated from the transform) if any
143    ///   series in `new_data` is shorter than a discovered shapelet.
144    /// - [`FdarError::InvalidParameter`] if the stored shapelet set is empty.
145    #[must_use = "predicted labels should not be discarded"]
146    pub fn predict(&self, new_data: &FdMatrix) -> Result<Vec<usize>, FdarError> {
147        let features = self.transform.transform(new_data)?;
148        let scores = self.classifier.project(&features);
149        let d = scores.ncols();
150        let n_new = scores.nrows();
151        let task = self.classifier.task_type();
152
153        let mut out = Vec::with_capacity(n_new);
154        for i in 0..n_new {
155            let row: Vec<f64> = (0..d).map(|j| scores[(i, j)]).collect();
156            let raw = self.classifier.predict_from_scores(&row, None);
157            // Map the FpcPredictor output to a remapped class index.
158            let remapped = match task {
159                TaskType::BinaryClassification => usize::from(raw >= 0.5),
160                TaskType::MulticlassClassification(_) => raw.round() as usize,
161                // The inner model is always a classifier here; regression is
162                // unreachable, but fall back to a rounded class index.
163                TaskType::Regression => raw.round() as usize,
164            };
165            // Map the remapped class back to the caller's original label.
166            let label = self.classes.get(remapped).copied().unwrap_or(remapped);
167            out.push(label);
168        }
169        Ok(out)
170    }
171}
172
173/// Fit a bundled shapelet-transform classifier: discover shapelets, transform the
174/// training curves to an `n×K` distance-feature matrix, and train an inner fdars
175/// classifier (k-NN default, LDA optional) on those features.
176///
177/// The returned [`ShapeletClassifierFit`] stores the discovered shapelets and the
178/// fitted inner model; reuse them on new curves via
179/// [`ShapeletClassifierFit::predict`].
180///
181/// `data` is a column-major [`FdMatrix`] with rows = curves, columns = evaluation
182/// points; `labels[i]` is the integer class of curve `i`.
183///
184/// The inner classifier's FPCA `ncomp` is resolved from `config.ncomp` as
185/// `ncomp.unwrap_or(K).min(K).min(n-1).max(1)` where `K` is the number of
186/// discovered shapelets — full rank by default, so the inner FPCA is an
187/// information-preserving rotation of the raw shapelet-distance features. See the
188/// module docs for the divergence note (sktime uses RotationForest; fdars reuses
189/// k-NN / LDA).
190///
191/// # Errors
192///
193/// - Any error from shapelet discovery/transform (e.g. label/row mismatch,
194///   fewer than 2 classes, a series shorter than a discovered shapelet).
195/// - Any error from the inner classifier fit.
196///
197/// # Examples
198///
199/// ```
200/// use fdars_core::matrix::FdMatrix;
201/// use fdars_core::{shapelet_classifier_fit, ShapeletClassifierConfig, ShapeletDiscoveryConfig};
202///
203/// // Build a 2-class dataset: class 1 carries a triangular motif class 0 lacks.
204/// fn make(n: usize, m: usize) -> (FdMatrix, Vec<usize>) {
205///     let mut flat = vec![0.0f64; n * m];
206///     let mut labels = vec![0usize; n];
207///     let (start, len) = (m / 2, (m / 4).max(1));
208///     for i in 0..n {
209///         let class1 = i % 2 == 1;
210///         labels[i] = usize::from(class1);
211///         for j in 0..m {
212///             let hash = (i.wrapping_mul(2654435761) ^ j.wrapping_mul(40503)) % 211;
213///             let mut v = 0.01 * (i as f64) + (j as f64) * 0.001 + 0.05 * (hash as f64 / 211.0 - 0.5);
214///             if class1 && j >= start && j < start + len {
215///                 let k = j - start;
216///                 let half = len / 2;
217///                 v += if k <= half { k as f64 } else { (len - k) as f64 };
218///             }
219///             flat[i + j * n] = v;
220///         }
221///     }
222///     (FdMatrix::from_column_major(flat, n, m).unwrap(), labels)
223/// }
224///
225/// // TRAIN/TEST discipline: discover on train only, evaluate on held-out test.
226/// let (train, train_y) = make(24, 24);
227/// let (test, test_y) = make(12, 24);
228///
229/// let mut discovery = ShapeletDiscoveryConfig::default();
230/// discovery.min_length = 3;
231/// discovery.max_length = 6;
232/// discovery.max_shapelets = 4;
233/// let mut cfg = ShapeletClassifierConfig::default();
234/// cfg.discovery = discovery;
235/// let fit = shapelet_classifier_fit(&train, &train_y, &cfg).unwrap();
236///
237/// let preds = fit.predict(&test).unwrap();
238/// let correct = preds.iter().zip(&test_y).filter(|(p, t)| p == t).count();
239/// let acc = correct as f64 / test_y.len() as f64;
240/// assert!(acc > 0.5, "held-out accuracy {acc} should beat chance");
241/// ```
242#[must_use = "the fitted classifier should not be discarded"]
243pub fn shapelet_classifier_fit(
244    data: &FdMatrix,
245    labels: &[usize],
246    config: &ShapeletClassifierConfig,
247) -> Result<ShapeletClassifierFit, FdarError> {
248    // Discover shapelets on the training split + transform to n×K features.
249    let transform = shapelet_transform_fit(data, labels, &config.discovery)?;
250    let features = transform.features().clone();
251    let k = transform.shapelets().len();
252    let n = features.nrows();
253
254    // Resolve inner FPCA components: full rank (=K) by default, clamped to the
255    // FPCA bound min(K, n-1), never below 1.
256    let ncomp = config
257        .ncomp
258        .unwrap_or(k)
259        .min(k)
260        .min(n.saturating_sub(1))
261        .max(1);
262
263    // Fit the inner classifier on the shapelet-distance features.
264    let classifier = match config.classifier {
265        ShapeletClassifier::Knn { k: k_nn } => {
266            fclassif_knn_fit(&features, labels, None, ncomp, k_nn)?
267        }
268        ShapeletClassifier::Lda => fclassif_lda_fit(&features, labels, None, ncomp)?,
269    };
270
271    // Sorted-unique original labels: the inner classifier remaps labels to 0..G-1
272    // in this order, so this vector maps a remapped class back to the caller's label.
273    let mut classes: Vec<usize> = labels.to_vec();
274    classes.sort_unstable();
275    classes.dedup();
276
277    Ok(ShapeletClassifierFit {
278        transform,
279        classifier,
280        config: config.clone(),
281        classes,
282    })
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::shapelet::discovery::ShapeletDiscoveryConfig;
289
290    /// Two-class dataset: class 1 carries a triangular motif class 0 lacks.
291    /// Labels alternate (even → class 0, odd → class 1).
292    fn labeled_dataset(n: usize, m: usize) -> (FdMatrix, Vec<usize>) {
293        let mut flat = vec![0.0f64; n * m];
294        let mut labels = vec![0usize; n];
295        let motif_start = m / 2;
296        let motif_len = (m / 4).max(1);
297        for i in 0..n {
298            let class1 = i % 2 == 1;
299            labels[i] = usize::from(class1);
300            let offset = 0.01 * (i as f64);
301            for j in 0..m {
302                let mut v = offset + (j as f64) * 0.001;
303                let hash = (i.wrapping_mul(2654435761) ^ j.wrapping_mul(40503)) % 211;
304                v += 0.05 * (hash as f64 / 211.0 - 0.5);
305                if class1 && j >= motif_start && j < motif_start + motif_len {
306                    let k = j - motif_start;
307                    let half = motif_len / 2;
308                    let tri = if k <= half {
309                        k as f64
310                    } else {
311                        (motif_len - k) as f64
312                    };
313                    v += tri;
314                }
315                flat[i + j * n] = v;
316            }
317        }
318        (FdMatrix::from_column_major(flat, n, m).unwrap(), labels)
319    }
320
321    fn discovery_cfg() -> ShapeletDiscoveryConfig {
322        ShapeletDiscoveryConfig {
323            min_length: 3,
324            max_length: 6,
325            max_candidates: None,
326            max_shapelets: 4,
327            seed: 0,
328            ..Default::default()
329        }
330    }
331
332    #[test]
333    fn test_stc_fit_predict_end_to_end() {
334        // TRAIN/TEST discipline: discover on train only, predict on held-out test.
335        let (train, train_y) = labeled_dataset(24, 24);
336        let (test, test_y) = labeled_dataset(12, 24);
337        let cfg = ShapeletClassifierConfig {
338            discovery: discovery_cfg(),
339            ..Default::default()
340        };
341        let fit = shapelet_classifier_fit(&train, &train_y, &cfg).unwrap();
342
343        let preds = fit.predict(&test).unwrap();
344        assert_eq!(preds.len(), test_y.len());
345        let correct = preds.iter().zip(&test_y).filter(|(p, t)| p == t).count();
346        let acc = correct as f64 / test_y.len() as f64;
347        assert!(
348            acc > 0.6,
349            "held-out accuracy {acc} should be well above chance (0.5)"
350        );
351    }
352
353    #[test]
354    fn test_stc_knn_default() {
355        // Default config uses 1-NN.
356        assert_eq!(
357            ShapeletClassifierConfig::default().classifier,
358            ShapeletClassifier::Knn { k: 1 }
359        );
360        let (train, train_y) = labeled_dataset(20, 24);
361        let cfg = ShapeletClassifierConfig {
362            discovery: discovery_cfg(),
363            ..Default::default()
364        };
365        let fit = shapelet_classifier_fit(&train, &train_y, &cfg).unwrap();
366        let acc = fit.train_accuracy();
367        assert!(
368            (0.0..=1.0).contains(&acc),
369            "train_accuracy out of range: {acc}"
370        );
371    }
372
373    #[test]
374    fn test_stc_lda_option() {
375        let (train, train_y) = labeled_dataset(24, 24);
376        let (test, _test_y) = labeled_dataset(10, 24);
377        let cfg = ShapeletClassifierConfig {
378            discovery: discovery_cfg(),
379            classifier: ShapeletClassifier::Lda,
380            ncomp: None,
381        };
382        let fit = shapelet_classifier_fit(&train, &train_y, &cfg).unwrap();
383        let preds = fit.predict(&test).unwrap();
384        assert_eq!(preds.len(), 10);
385        for &p in &preds {
386            assert!(p == 0 || p == 1, "unexpected label {p}");
387        }
388    }
389
390    #[test]
391    fn test_stc_predict_consistency() {
392        // LDA is deterministic at fit and predict on the same projected scores, so
393        // predicting the training curves reproduces the fit-time training predictions.
394        let (train, train_y) = labeled_dataset(24, 24);
395        let cfg = ShapeletClassifierConfig {
396            discovery: discovery_cfg(),
397            classifier: ShapeletClassifier::Lda,
398            ncomp: None,
399        };
400        let fit = shapelet_classifier_fit(&train, &train_y, &cfg).unwrap();
401
402        // Fit-time predictions are stored in remapped space; map them back to the
403        // caller's labels via the stored `classes` list.
404        let fit_time: Vec<usize> = fit
405            .classifier
406            .result
407            .predicted
408            .iter()
409            .map(|&r| fit.classes[r])
410            .collect();
411
412        let re = fit.predict(&train).unwrap();
413        assert_eq!(
414            re, fit_time,
415            "predict(train) != fit-time training predictions"
416        );
417    }
418
419    #[test]
420    fn test_stc_validation() {
421        // Single-class labels → error (propagated from discovery/classifier).
422        let (data, _labels) = labeled_dataset(8, 24);
423        let single = vec![0usize; 8];
424        let cfg = ShapeletClassifierConfig {
425            discovery: discovery_cfg(),
426            ..Default::default()
427        };
428        assert!(shapelet_classifier_fit(&data, &single, &cfg).is_err());
429
430        // Label/row length mismatch → error.
431        let short = vec![0usize, 1, 0];
432        assert!(shapelet_classifier_fit(&data, &short, &cfg).is_err());
433    }
434
435    #[test]
436    fn test_shapelet_reexports() {
437        // Crate-root re-exported names must be reachable.
438        use crate::{
439            discover_shapelets, shapelet_classifier_fit as _scf, shapelet_distance,
440            shapelet_transform, shapelet_transform_fit, QualityMeasure, Shapelet,
441            ShapeletClassifier, ShapeletClassifierConfig, ShapeletClassifierFit,
442            ShapeletDiscoveryConfig, ShapeletSet, ShapeletTransformFit,
443        };
444        // Reference each to prove the path resolves (compile-level).
445        let _ = _scf;
446        let _ = shapelet_distance;
447        let _ = discover_shapelets;
448        let _ = shapelet_transform;
449        let _ = shapelet_transform_fit;
450        let _c: fn() -> ShapeletClassifierConfig = ShapeletClassifierConfig::default;
451        let _q = QualityMeasure::InfoGain;
452        let _cl = ShapeletClassifier::default();
453        // Type-level references.
454        fn _takes(
455            _a: &Shapelet,
456            _b: &ShapeletSet,
457            _c: &ShapeletDiscoveryConfig,
458            _d: &ShapeletTransformFit,
459            _e: &ShapeletClassifierFit,
460        ) {
461        }
462    }
463}