Skip to main content

fdars_core/shapelet/
transform.rs

1//! Shapelet transform: turn a fitted [`ShapeletSet`] into an `n×K` distance
2//! feature matrix, for both training and out-of-sample curves.
3//!
4//! Given `K` discovered shapelets and a curve set of `n` series, the transform
5//! produces an `n×K` [`FdMatrix`] whose entry `X[(i, j)]` is the shapelet
6//! distance ([`shapelet_distance`]) from shapelet `j` to curve `i`:
7//!
8//! ```text
9//! X[(i, j)] = sdist(shapelet_j, curve_i)
10//! ```
11//!
12//! **The `K` output columns are shapelet distances, not functional evaluation
13//! points.** Downstream (Phase 60) an fdars classifier consumes this matrix as
14//! `data` — rows are observations, columns are the `K` shapelet-distance
15//! features.
16//!
17//! # Consistency and normalization
18//!
19//! The shapelets carried by a [`ShapeletSet`] are stored **already
20//! z-normalized** (Phase 57 provenance). The transform reuses those stored
21//! values directly and never re-normalizes against the input series' statistics.
22//! Every window of each input series is z-normalized independently at comparison
23//! time inside [`shapelet_distance`]. Because training and out-of-sample curves
24//! flow through the identical code path with the identical stored shapelets and
25//! `best_so_far = f64::INFINITY`, re-transforming the training set exactly
26//! reproduces the fit-time distances (see [`ShapeletTransformFit`]).
27
28use crate::error::FdarError;
29use crate::iter_maybe_parallel;
30use crate::matrix::FdMatrix;
31use crate::shapelet::discovery::{discover_shapelets, ShapeletDiscoveryConfig, ShapeletSet};
32use crate::shapelet::distance::shapelet_distance;
33
34#[cfg(feature = "parallel")]
35use rayon::iter::ParallelIterator;
36
37/// Apply a fitted [`ShapeletSet`] to a curve set, producing an `n×K` distance
38/// feature matrix.
39///
40/// Returns an [`FdMatrix`] with `n = data` rows (curves) and `K =
41/// shapelets.len()` columns, where
42/// `X[(i, j)] = shapelet_distance(&shapelets.shapelets()[j].values, curve_i, f64::INFINITY).0`.
43///
44/// The shapelet `values` are already z-normalized, so they are reused directly
45/// — no re-normalization against `data`. Each input row is z-normalized
46/// per-window inside [`shapelet_distance`]. The row loop is parallelized with
47/// [`iter_maybe_parallel!`]; distances are order-independent, so the result is
48/// identical with or without the `parallel` feature.
49///
50/// **Output columns are shapelet distances, not evaluation points.**
51///
52/// # Errors
53///
54/// - [`FdarError::InvalidParameter`] if the shapelet set is empty (`K == 0`): a
55///   zero-column feature matrix carries no information.
56/// - [`FdarError::InvalidDimension`] (propagated from [`shapelet_distance`]) if
57///   any series is shorter than a shapelet — no valid sliding window exists.
58///
59/// All returned entries are finite (guaranteed by the Phase 57 z-normalization
60/// constant-window guard).
61///
62/// # Examples
63///
64/// ```
65/// use fdars_core::matrix::FdMatrix;
66/// use fdars_core::shapelet::{discover_shapelets, shapelet_transform, ShapeletDiscoveryConfig};
67///
68/// let n = 8usize;
69/// let m = 8usize;
70/// let mut flat = vec![0.0f64; n * m];
71/// let mut labels = vec![0usize; n];
72/// for i in 0..n {
73///     let class1 = i % 2 == 1;
74///     labels[i] = usize::from(class1);
75///     for j in 0..m {
76///         let base = 0.1 * (i as f64) + 0.05 * (j as f64);
77///         let motif = if class1 && (3..6).contains(&j) { (j as f64) * 2.0 } else { 0.0 };
78///         flat[i + j * n] = base + motif;
79///     }
80/// }
81/// let data = FdMatrix::from_column_major(flat, n, m).unwrap();
82///
83/// let cfg = ShapeletDiscoveryConfig { max_shapelets: 3, ..Default::default() };
84/// let set = discover_shapelets(&data, &labels, &cfg).unwrap();
85///
86/// let features = shapelet_transform(&set, &data).unwrap();
87/// assert_eq!(features.shape(), (n, set.len()));
88/// // Every entry is a finite shapelet distance.
89/// for j in 0..set.len() {
90///     for i in 0..n {
91///         assert!(features.get(i, j).unwrap().is_finite());
92///     }
93/// }
94/// ```
95#[must_use = "the transformed feature matrix should not be discarded"]
96pub fn shapelet_transform(shapelets: &ShapeletSet, data: &FdMatrix) -> Result<FdMatrix, FdarError> {
97    let k = shapelets.len();
98    if k == 0 {
99        return Err(FdarError::InvalidParameter {
100            parameter: "shapelets",
101            message: "shapelet set is empty (K = 0); a 0-column feature matrix is not useful"
102                .to_string(),
103        });
104    }
105
106    let (n, ncols) = data.shape();
107    let shp = shapelets.shapelets();
108
109    // Compute one row of K distances per curve, in parallel over curves.
110    // Each row is independent, so parallelism is deterministic. Any per-row
111    // error (e.g. a series shorter than a shapelet) is captured as an `Err`.
112    let rows: Vec<Result<Vec<f64>, FdarError>> = iter_maybe_parallel!(0..n)
113        .map(|i| {
114            // Copy the (non-contiguous, column-major) row contiguously once,
115            // then scan it against every shapelet.
116            let mut buf = vec![0.0f64; ncols];
117            data.row_to_buf(i, &mut buf);
118            let mut row = Vec::with_capacity(k);
119            for s in shp {
120                let (dist, _off) = shapelet_distance(&s.values, &buf, f64::INFINITY)?;
121                row.push(dist);
122            }
123            Ok(row)
124        })
125        .collect();
126
127    // Bubble the first error (deterministic: rows are order-independent).
128    let mut per_row = Vec::with_capacity(n);
129    for r in rows {
130        per_row.push(r?);
131    }
132
133    // Assemble the n×K matrix in column-major order: element (i, j) at i + j*n.
134    let mut flat = vec![0.0f64; n * k];
135    for (i, row) in per_row.iter().enumerate() {
136        for (j, &d) in row.iter().enumerate() {
137            flat[i + j * n] = d;
138        }
139    }
140    FdMatrix::from_column_major(flat, n, k)
141}
142
143/// A fitted shapelet transform: the discovered [`ShapeletSet`] plus the training
144/// feature matrix produced by applying it to the training curves.
145///
146/// Stores the already-z-normalized shapelets so that out-of-sample
147/// [`transform`](Self::transform) reuses the exact same shapelets and
148/// normalization as the fit — never re-discovering or re-normalizing against
149/// test-set statistics.
150#[derive(Debug, Clone, PartialEq)]
151#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
152#[non_exhaustive]
153pub struct ShapeletTransformFit {
154    /// The discovered, already-z-normalized shapelet set.
155    pub shapelets: ShapeletSet,
156    /// The training `n×K` distance feature matrix (columns = shapelet distances).
157    pub features: FdMatrix,
158}
159
160impl ShapeletTransformFit {
161    /// The fitted shapelet set (already z-normalized, ordered by quality).
162    #[must_use]
163    pub fn shapelets(&self) -> &ShapeletSet {
164        &self.shapelets
165    }
166
167    /// The training `n×K` feature matrix (columns are shapelet distances).
168    #[must_use]
169    pub fn features(&self) -> &FdMatrix {
170        &self.features
171    }
172
173    /// Transform out-of-sample curves using the stored shapelets.
174    ///
175    /// Applies the exact fitted shapelets (same sequences, same stored
176    /// z-normalization) to `new_data`, yielding an `n_new×K` feature matrix.
177    /// This is [`shapelet_transform`] against the stored set — no re-discovery,
178    /// no re-normalization against `new_data`.
179    ///
180    /// # Errors
181    ///
182    /// Same as [`shapelet_transform`]: [`FdarError::InvalidDimension`] if a
183    /// series is shorter than a shapelet, [`FdarError::InvalidParameter`] if the
184    /// stored set is empty.
185    #[must_use = "the transformed feature matrix should not be discarded"]
186    pub fn transform(&self, new_data: &FdMatrix) -> Result<FdMatrix, FdarError> {
187        shapelet_transform(self.shapelets(), new_data)
188    }
189}
190
191/// Fit a shapelet transform: discover shapelets from a labeled training set,
192/// then transform that training set into an `n×K` distance feature matrix.
193///
194/// Calls [`discover_shapelets`] (Phase 58) with `config`, then
195/// [`shapelet_transform`] on the training `data`, storing both the shapelet set
196/// and the training features in the returned [`ShapeletTransformFit`]. Reuse the
197/// stored set on new curves via [`ShapeletTransformFit::transform`].
198///
199/// `data` is a column-major [`FdMatrix`] with rows = curves, columns =
200/// evaluation points; `labels[i]` is the integer class of curve `i`.
201///
202/// # Errors
203///
204/// - Any error from [`discover_shapelets`] (e.g. [`FdarError::InvalidDimension`]
205///   on label/row mismatch, [`FdarError::InvalidParameter`] on fewer than 2
206///   classes or bad length bounds).
207/// - Any error from [`shapelet_transform`] (empty discovered set, or a series
208///   shorter than a discovered shapelet).
209///
210/// # Examples
211///
212/// ```
213/// use fdars_core::matrix::FdMatrix;
214/// use fdars_core::shapelet::{shapelet_transform_fit, ShapeletDiscoveryConfig};
215///
216/// // Two classes of length-8 curves; class 1 carries a motif class 0 lacks.
217/// let n = 8usize;
218/// let m = 8usize;
219/// let mut flat = vec![0.0f64; n * m];
220/// let mut labels = vec![0usize; n];
221/// for i in 0..n {
222///     let class1 = i % 2 == 1;
223///     labels[i] = usize::from(class1);
224///     for j in 0..m {
225///         let base = 0.1 * (i as f64) + 0.05 * (j as f64);
226///         let motif = if class1 && (3..6).contains(&j) { (j as f64) * 2.0 } else { 0.0 };
227///         flat[i + j * n] = base + motif;
228///     }
229/// }
230/// let data = FdMatrix::from_column_major(flat, n, m).unwrap();
231///
232/// let cfg = ShapeletDiscoveryConfig { max_shapelets: 3, ..Default::default() };
233/// let fit = shapelet_transform_fit(&data, &labels, &cfg).unwrap();
234///
235/// // Training features are n×K; reuse the fitted shapelets on new curves.
236/// let k = fit.shapelets().len();
237/// assert_eq!(fit.features().shape(), (n, k));
238/// let new_features = fit.transform(&data).unwrap();
239/// assert_eq!(new_features.shape(), (n, k));
240/// ```
241#[must_use = "the fitted shapelet transform should not be discarded"]
242pub fn shapelet_transform_fit(
243    data: &FdMatrix,
244    labels: &[usize],
245    config: &ShapeletDiscoveryConfig,
246) -> Result<ShapeletTransformFit, FdarError> {
247    let shapelets = discover_shapelets(data, labels, config)?;
248    let features = shapelet_transform(&shapelets, data)?;
249    Ok(ShapeletTransformFit {
250        shapelets,
251        features,
252    })
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::shapelet::distance::shapelet_distance;
259
260    /// Two-class dataset: class 1 carries a triangular motif class 0 lacks.
261    /// `rows` optionally overrides the number of curves (labels alternate).
262    fn labeled_dataset(n: usize, m: usize) -> (FdMatrix, Vec<usize>) {
263        let mut flat = vec![0.0f64; n * m];
264        let mut labels = vec![0usize; n];
265        let motif_start = m / 2;
266        let motif_len = (m / 4).max(1);
267        for i in 0..n {
268            let class1 = i % 2 == 1;
269            labels[i] = usize::from(class1);
270            let offset = 0.01 * (i as f64);
271            for j in 0..m {
272                let mut v = offset + (j as f64) * 0.001;
273                let hash = (i.wrapping_mul(2654435761) ^ j.wrapping_mul(40503)) % 211;
274                v += 0.05 * (hash as f64 / 211.0 - 0.5);
275                if class1 && j >= motif_start && j < motif_start + motif_len {
276                    let k = j - motif_start;
277                    let half = motif_len / 2;
278                    let tri = if k <= half {
279                        k as f64
280                    } else {
281                        (motif_len - k) as f64
282                    };
283                    v += tri;
284                }
285                flat[i + j * n] = v;
286            }
287        }
288        (FdMatrix::from_column_major(flat, n, m).unwrap(), labels)
289    }
290
291    fn default_cfg() -> ShapeletDiscoveryConfig {
292        ShapeletDiscoveryConfig {
293            min_length: 3,
294            max_length: 6,
295            max_candidates: None,
296            max_shapelets: 4,
297            seed: 0,
298            ..Default::default()
299        }
300    }
301
302    #[test]
303    fn test_transform_fit_shape() {
304        let (data, labels) = labeled_dataset(16, 24);
305        let cfg = default_cfg();
306        let fit = shapelet_transform_fit(&data, &labels, &cfg).unwrap();
307
308        let k = fit.shapelets().len();
309        assert!(k > 0, "no shapelets discovered");
310        assert_eq!(fit.features().shape(), (16, k), "training features not n×K");
311        // All entries finite.
312        for idx in 0..fit.features().len() {
313            let (i, j) = (idx % 16, idx / 16);
314            let v = fit.features().get(i, j).unwrap();
315            assert!(v.is_finite(), "non-finite feature at ({i},{j}): {v}");
316        }
317    }
318
319    #[test]
320    fn test_transform_out_of_sample_shape() {
321        let (train, labels) = labeled_dataset(16, 24);
322        let cfg = default_cfg();
323        let fit = shapelet_transform_fit(&train, &labels, &cfg).unwrap();
324        let k = fit.shapelets().len();
325
326        // New set with a DIFFERENT row count (catches a transpose bug).
327        let n_new = 9usize;
328        let (new_data, _new_labels) = labeled_dataset(n_new, 24);
329        let features = fit.transform(&new_data).unwrap();
330        assert_eq!(features.nrows(), n_new, "wrong row count (transpose?)");
331        assert_eq!(features.ncols(), k, "wrong column count (transpose?)");
332        assert_ne!(n_new, 16, "test setup: n_new must differ from n_train");
333        for idx in 0..features.len() {
334            let (i, j) = (idx % n_new, idx / n_new);
335            assert!(features.get(i, j).unwrap().is_finite());
336        }
337    }
338
339    #[test]
340    fn test_transform_consistency() {
341        let (train, labels) = labeled_dataset(16, 24);
342        let cfg = default_cfg();
343        let fit = shapelet_transform_fit(&train, &labels, &cfg).unwrap();
344
345        // Re-transforming the training data reproduces the stored features.
346        let re = fit.transform(&train).unwrap();
347        assert_eq!(re.shape(), fit.features().shape());
348        for idx in 0..re.len() {
349            let (i, j) = (idx % 16, idx / 16);
350            let a = re.get(i, j).unwrap();
351            let b = fit.features().get(i, j).unwrap();
352            assert!(
353                (a - b).abs() < 1e-12,
354                "transform not consistent at ({i},{j}): {a} vs {b}"
355            );
356        }
357        // Two transform calls are bit-identical.
358        let re2 = fit.transform(&train).unwrap();
359        assert_eq!(re, re2, "two transform(train) calls differ");
360    }
361
362    #[test]
363    fn test_transform_values_are_sdist() {
364        // Tiny hand-checked case: build a set with two known shapelets and
365        // verify each X[i,j] equals the direct shapelet_distance.
366        let n = 3usize;
367        let m = 7usize;
368        let mut flat = vec![0.0f64; n * m];
369        let mut labels = vec![0usize; n];
370        for i in 0..n {
371            labels[i] = i % 2;
372            for j in 0..m {
373                flat[i + j * n] = (i as f64) + (j as f64) * (1.0 + i as f64);
374            }
375        }
376        let data = FdMatrix::from_column_major(flat, n, m).unwrap();
377        let cfg = ShapeletDiscoveryConfig {
378            min_length: 3,
379            max_length: 4,
380            max_candidates: None,
381            max_shapelets: 2,
382            seed: 0,
383            ..Default::default()
384        };
385        let set = discover_shapelets(&data, &labels, &cfg).unwrap();
386        let features = shapelet_transform(&set, &data).unwrap();
387
388        for j in 0..set.len() {
389            let s = &set.shapelets()[j];
390            for i in 0..n {
391                let row = data.row(i);
392                let (expected, _off) = shapelet_distance(&s.values, &row, f64::INFINITY).unwrap();
393                let got = features.get(i, j).unwrap();
394                assert_eq!(got, expected, "X[{i},{j}] != direct sdist");
395            }
396        }
397    }
398
399    #[test]
400    fn test_transform_short_series_error() {
401        let (train, labels) = labeled_dataset(12, 24);
402        let cfg = default_cfg();
403        let fit = shapelet_transform_fit(&train, &labels, &cfg).unwrap();
404
405        // A new series shorter than the longest shapelet must error.
406        let longest = fit
407            .shapelets()
408            .shapelets()
409            .iter()
410            .map(|s| s.length)
411            .max()
412            .unwrap();
413        let short_m = longest - 1;
414        assert!(short_m >= 1, "test setup: need a positive short length");
415        let (short_data, _) = labeled_dataset(4, short_m);
416        let err = fit.transform(&short_data).unwrap_err();
417        assert!(
418            matches!(err, FdarError::InvalidDimension { .. }),
419            "expected InvalidDimension, got {err:?}"
420        );
421    }
422
423    #[test]
424    fn test_transform_empty_set_error() {
425        // Hand-build an empty ShapeletSet (K = 0).
426        let empty = ShapeletSet {
427            shapelets: Vec::new(),
428            quality: crate::shapelet::QualityMeasure::InfoGain,
429        };
430        let (data, _labels) = labeled_dataset(4, 10);
431        let err = shapelet_transform(&empty, &data).unwrap_err();
432        assert!(
433            matches!(err, FdarError::InvalidParameter { .. }),
434            "expected InvalidParameter, got {err:?}"
435        );
436    }
437}