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}