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