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}