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