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}