Skip to main content

fdars_core/shapelet/
distance.rs

1//! Shapelet distance core: per-window z-normalization and the sliding-window
2//! minimum z-normalized Euclidean distance (`sdist`), plus the [`Shapelet`] type.
3//!
4//! This module provides the atomic numerical primitive that every downstream
5//! shapelet phase (discovery, transform, classifier) builds on. It is pure
6//! `&[f64]` arithmetic — no nalgebra conversion, no integration weights.
7//!
8//! # Shapelet distance definition
9//!
10//! Given a length-`L` shapelet `S` (stored **already z-normalized**) and a
11//! series `T` of length `M ≥ L`, the shapelet distance is the minimum over all
12//! `M - L + 1` sliding windows of the Euclidean distance between the shapelet
13//! and the (independently, per-window) z-normalized window:
14//!
15//! ```text
16//! sdist(S, T) = min_{t = 0 .. M-L}  || z(T[t : t+L]) - S ||_2
17//! ```
18//!
19//! Each window is z-normalized **independently at comparison time** — never the
20//! whole series once up front. This is what makes the distance scale- and
21//! offset-invariant (it captures *shape*, not amplitude/offset).
22//!
23//! # z-normalization convention
24//!
25//! Z-normalization here uses the **population** standard deviation (`ddof = 0`),
26//! matching the pyts convention. (sktime/aeon variants may use `ddof = 1`; that
27//! divergence is intentional and noted here.) A constant or near-constant window
28//! (population std ≤ `1e-12`) normalizes to the **zero vector** rather than
29//! producing `NaN`/`Inf`.
30
31use crate::error::FdarError;
32
33/// Standard-deviation floor for the constant-window guard.
34///
35/// A window whose population standard deviation is at or below this threshold is
36/// treated as constant and normalized to the zero vector (never divided by
37/// ~zero, so the result is always finite).
38const STD_EPS: f64 = 1e-12;
39
40/// Z-normalize `src` into `dst` in place (population std, `ddof = 0`).
41///
42/// Subtracts the arithmetic mean and divides by the population standard
43/// deviation. Uses a numerically stable two-pass computation (mean first, then
44/// std from deviations) rather than the unstable `E[X²] - E[X]²` form.
45///
46/// **Constant-window guard:** if the population std is ≤ `1e-12` the window is
47/// treated as constant and `dst` is filled with zeros. The output is therefore
48/// always finite — never `NaN` or `Inf`.
49///
50/// This is the allocation-free variant intended for the hot sliding-window loop,
51/// where `dst` is a reused scratch buffer.
52///
53/// # Panics
54///
55/// In debug builds, panics if `src.len() != dst.len()`. In release builds the
56/// shorter length is used (no out-of-bounds access).
57pub fn z_normalize_into(src: &[f64], dst: &mut [f64]) {
58    debug_assert_eq!(
59        src.len(),
60        dst.len(),
61        "z_normalize_into: src and dst length mismatch"
62    );
63    let n = src.len().min(dst.len());
64    if n == 0 {
65        return;
66    }
67    let len_f = n as f64;
68    // Pass 1: mean.
69    let mut sum = 0.0;
70    for &v in &src[..n] {
71        sum += v;
72    }
73    let mean = sum / len_f;
74    // Pass 2: population variance from deviations.
75    let mut sq = 0.0;
76    for &v in &src[..n] {
77        let d = v - mean;
78        sq += d * d;
79    }
80    let std = (sq / len_f).sqrt();
81    if std <= STD_EPS {
82        // Constant / near-constant window: zero vector, always finite.
83        for d in &mut dst[..n] {
84            *d = 0.0;
85        }
86        return;
87    }
88    let inv = 1.0 / std;
89    for i in 0..n {
90        dst[i] = (src[i] - mean) * inv;
91    }
92}
93
94/// Z-normalize a window slice, returning a freshly allocated vector.
95///
96/// Population std (`ddof = 0`); constant windows (std ≤ `1e-12`) map to the zero
97/// vector. See [`z_normalize_into`] for the in-place hot-loop variant.
98///
99/// # Examples
100///
101/// ```
102/// use fdars_core::shapelet::z_normalize_window;
103///
104/// // A constant window normalizes to zeros (no NaN/Inf).
105/// let z = z_normalize_window(&[5.0, 5.0, 5.0]);
106/// assert_eq!(z, vec![0.0, 0.0, 0.0]);
107///
108/// // A non-constant window has mean ~0 and population std ~1.
109/// let z = z_normalize_window(&[1.0, 2.0, 3.0]);
110/// let mean: f64 = z.iter().sum::<f64>() / z.len() as f64;
111/// assert!(mean.abs() < 1e-12);
112/// ```
113#[must_use]
114pub fn z_normalize_window(slice: &[f64]) -> Vec<f64> {
115    let mut out = vec![0.0; slice.len()];
116    z_normalize_into(slice, &mut out);
117    out
118}
119
120/// A discovered shapelet: a z-normalized discriminative subsequence plus its
121/// provenance in the training set.
122///
123/// The `values` are stored **already z-normalized** so that [`shapelet_distance`]
124/// (and downstream transform/predict paths) never re-normalize the shapelet
125/// against test statistics.
126///
127/// `quality` is a placeholder here (0.0); it is populated by the discovery phase
128/// (Phase 58) with a discriminative score (higher = better).
129#[derive(Debug, Clone, PartialEq)]
130#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
131#[non_exhaustive]
132pub struct Shapelet {
133    /// Z-normalized subsequence values.
134    pub values: Vec<f64>,
135    /// Index of the source training series this shapelet was extracted from.
136    pub series_idx: usize,
137    /// Start offset of the subsequence within the source series.
138    pub start: usize,
139    /// Length `L` of the subsequence.
140    pub length: usize,
141    /// Discriminative quality score (higher = better). 0.0 until set in discovery.
142    pub quality: f64,
143}
144
145impl Shapelet {
146    /// Build a shapelet from a source series slice, z-normalizing the window
147    /// `series[start .. start + length]` and recording provenance.
148    ///
149    /// `quality` is initialized to 0.0 (set later during discovery).
150    ///
151    /// # Errors
152    ///
153    /// Returns [`FdarError::InvalidDimension`] if `length == 0` or the window
154    /// `[start, start + length)` does not lie within `series`.
155    pub fn from_source(
156        series: &[f64],
157        series_idx: usize,
158        start: usize,
159        length: usize,
160    ) -> Result<Self, FdarError> {
161        if length == 0 {
162            return Err(FdarError::InvalidDimension {
163                parameter: "length",
164                expected: "length >= 1".to_string(),
165                actual: length.to_string(),
166            });
167        }
168        let end = start
169            .checked_add(length)
170            .ok_or(FdarError::InvalidDimension {
171                parameter: "start+length",
172                expected: format!("<= series length {}", series.len()),
173                actual: "overflow".to_string(),
174            })?;
175        if end > series.len() {
176            return Err(FdarError::InvalidDimension {
177                parameter: "start+length",
178                expected: format!("<= series length {}", series.len()),
179                actual: end.to_string(),
180            });
181        }
182        Ok(Self {
183            values: z_normalize_window(&series[start..end]),
184            series_idx,
185            start,
186            length,
187            quality: 0.0,
188        })
189    }
190
191    /// Length `L` of the shapelet.
192    #[must_use]
193    pub fn len(&self) -> usize {
194        self.length
195    }
196
197    /// Whether the shapelet has zero length.
198    #[must_use]
199    pub fn is_empty(&self) -> bool {
200        self.length == 0
201    }
202}
203
204/// Shapelet distance `sdist`: the minimum over sliding windows of the
205/// z-normalized Euclidean distance between the (pre-normalized) shapelet and
206/// each per-window-normalized window of `series`.
207///
208/// Returns `(min_distance, best_offset)` where `best_offset` is the start index
209/// of the window achieving the minimum (the **first** such offset on ties, for
210/// deterministic output).
211///
212/// # Early abandon
213///
214/// `best_so_far` is an upper bound on the distance we care about (from prior
215/// windows or an external caller). The inner element loop compares the running
216/// **squared** partial sum against `best_so_far²` and breaks as soon as it is
217/// exceeded — pruning hopeless windows early. Because the squared partial sum is
218/// monotonically non-decreasing, abandoning can only skip windows that cannot
219/// beat the current best, so the returned minimum is **identical** to a full,
220/// non-abandoned computation. Pass `best_so_far = f64::INFINITY` to disable
221/// abandoning entirely.
222///
223/// The metric is plain (unweighted) Euclidean distance over z-normalized
224/// values — deliberately *not* the Simpson/integration-weighted functional L2
225/// used elsewhere in the crate.
226///
227/// # Errors
228///
229/// Returns [`FdarError::InvalidDimension`] if the shapelet is empty or longer
230/// than `series` (no valid window).
231///
232/// # Examples
233///
234/// ```
235/// use fdars_core::shapelet::{shapelet_distance, z_normalize_window};
236///
237/// // Shapelet is a z-normalized motif; the series contains that exact motif at
238/// // offset 2. (A non-linear motif so only the true window matches.)
239/// let shapelet = z_normalize_window(&[1.0, 4.0, 2.0]);
240/// let series = [0.0, 9.0, 1.0, 4.0, 2.0, 7.0];
241/// let (dist, offset) = shapelet_distance(&shapelet, &series, f64::INFINITY).unwrap();
242/// assert!(dist < 1e-9);
243/// assert_eq!(offset, 2);
244/// ```
245#[must_use = "the shapelet distance and best-match offset should not be discarded"]
246pub fn shapelet_distance(
247    shapelet_z: &[f64],
248    series: &[f64],
249    best_so_far: f64,
250) -> Result<(f64, usize), FdarError> {
251    let l = shapelet_z.len();
252    if l == 0 {
253        return Err(FdarError::InvalidDimension {
254            parameter: "shapelet_z",
255            expected: "length >= 1".to_string(),
256            actual: "0".to_string(),
257        });
258    }
259    if l > series.len() {
260        return Err(FdarError::InvalidDimension {
261            parameter: "shapelet_z.len",
262            expected: format!("<= series length {}", series.len()),
263            actual: l.to_string(),
264        });
265    }
266
267    // Running best squared distance (compare everything in squared space; sqrt
268    // only the final answer). Seed from the caller's bound so early-abandon can
269    // prune from the very first window.
270    let mut best_sq = if best_so_far.is_finite() {
271        best_so_far * best_so_far
272    } else {
273        f64::INFINITY
274    };
275    let mut best_offset = 0usize;
276    let mut found = false;
277
278    // Reused scratch buffer for the per-window z-normalization (no per-window
279    // allocation in the hot loop).
280    let mut window_z = vec![0.0; l];
281
282    let n_windows = series.len() - l + 1;
283    for t in 0..n_windows {
284        let window = &series[t..t + l];
285        z_normalize_into(window, &mut window_z);
286
287        // Accumulate squared Euclidean distance with early abandon.
288        let mut acc = 0.0;
289        let mut abandoned = false;
290        for k in 0..l {
291            let diff = window_z[k] - shapelet_z[k];
292            acc += diff * diff;
293            if acc > best_sq {
294                abandoned = true;
295                break;
296            }
297        }
298        if abandoned {
299            continue;
300        }
301        // acc <= best_sq here. Strict `<` keeps the first-minimum offset on ties.
302        if !found || acc < best_sq {
303            best_sq = acc;
304            best_offset = t;
305            found = true;
306        }
307    }
308
309    // If every window abandoned against the caller's tight bound, no window beat
310    // it; report the bound itself as the (non-improving) minimum at offset 0.
311    let min_dist = if found { best_sq.sqrt() } else { best_so_far };
312    Ok((min_dist, best_offset))
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    fn population_std(z: &[f64]) -> f64 {
320        let n = z.len() as f64;
321        let mean = z.iter().sum::<f64>() / n;
322        (z.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n).sqrt()
323    }
324
325    #[test]
326    fn test_znorm_constant_window() {
327        // Exactly constant.
328        let z = z_normalize_window(&[5.0, 5.0, 5.0, 5.0]);
329        assert_eq!(z, vec![0.0; 4]);
330        assert!(z.iter().all(|v| v.is_finite()));
331
332        // Near-constant: one element perturbed by 1e-15 must still be finite.
333        let mut x = vec![5.0; 20];
334        x[3] += 1e-15;
335        let z = z_normalize_window(&x);
336        assert!(
337            z.iter().all(|v| v.is_finite()),
338            "near-constant produced non-finite"
339        );
340    }
341
342    #[test]
343    fn test_znorm_mean_std() {
344        let z = z_normalize_window(&[1.0, 2.0, 3.0, 4.0, 5.0]);
345        let mean = z.iter().sum::<f64>() / z.len() as f64;
346        assert!(mean.abs() < 1e-12, "mean not ~0: {mean}");
347        assert!(
348            (population_std(&z) - 1.0).abs() < 1e-12,
349            "population std not ~1"
350        );
351        assert!(z.iter().all(|v| v.is_finite()));
352    }
353
354    #[test]
355    fn test_sdist_scale_offset_invariant() {
356        // Shapelet = z-normed motif; series contains a plain motif in noise.
357        let shapelet = z_normalize_window(&[1.0, 3.0, 2.0, 4.0]);
358        let series = vec![0.5, 1.0, 3.0, 2.0, 4.0, 0.7, 0.2];
359
360        let (d0, o0) = shapelet_distance(&shapelet, &series, f64::INFINITY).unwrap();
361
362        // Offset by a constant.
363        let shifted: Vec<f64> = series.iter().map(|v| v + 100.0).collect();
364        let (d1, o1) = shapelet_distance(&shapelet, &shifted, f64::INFINITY).unwrap();
365
366        // Scale by a positive constant.
367        let scaled: Vec<f64> = series.iter().map(|v| v * 50.0).collect();
368        let (d2, o2) = shapelet_distance(&shapelet, &scaled, f64::INFINITY).unwrap();
369
370        assert!(
371            (d0 - d1).abs() < 1e-10,
372            "offset invariance failed: {d0} vs {d1}"
373        );
374        assert!(
375            (d0 - d2).abs() < 1e-10,
376            "scale invariance failed: {d0} vs {d2}"
377        );
378        assert_eq!(o0, o1);
379        assert_eq!(o0, o2);
380    }
381
382    #[test]
383    fn test_sdist_min_semantics() {
384        // Series contains an exact copy of the shapelet's source motif at offset 3.
385        let motif = [2.0, -1.0, 0.5, 3.0, 1.0];
386        let shapelet = z_normalize_window(&motif);
387        let mut series = vec![9.0, 8.0, 7.0]; // noise prefix
388        series.extend_from_slice(&motif);
389        series.extend_from_slice(&[6.0, 5.0]); // noise suffix
390
391        let (dist, offset) = shapelet_distance(&shapelet, &series, f64::INFINITY).unwrap();
392        assert!(dist < 1e-9, "exact-motif sdist not ~0: {dist}");
393        assert_eq!(offset, 3, "wrong best-match offset");
394    }
395
396    #[test]
397    fn test_sdist_early_abandon_identical() {
398        let shapelet = z_normalize_window(&[0.0, 1.0, 0.5, -1.0, 2.0]);
399        let series = vec![
400            3.0, 1.0, -2.0, 0.4, 1.5, 0.0, 1.0, 0.5, -1.0, 2.0, 4.0, 2.2, -0.3,
401        ];
402
403        // Truth: no abandon.
404        let (d_inf, o_inf) = shapelet_distance(&shapelet, &series, f64::INFINITY).unwrap();
405
406        // Tight bound that is >= the true min: abandon must only prune, not
407        // change the answer.
408        let bound = d_inf + 0.5;
409        let (d_tight, o_tight) = shapelet_distance(&shapelet, &series, bound).unwrap();
410        assert!((d_inf - d_tight).abs() < 1e-12, "abandon changed the min");
411        assert_eq!(o_inf, o_tight, "abandon changed the offset");
412
413        // An exact-min bound must also reproduce the min.
414        let (d_eq, o_eq) = shapelet_distance(&shapelet, &series, d_inf).unwrap();
415        assert!((d_inf - d_eq).abs() < 1e-12);
416        assert_eq!(o_inf, o_eq);
417    }
418
419    #[test]
420    fn test_sdist_dimension_error() {
421        let shapelet = z_normalize_window(&[1.0, 2.0, 3.0, 4.0, 5.0]);
422        let series = [1.0, 2.0]; // shorter than the shapelet
423        let err = shapelet_distance(&shapelet, &series, f64::INFINITY).unwrap_err();
424        assert!(matches!(err, FdarError::InvalidDimension { .. }));
425
426        // Empty shapelet also errors.
427        let err = shapelet_distance(&[], &series, f64::INFINITY).unwrap_err();
428        assert!(matches!(err, FdarError::InvalidDimension { .. }));
429    }
430
431    #[test]
432    fn test_shapelet_from_source() {
433        let series = [0.0, 1.0, 2.0, 3.0, 4.0];
434        let s = Shapelet::from_source(&series, 7, 1, 3).unwrap();
435        assert_eq!(s.series_idx, 7);
436        assert_eq!(s.start, 1);
437        assert_eq!(s.length, 3);
438        assert_eq!(s.len(), 3);
439        assert!(!s.is_empty());
440        assert_eq!(s.quality, 0.0);
441        // values == z-norm of series[1..4]
442        assert_eq!(s.values, z_normalize_window(&series[1..4]));
443
444        // Out-of-range window errors.
445        assert!(Shapelet::from_source(&series, 0, 3, 5).is_err());
446        assert!(Shapelet::from_source(&series, 0, 0, 0).is_err());
447    }
448}