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}