use crate::error::FdarError;
use crate::iter_maybe_parallel;
use crate::matrix::FdMatrix;
use crate::shapelet::discovery::{discover_shapelets, ShapeletDiscoveryConfig, ShapeletSet};
use crate::shapelet::distance::shapelet_distance;
#[cfg(feature = "parallel")]
use rayon::iter::ParallelIterator;
#[must_use = "the transformed feature matrix should not be discarded"]
pub fn shapelet_transform(shapelets: &ShapeletSet, data: &FdMatrix) -> Result<FdMatrix, FdarError> {
let k = shapelets.len();
if k == 0 {
return Err(FdarError::InvalidParameter {
parameter: "shapelets",
message: "shapelet set is empty (K = 0); a 0-column feature matrix is not useful"
.to_string(),
});
}
let (n, ncols) = data.shape();
let shp = shapelets.shapelets();
let rows: Vec<Result<Vec<f64>, FdarError>> = iter_maybe_parallel!(0..n)
.map(|i| {
let mut buf = vec![0.0f64; ncols];
data.row_to_buf(i, &mut buf);
let mut row = Vec::with_capacity(k);
for s in shp {
let (dist, _off) = shapelet_distance(&s.values, &buf, f64::INFINITY)?;
row.push(dist);
}
Ok(row)
})
.collect();
let mut per_row = Vec::with_capacity(n);
for r in rows {
per_row.push(r?);
}
let mut flat = vec![0.0f64; n * k];
for (i, row) in per_row.iter().enumerate() {
for (j, &d) in row.iter().enumerate() {
flat[i + j * n] = d;
}
}
FdMatrix::from_column_major(flat, n, k)
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct ShapeletTransformFit {
pub shapelets: ShapeletSet,
pub features: FdMatrix,
}
impl ShapeletTransformFit {
#[must_use]
pub fn shapelets(&self) -> &ShapeletSet {
&self.shapelets
}
#[must_use]
pub fn features(&self) -> &FdMatrix {
&self.features
}
#[must_use = "the transformed feature matrix should not be discarded"]
pub fn transform(&self, new_data: &FdMatrix) -> Result<FdMatrix, FdarError> {
shapelet_transform(self.shapelets(), new_data)
}
}
#[must_use = "the fitted shapelet transform should not be discarded"]
pub fn shapelet_transform_fit(
data: &FdMatrix,
labels: &[usize],
config: &ShapeletDiscoveryConfig,
) -> Result<ShapeletTransformFit, FdarError> {
let shapelets = discover_shapelets(data, labels, config)?;
let features = shapelet_transform(&shapelets, data)?;
Ok(ShapeletTransformFit {
shapelets,
features,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shapelet::distance::shapelet_distance;
fn labeled_dataset(n: usize, m: usize) -> (FdMatrix, Vec<usize>) {
let mut flat = vec![0.0f64; n * m];
let mut labels = vec![0usize; n];
let motif_start = m / 2;
let motif_len = (m / 4).max(1);
for i in 0..n {
let class1 = i % 2 == 1;
labels[i] = usize::from(class1);
let offset = 0.01 * (i as f64);
for j in 0..m {
let mut v = offset + (j as f64) * 0.001;
let hash = (i.wrapping_mul(2654435761) ^ j.wrapping_mul(40503)) % 211;
v += 0.05 * (hash as f64 / 211.0 - 0.5);
if class1 && j >= motif_start && j < motif_start + motif_len {
let k = j - motif_start;
let half = motif_len / 2;
let tri = if k <= half {
k as f64
} else {
(motif_len - k) as f64
};
v += tri;
}
flat[i + j * n] = v;
}
}
(FdMatrix::from_column_major(flat, n, m).unwrap(), labels)
}
fn default_cfg() -> ShapeletDiscoveryConfig {
ShapeletDiscoveryConfig {
min_length: 3,
max_length: 6,
max_candidates: None,
max_shapelets: 4,
seed: 0,
..Default::default()
}
}
#[test]
fn test_transform_fit_shape() {
let (data, labels) = labeled_dataset(16, 24);
let cfg = default_cfg();
let fit = shapelet_transform_fit(&data, &labels, &cfg).unwrap();
let k = fit.shapelets().len();
assert!(k > 0, "no shapelets discovered");
assert_eq!(fit.features().shape(), (16, k), "training features not n×K");
for idx in 0..fit.features().len() {
let (i, j) = (idx % 16, idx / 16);
let v = fit.features().get(i, j).unwrap();
assert!(v.is_finite(), "non-finite feature at ({i},{j}): {v}");
}
}
#[test]
fn test_transform_out_of_sample_shape() {
let (train, labels) = labeled_dataset(16, 24);
let cfg = default_cfg();
let fit = shapelet_transform_fit(&train, &labels, &cfg).unwrap();
let k = fit.shapelets().len();
let n_new = 9usize;
let (new_data, _new_labels) = labeled_dataset(n_new, 24);
let features = fit.transform(&new_data).unwrap();
assert_eq!(features.nrows(), n_new, "wrong row count (transpose?)");
assert_eq!(features.ncols(), k, "wrong column count (transpose?)");
assert_ne!(n_new, 16, "test setup: n_new must differ from n_train");
for idx in 0..features.len() {
let (i, j) = (idx % n_new, idx / n_new);
assert!(features.get(i, j).unwrap().is_finite());
}
}
#[test]
fn test_transform_consistency() {
let (train, labels) = labeled_dataset(16, 24);
let cfg = default_cfg();
let fit = shapelet_transform_fit(&train, &labels, &cfg).unwrap();
let re = fit.transform(&train).unwrap();
assert_eq!(re.shape(), fit.features().shape());
for idx in 0..re.len() {
let (i, j) = (idx % 16, idx / 16);
let a = re.get(i, j).unwrap();
let b = fit.features().get(i, j).unwrap();
assert!(
(a - b).abs() < 1e-12,
"transform not consistent at ({i},{j}): {a} vs {b}"
);
}
let re2 = fit.transform(&train).unwrap();
assert_eq!(re, re2, "two transform(train) calls differ");
}
#[test]
fn test_transform_values_are_sdist() {
let n = 3usize;
let m = 7usize;
let mut flat = vec![0.0f64; n * m];
let mut labels = vec![0usize; n];
for i in 0..n {
labels[i] = i % 2;
for j in 0..m {
flat[i + j * n] = (i as f64) + (j as f64) * (1.0 + i as f64);
}
}
let data = FdMatrix::from_column_major(flat, n, m).unwrap();
let cfg = ShapeletDiscoveryConfig {
min_length: 3,
max_length: 4,
max_candidates: None,
max_shapelets: 2,
seed: 0,
..Default::default()
};
let set = discover_shapelets(&data, &labels, &cfg).unwrap();
let features = shapelet_transform(&set, &data).unwrap();
for j in 0..set.len() {
let s = &set.shapelets()[j];
for i in 0..n {
let row = data.row(i);
let (expected, _off) = shapelet_distance(&s.values, &row, f64::INFINITY).unwrap();
let got = features.get(i, j).unwrap();
assert_eq!(got, expected, "X[{i},{j}] != direct sdist");
}
}
}
#[test]
fn test_transform_short_series_error() {
let (train, labels) = labeled_dataset(12, 24);
let cfg = default_cfg();
let fit = shapelet_transform_fit(&train, &labels, &cfg).unwrap();
let longest = fit
.shapelets()
.shapelets()
.iter()
.map(|s| s.length)
.max()
.unwrap();
let short_m = longest - 1;
assert!(short_m >= 1, "test setup: need a positive short length");
let (short_data, _) = labeled_dataset(4, short_m);
let err = fit.transform(&short_data).unwrap_err();
assert!(
matches!(err, FdarError::InvalidDimension { .. }),
"expected InvalidDimension, got {err:?}"
);
}
#[test]
fn test_transform_empty_set_error() {
let empty = ShapeletSet {
shapelets: Vec::new(),
quality: crate::shapelet::QualityMeasure::InfoGain,
};
let (data, _labels) = labeled_dataset(4, 10);
let err = shapelet_transform(&empty, &data).unwrap_err();
assert!(
matches!(err, FdarError::InvalidParameter { .. }),
"expected InvalidParameter, got {err:?}"
);
}
}