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