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}