1use crate::error::FdarError;
20use crate::helpers::seed_for_thread;
21use crate::iter_maybe_parallel;
22use crate::matrix::FdMatrix;
23use crate::shapelet::distance::{shapelet_distance, Shapelet};
24use rand::Rng;
25#[cfg(feature = "parallel")]
26use rayon::iter::ParallelIterator;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
32#[non_exhaustive]
33pub enum QualityMeasure {
34 #[default]
37 InfoGain,
38 FStatistic,
41}
42
43#[non_exhaustive]
52#[derive(Debug, Clone, PartialEq)]
53#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
54pub struct ShapeletDiscoveryConfig {
55 pub min_length: usize,
57 pub max_length: usize,
59 pub max_candidates: Option<usize>,
63 pub max_shapelets: usize,
66 pub quality: QualityMeasure,
68 pub seed: u64,
70}
71
72impl Default for ShapeletDiscoveryConfig {
73 fn default() -> Self {
74 Self {
75 min_length: 3,
76 max_length: 0,
77 max_candidates: Some(10_000),
78 max_shapelets: 0,
79 quality: QualityMeasure::InfoGain,
80 seed: 0,
81 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq)]
90#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
91#[non_exhaustive]
92pub struct ShapeletSet {
93 pub shapelets: Vec<Shapelet>,
95 pub quality: QualityMeasure,
97}
98
99impl ShapeletSet {
100 #[must_use]
102 pub fn shapelets(&self) -> &[Shapelet] {
103 &self.shapelets
104 }
105
106 #[must_use]
108 pub fn len(&self) -> usize {
109 self.shapelets.len()
110 }
111
112 #[must_use]
114 pub fn is_empty(&self) -> bool {
115 self.shapelets.is_empty()
116 }
117
118 #[must_use]
120 pub fn quality(&self) -> QualityMeasure {
121 self.quality
122 }
123}
124
125fn entropy_from_counts(counts: &[usize], total: usize) -> f64 {
127 if total == 0 {
128 return 0.0;
129 }
130 let n = total as f64;
131 let mut h = 0.0;
132 for &c in counts {
133 if c > 0 {
134 let p = c as f64 / n;
135 h -= p * p.log2();
136 }
137 }
138 h
139}
140
141fn information_gain(orderline: &mut [(f64, usize)], n_classes: usize) -> f64 {
148 let n = orderline.len();
149 if n < 2 || n_classes < 2 {
150 return 0.0;
151 }
152 orderline.sort_by(|a, b| a.0.total_cmp(&b.0));
153
154 let mut total_counts = vec![0usize; n_classes];
156 for &(_, y) in orderline.iter() {
157 total_counts[y] += 1;
158 }
159 let parent_h = entropy_from_counts(&total_counts, n);
160
161 let mut left_counts = vec![0usize; n_classes];
165 let mut right_counts = total_counts.clone();
166 let mut best_ig = 0.0f64;
167
168 for t in 0..(n - 1) {
169 let (d, y) = orderline[t];
170 left_counts[y] += 1;
171 right_counts[y] -= 1;
172
173 let d_next = orderline[t + 1].0;
176 if d_next <= d {
177 continue;
178 }
179 let n_left = t + 1;
180 let n_right = n - n_left;
181 let h_left = entropy_from_counts(&left_counts, n_left);
182 let h_right = entropy_from_counts(&right_counts, n_right);
183 let weighted = (n_left as f64 / n as f64) * h_left + (n_right as f64 / n as f64) * h_right;
184 let ig = parent_h - weighted;
185 if ig > best_ig {
186 best_ig = ig;
187 }
188 }
189 best_ig
190}
191
192fn f_statistic_1d(distances: &[f64], labels: &[usize], n_classes: usize) -> f64 {
209 let n = distances.len();
210 if n == 0 || n_classes < 2 || n <= n_classes {
211 return 0.0;
212 }
213 let mut group_sum = vec![0.0f64; n_classes];
214 let mut group_cnt = vec![0usize; n_classes];
215 let mut grand_sum = 0.0f64;
216 for (&d, &y) in distances.iter().zip(labels.iter()) {
217 group_sum[y] += d;
218 group_cnt[y] += 1;
219 grand_sum += d;
220 }
221 let grand_mean = grand_sum / n as f64;
222 let mut group_mean = vec![0.0f64; n_classes];
223 for g in 0..n_classes {
224 if group_cnt[g] > 0 {
225 group_mean[g] = group_sum[g] / group_cnt[g] as f64;
226 }
227 }
228 let mut ss_between = 0.0f64;
229 for g in 0..n_classes {
230 let diff = group_mean[g] - grand_mean;
231 ss_between += group_cnt[g] as f64 * diff * diff;
232 }
233 let mut ss_within = 0.0f64;
234 for (&d, &y) in distances.iter().zip(labels.iter()) {
235 let diff = d - group_mean[y];
236 ss_within += diff * diff;
237 }
238 let ms_between = ss_between / (n_classes as f64 - 1.0).max(1.0);
239 let ms_within = ss_within / (n as f64 - n_classes as f64).max(1.0);
240 if ms_within > 1e-15 {
241 ms_between / ms_within
242 } else {
243 0.0
244 }
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249struct Candidate {
250 series_idx: usize,
251 start: usize,
252 length: usize,
253}
254
255fn generate_candidates(
261 n_series: usize,
262 ncols: usize,
263 min_length: usize,
264 max_length: usize,
265 max_candidates: Option<usize>,
266 seed: u64,
267) -> Vec<Candidate> {
268 let per_series: usize = (min_length..=max_length).map(|l| ncols - l + 1).sum();
270 let total = n_series.saturating_mul(per_series);
271
272 let exhaustive = match max_candidates {
273 Some(m) => total <= m,
274 None => true,
275 };
276
277 if exhaustive {
278 let mut out = Vec::with_capacity(total);
279 for series_idx in 0..n_series {
280 for length in min_length..=max_length {
281 for start in 0..=(ncols - length) {
282 out.push(Candidate {
283 series_idx,
284 start,
285 length,
286 });
287 }
288 }
289 }
290 return out;
291 }
292
293 let m = max_candidates.unwrap(); let mut rng = seed_for_thread(seed, 0);
298 use std::collections::HashSet;
299 let mut chosen: HashSet<usize> = HashSet::with_capacity(m);
300 let max_draws = m.saturating_mul(64).max(total);
303 let mut draws = 0usize;
304 while chosen.len() < m && draws < max_draws {
305 let idx = rng.gen_range(0..total);
306 chosen.insert(idx);
307 draws += 1;
308 }
309
310 let mut out: Vec<Candidate> = chosen
311 .into_iter()
312 .map(|lin| decode_candidate(lin, n_series, ncols, min_length, max_length))
313 .collect();
314 out.sort_by_key(|c| (c.series_idx, c.start, c.length));
316 out
317}
318
319fn decode_candidate(
322 lin: usize,
323 _n_series: usize,
324 ncols: usize,
325 min_length: usize,
326 max_length: usize,
327) -> Candidate {
328 let per_series: usize = (min_length..=max_length).map(|l| ncols - l + 1).sum();
329 let series_idx = lin / per_series;
330 let mut rem = lin % per_series;
331 let mut length = min_length;
332 loop {
333 let starts = ncols - length + 1;
334 if rem < starts {
335 return Candidate {
336 series_idx,
337 start: rem,
338 length,
339 };
340 }
341 rem -= starts;
342 length += 1;
343 }
344}
345
346#[must_use = "the discovered shapelet set should not be discarded"]
403pub fn discover_shapelets(
404 data: &FdMatrix,
405 labels: &[usize],
406 config: &ShapeletDiscoveryConfig,
407) -> Result<ShapeletSet, FdarError> {
408 let (n_series, ncols) = data.shape();
409
410 if labels.len() != n_series {
412 return Err(FdarError::InvalidDimension {
413 parameter: "labels",
414 expected: format!("{n_series} labels (one per curve)"),
415 actual: format!("{} labels", labels.len()),
416 });
417 }
418 if n_series == 0 || ncols == 0 {
419 return Err(FdarError::InvalidDimension {
420 parameter: "data",
421 expected: "at least one curve with at least one point".to_string(),
422 actual: format!("{n_series}x{ncols}"),
423 });
424 }
425
426 let mut distinct: Vec<usize> = labels.to_vec();
428 distinct.sort_unstable();
429 distinct.dedup();
430 let n_classes = distinct.len();
431 if n_classes < 2 {
432 return Err(FdarError::InvalidParameter {
433 parameter: "labels",
434 message: format!("at least 2 distinct classes required, found {n_classes}"),
435 });
436 }
437 let remap = |y: usize| distinct.iter().position(|&d| d == y).unwrap();
438 let labels_dense: Vec<usize> = labels.iter().map(|&y| remap(y)).collect();
439
440 if config.min_length < 1 {
441 return Err(FdarError::InvalidParameter {
442 parameter: "min_length",
443 message: "min_length must be >= 1".to_string(),
444 });
445 }
446 let max_length = if config.max_length == 0 {
448 ncols
449 } else {
450 config.max_length
451 };
452 if config.min_length > max_length {
453 return Err(FdarError::InvalidParameter {
454 parameter: "min_length",
455 message: format!(
456 "min_length ({}) > max_length ({max_length})",
457 config.min_length
458 ),
459 });
460 }
461 if max_length > ncols {
462 return Err(FdarError::InvalidParameter {
463 parameter: "max_length",
464 message: format!("max_length ({max_length}) > series length ({ncols})"),
465 });
466 }
467 let max_shapelets = if config.max_shapelets == 0 {
469 (10 * n_series).min(1000)
470 } else {
471 config.max_shapelets
472 };
473 if max_shapelets < 1 {
474 return Err(FdarError::InvalidParameter {
475 parameter: "max_shapelets",
476 message: "max_shapelets must be >= 1".to_string(),
477 });
478 }
479
480 let candidates = generate_candidates(
482 n_series,
483 ncols,
484 config.min_length,
485 max_length,
486 config.max_candidates,
487 config.seed,
488 );
489
490 let series_rows: Vec<Vec<f64>> = {
493 let mut rows = Vec::with_capacity(n_series);
494 let mut buf = vec![0.0f64; ncols];
495 for i in 0..n_series {
496 data.row_to_buf(i, &mut buf);
497 rows.push(buf.clone());
498 }
499 rows
500 };
501
502 let quality = config.quality;
504 let scored: Vec<(f64, Candidate)> = iter_maybe_parallel!(0..candidates.len())
505 .map(|ci| {
506 let cand = candidates[ci];
507 let src = &series_rows[cand.series_idx];
508 let shp = Shapelet::from_source(src, cand.series_idx, cand.start, cand.length)
510 .expect("candidate window is in-range by construction");
511 let mut orderline: Vec<(f64, usize)> = Vec::with_capacity(n_series);
513 for (i, row) in series_rows.iter().enumerate() {
514 let (d, _off) = shapelet_distance(&shp.values, row, f64::INFINITY)
515 .expect("series length >= shapelet length by construction");
516 orderline.push((d, labels_dense[i]));
517 }
518 let score = match quality {
519 QualityMeasure::InfoGain => information_gain(&mut orderline, n_classes),
520 QualityMeasure::FStatistic => {
521 let dists: Vec<f64> = orderline.iter().map(|&(d, _)| d).collect();
522 let labs: Vec<usize> = orderline.iter().map(|&(_, y)| y).collect();
523 f_statistic_1d(&dists, &labs, n_classes)
524 }
525 };
526 (score, cand)
527 })
528 .collect();
529
530 let mut ranked = scored;
532 ranked.sort_by(|a, b| {
533 b.0.total_cmp(&a.0).then_with(|| {
534 (a.1.series_idx, a.1.start, a.1.length).cmp(&(b.1.series_idx, b.1.start, b.1.length))
535 })
536 });
537
538 let mut accepted_ranges: std::collections::HashMap<usize, Vec<(usize, usize)>> =
542 std::collections::HashMap::new();
543 let mut selected: Vec<Shapelet> = Vec::with_capacity(max_shapelets);
544
545 for (score, cand) in ranked {
546 if selected.len() >= max_shapelets {
547 break;
548 }
549 let start = cand.start;
550 let end = cand.start + cand.length;
551 let overlaps = accepted_ranges
552 .get(&cand.series_idx)
553 .is_some_and(|ranges| ranges.iter().any(|&(s, e)| !(end <= s || e <= start)));
554 if overlaps {
555 continue;
556 }
557 let src = &series_rows[cand.series_idx];
558 let mut shp = Shapelet::from_source(src, cand.series_idx, cand.start, cand.length)
559 .expect("candidate window is in-range by construction");
560 shp.quality = score;
561 accepted_ranges
562 .entry(cand.series_idx)
563 .or_default()
564 .push((start, end));
565 selected.push(shp);
566 }
567
568 Ok(ShapeletSet {
569 shapelets: selected,
570 quality: config.quality,
571 })
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577
578 fn planted_motif_dataset() -> (FdMatrix, Vec<usize>, usize, usize) {
582 let n = 20usize;
583 let m = 40usize;
584 let motif_start = 15usize;
585 let motif_len = 8usize;
586 let mut flat = vec![0.0f64; n * m];
587 let mut labels = vec![0usize; n];
588 for i in 0..n {
589 let class1 = i % 2 == 1;
590 labels[i] = usize::from(class1);
591 let offset = 0.01 * (i as f64); for j in 0..m {
593 let mut v = offset + (j as f64) * 0.001;
594 let hash = (i.wrapping_mul(2654435761) ^ j.wrapping_mul(40503)) % 211;
599 v += 0.05 * (hash as f64 / 211.0 - 0.5);
600 if class1 && j >= motif_start && j < motif_start + motif_len {
601 let k = j - motif_start;
603 let half = motif_len / 2;
604 let tri = if k <= half {
605 k as f64
606 } else {
607 (motif_len - k) as f64
608 };
609 v += tri;
610 }
611 flat[i + j * n] = v;
612 }
613 }
614 (
615 FdMatrix::from_column_major(flat, n, m).unwrap(),
616 labels,
617 motif_start,
618 motif_len,
619 )
620 }
621
622 #[test]
623 fn test_discover_known_motif() {
624 let (data, labels, motif_start, motif_len) = planted_motif_dataset();
625 let cfg = ShapeletDiscoveryConfig {
626 min_length: motif_len,
627 max_length: motif_len,
628 max_candidates: None, max_shapelets: 3,
630 quality: QualityMeasure::InfoGain,
631 seed: 0,
632 };
633 let set = discover_shapelets(&data, &labels, &cfg).unwrap();
634 assert!(!set.is_empty(), "no shapelets discovered");
635 let top = &set.shapelets()[0];
637 assert!(top.quality > 0.0, "top shapelet has non-positive quality");
638 let s = top.start;
640 let e = top.start + top.length;
641 assert!(
642 !(e <= motif_start || motif_start + motif_len <= s),
643 "top shapelet [{s},{e}) does not overlap planted motif [{motif_start},{})",
644 motif_start + motif_len
645 );
646 assert!(
649 top.quality > 0.9,
650 "top shapelet IG {} not near max entropy 1.0",
651 top.quality
652 );
653 }
654
655 #[test]
656 fn test_discover_tractable_contracted() {
657 let n = 100usize;
659 let m = 200usize;
660 let mut flat = vec![0.0f64; n * m];
661 let mut labels = vec![0usize; n];
662 for i in 0..n {
663 let class1 = i % 2 == 1;
664 labels[i] = usize::from(class1);
665 for j in 0..m {
666 let mut v = (j as f64) * 0.01 + (i as f64) * 0.001;
667 if class1 && (80..90).contains(&j) {
668 v += 5.0;
669 }
670 flat[i + j * n] = v;
671 }
672 }
673 let data = FdMatrix::from_column_major(flat, n, m).unwrap();
674 let cfg = ShapeletDiscoveryConfig {
675 min_length: 10,
676 max_length: 20,
677 max_candidates: Some(800),
678 max_shapelets: 5,
679 quality: QualityMeasure::InfoGain,
680 seed: 7,
681 };
682 let start = std::time::Instant::now();
683 let set = discover_shapelets(&data, &labels, &cfg).unwrap();
684 let elapsed = start.elapsed();
685 assert!(
686 elapsed.as_secs() < 10,
687 "contracted discovery too slow: {elapsed:?}"
688 );
689 assert!(set.len() <= 5, "returned more than max_shapelets");
690 assert!(!set.is_empty());
691 }
692
693 #[test]
694 fn test_infogain_optimal_split() {
695 let mut orderline = vec![
697 (0.1, 0usize),
698 (0.2, 0),
699 (0.15, 0),
700 (5.0, 1),
701 (5.5, 1),
702 (6.0, 1),
703 ];
704 let ig = information_gain(&mut orderline, 2);
705 assert!((ig - 1.0).abs() < 1e-12, "IG for clean split not 1.0: {ig}");
707
708 let mut flat = vec![(1.0, 0usize), (1.0, 1), (1.0, 0), (1.0, 1)];
711 let ig0 = information_gain(&mut flat, 2);
712 assert!(ig0.abs() < 1e-12, "IG for degenerate split not 0: {ig0}");
713 }
714
715 #[test]
716 fn test_fstatistic_measure() {
717 let disc_d = [0.1, 0.12, 0.09, 5.0, 5.1, 4.9];
719 let labs = [0usize, 0, 0, 1, 1, 1];
720 let f_disc = f_statistic_1d(&disc_d, &labs, 2);
721 let noise_d = [1.0, 5.0, 1.0, 5.0, 1.0, 5.0];
723 let f_noise = f_statistic_1d(&noise_d, &labs, 2);
724 assert!(
725 f_disc > f_noise,
726 "F-stat did not rank discriminative above noise: {f_disc} vs {f_noise}"
727 );
728 assert!(
729 f_disc > 10.0,
730 "discriminative F-stat unexpectedly low: {f_disc}"
731 );
732
733 let (data, labels, _, motif_len) = planted_motif_dataset();
735 let cfg = ShapeletDiscoveryConfig {
736 min_length: motif_len,
737 max_length: motif_len,
738 max_candidates: None,
739 max_shapelets: 3,
740 quality: QualityMeasure::FStatistic,
741 seed: 0,
742 };
743 let set = discover_shapelets(&data, &labels, &cfg).unwrap();
744 assert!(!set.is_empty());
745 assert_eq!(set.quality(), QualityMeasure::FStatistic);
746 assert!(set.shapelets()[0].quality > 0.0);
747 }
748
749 #[test]
750 fn test_self_similarity_pruning() {
751 let (data, labels, _, _) = planted_motif_dataset();
752 let cfg = ShapeletDiscoveryConfig {
755 min_length: 6,
756 max_length: 6,
757 max_candidates: None,
758 max_shapelets: 8,
759 quality: QualityMeasure::InfoGain,
760 seed: 0,
761 };
762 let set = discover_shapelets(&data, &labels, &cfg).unwrap();
763 let shp = set.shapelets();
765 for a in 0..shp.len() {
766 for b in (a + 1)..shp.len() {
767 if shp[a].series_idx == shp[b].series_idx {
768 let (sa, ea) = (shp[a].start, shp[a].start + shp[a].length);
769 let (sb, eb) = (shp[b].start, shp[b].start + shp[b].length);
770 assert!(
771 ea <= sb || eb <= sa,
772 "same-series shapelets overlap: [{sa},{ea}) & [{sb},{eb})"
773 );
774 }
775 }
776 }
777 }
778
779 #[test]
780 fn test_discover_deterministic() {
781 let n = 30usize;
784 let m = 60usize;
785 let mut flat = vec![0.0f64; n * m];
786 let mut labels = vec![0usize; n];
787 for i in 0..n {
788 let class1 = i % 2 == 1;
789 labels[i] = usize::from(class1);
790 for j in 0..m {
791 let mut v = (j as f64) * 0.02 + (i as f64) * 0.003;
792 if class1 && (20..30).contains(&j) {
793 v += 3.0;
794 }
795 flat[i + j * n] = v;
796 }
797 }
798 let data = FdMatrix::from_column_major(flat, n, m).unwrap();
799 let cfg = ShapeletDiscoveryConfig {
800 min_length: 8,
801 max_length: 12,
802 max_candidates: Some(500),
803 max_shapelets: 6,
804 quality: QualityMeasure::InfoGain,
805 seed: 123,
806 };
807 let a = discover_shapelets(&data, &labels, &cfg).unwrap();
808 let b = discover_shapelets(&data, &labels, &cfg).unwrap();
809 assert_eq!(a, b, "same-seed fits not byte-identical");
810 }
811
812 #[test]
813 fn test_discover_validation() {
814 let (data, labels, _, _) = planted_motif_dataset();
815 let (_n, ncols) = data.shape();
816
817 let one_class = vec![0usize; labels.len()];
819 let cfg = ShapeletDiscoveryConfig::default();
820 assert!(matches!(
821 discover_shapelets(&data, &one_class, &cfg),
822 Err(FdarError::InvalidParameter { .. })
823 ));
824
825 let short_labels = vec![0usize, 1];
827 assert!(matches!(
828 discover_shapelets(&data, &short_labels, &cfg),
829 Err(FdarError::InvalidDimension { .. })
830 ));
831
832 let cfg_bad = ShapeletDiscoveryConfig {
834 min_length: 10,
835 max_length: 5,
836 ..Default::default()
837 };
838 assert!(matches!(
839 discover_shapelets(&data, &labels, &cfg_bad),
840 Err(FdarError::InvalidParameter { .. })
841 ));
842
843 let cfg_big = ShapeletDiscoveryConfig {
845 min_length: 3,
846 max_length: ncols + 5,
847 ..Default::default()
848 };
849 assert!(matches!(
850 discover_shapelets(&data, &labels, &cfg_big),
851 Err(FdarError::InvalidParameter { .. })
852 ));
853 }
854}