use crate::depth::band::{modified_band_1d, modified_epigraph_index_1d};
use crate::depth::{functional_boxplot, total_variation_depth_1d, DepthMethod};
use crate::error::FdarError;
use crate::helpers::{quantile_sorted, sort_nan_safe};
use crate::iter_maybe_parallel;
use crate::matrix::FdMatrix;
use crate::streaming_depth::{SortedReferenceState, StreamingDepth, StreamingFraimanMuniz};
use rand::prelude::*;
use rand_distr::StandardNormal;
#[cfg(feature = "parallel")]
use rayon::iter::ParallelIterator;
fn compute_trimmed_stats(data: &FdMatrix, depths: &[f64], n_keep: usize) -> (Vec<f64>, Vec<f64>) {
let m = data.ncols();
let mut depth_idx: Vec<(usize, f64)> =
depths.iter().enumerate().map(|(i, &d)| (i, d)).collect();
if n_keep < depth_idx.len() {
depth_idx.select_nth_unstable_by(n_keep - 1, |a, b| {
b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
});
}
let keep_idx: Vec<usize> = depth_idx[..n_keep].iter().map(|(i, _)| *i).collect();
let results: Vec<(f64, f64)> = iter_maybe_parallel!(0..m)
.map(|j| {
let mut mean_j = 0.0;
for &i in &keep_idx {
mean_j += data[(i, j)];
}
mean_j /= n_keep as f64;
let mut var_j = 0.0;
for &i in &keep_idx {
let diff = data[(i, j)] - mean_j;
var_j += diff * diff;
}
var_j /= n_keep as f64;
var_j = var_j.max(1e-10);
(mean_j, var_j)
})
.collect();
let trimmed_mean: Vec<f64> = results.iter().map(|&(m, _)| m).collect();
let trimmed_var: Vec<f64> = results.iter().map(|&(_, v)| v).collect();
(trimmed_mean, trimmed_var)
}
fn normalized_distance(
data: &FdMatrix,
i: usize,
trimmed_mean: &[f64],
trimmed_var: &[f64],
) -> f64 {
let m = data.ncols();
let mut dist = 0.0;
for j in 0..m {
let diff = data[(i, j)] - trimmed_mean[j];
dist += diff * diff / trimmed_var[j];
}
(dist / m as f64).sqrt()
}
#[must_use = "expensive computation whose result should not be discarded"]
pub fn outliers_threshold_lrt(
data: &FdMatrix,
nb: usize,
smo: f64,
trim: f64,
seed: u64,
percentile: f64,
) -> f64 {
outliers_threshold_lrt_with_dist(data, nb, smo, trim, seed, percentile).0
}
#[must_use = "expensive computation whose result should not be discarded"]
pub fn outliers_threshold_lrt_with_dist(
data: &FdMatrix,
nb: usize,
smo: f64,
trim: f64,
seed: u64,
percentile: f64,
) -> (f64, Vec<f64>) {
let n = data.nrows();
let m = data.ncols();
if n < 3 || m == 0 {
return (0.0, vec![]);
}
let n_keep = ((1.0 - trim) * n as f64).ceil().max(1.0) as usize;
let n_keep = n_keep.min(n);
let col_vars: Vec<f64> = iter_maybe_parallel!(0..m)
.map(|j| {
let mut sum = 0.0;
let mut sum_sq = 0.0;
for i in 0..n {
let val = data[(i, j)];
sum += val;
sum_sq += val * val;
}
let mean = sum / n as f64;
let var = sum_sq / n as f64 - mean * mean;
var.max(0.0).sqrt()
})
.collect();
let max_dists: Vec<f64> = iter_maybe_parallel!(0..nb)
.map(|b| {
let mut rng = StdRng::seed_from_u64(seed.wrapping_add(b as u64));
let indices: Vec<usize> = (0..n).map(|_| rng.gen_range(0..n)).collect();
let noise_vals: Vec<f64> = (0..n * m)
.map(|_| rng.sample::<f64, _>(StandardNormal))
.collect();
let mut boot_data = FdMatrix::zeros(n, m);
for j in 0..m {
let smo_var = smo * col_vars[j];
for (new_i, &old_i) in indices.iter().enumerate() {
let noise = noise_vals[new_i * m + j] * smo_var;
boot_data[(new_i, j)] = data[(old_i, j)] + noise;
}
}
let state = SortedReferenceState::from_reference(&boot_data);
let streaming_fm = StreamingFraimanMuniz::new(state, true);
let depths = streaming_fm.depth_batch(&boot_data);
let (trimmed_mean, trimmed_var) = compute_trimmed_stats(&boot_data, &depths, n_keep);
(0..n)
.map(|i| normalized_distance(&boot_data, i, &trimmed_mean, &trimmed_var))
.fold(0.0_f64, f64::max)
})
.collect();
let mut sorted_dists = max_dists;
crate::helpers::sort_nan_safe(&mut sorted_dists);
let idx =
crate::utility::f64_to_usize_clamped(nb as f64 * percentile).min(nb.saturating_sub(1));
let threshold = sorted_dists.get(idx).copied().unwrap_or(0.0);
(threshold, sorted_dists)
}
#[must_use = "expensive computation whose result should not be discarded"]
pub fn detect_outliers_lrt(data: &FdMatrix, threshold: f64, trim: f64) -> Vec<bool> {
let n = data.nrows();
let m = data.ncols();
if n < 3 || m == 0 {
return vec![false; n];
}
let n_keep = ((1.0 - trim) * n as f64).ceil().max(1.0) as usize;
let n_keep = n_keep.min(n);
let state = SortedReferenceState::from_reference(data);
let streaming_fm = StreamingFraimanMuniz::new(state, true);
let depths = streaming_fm.depth_batch(data);
let (trimmed_mean, trimmed_var) = compute_trimmed_stats(data, &depths, n_keep);
iter_maybe_parallel!(0..n)
.map(|i| normalized_distance(data, i, &trimmed_mean, &trimmed_var) > threshold)
.collect()
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct OutligramResult {
pub mei: Vec<f64>,
pub mbd: Vec<f64>,
pub a0: f64,
pub a1: f64,
pub a2: f64,
pub threshold: f64,
pub outlier_flags: Vec<bool>,
}
pub fn outliergram(data: &FdMatrix, factor: f64) -> Result<OutligramResult, FdarError> {
let n = data.nrows();
if n < 3 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "at least 3 rows".to_string(),
actual: format!("{n} rows"),
});
}
let mei = modified_epigraph_index_1d(data, data);
let mbd = modified_band_1d(data, data);
let mut xtx = [[0.0; 3]; 3];
let mut xty = [0.0; 3];
for i in 0..n {
let x = [1.0, mei[i], mei[i] * mei[i]];
for r in 0..3 {
for c in 0..3 {
xtx[r][c] += x[r] * x[c];
}
xty[r] += x[r] * mbd[i];
}
}
let (a0, a1, a2) = solve_3x3(xtx, xty);
let residuals: Vec<f64> = (0..n)
.map(|i| mbd[i] - (a0 + a1 * mei[i] + a2 * mei[i] * mei[i]))
.collect();
let mut sorted_resid = residuals.clone();
sorted_resid.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let q1 = sorted_resid[n / 4];
let q3 = sorted_resid[3 * n / 4];
let iqr = q3 - q1;
let threshold = q1 - factor * iqr;
let outlier_flags: Vec<bool> = residuals.iter().map(|&r| r < threshold).collect();
Ok(OutligramResult {
mei,
mbd,
a0,
a1,
a2,
threshold,
outlier_flags,
})
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct MagnitudeShapeResult {
pub magnitude: Vec<f64>,
pub shape: Vec<f64>,
}
pub fn magnitude_shape_outlyingness(data: &FdMatrix) -> Result<MagnitudeShapeResult, FdarError> {
let (n, m) = data.shape();
if n < 2 || m == 0 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "at least 2 rows and 1 column".to_string(),
actual: format!("{n} rows, {m} columns"),
});
}
let mbd = modified_band_1d(data, data);
let magnitude: Vec<f64> = mbd.iter().map(|&d| 1.0 - d).collect();
let mut col_means = vec![0.0; m];
for j in 0..m {
for i in 0..n {
col_means[j] += data[(i, j)];
}
col_means[j] /= n as f64;
}
let mut directions = vec![vec![0.0; m]; n];
for i in 0..n {
let mut norm_sq = 0.0;
for j in 0..m {
let c = data[(i, j)] - col_means[j];
directions[i][j] = c;
norm_sq += c * c;
}
let norm = norm_sq.sqrt().max(1e-15);
for j in 0..m {
directions[i][j] /= norm;
}
}
let mut mean_dir = vec![0.0; m];
for i in 0..n {
for j in 0..m {
mean_dir[j] += directions[i][j];
}
}
let mut mean_norm_sq = 0.0;
for j in 0..m {
mean_dir[j] /= n as f64;
mean_norm_sq += mean_dir[j] * mean_dir[j];
}
let mean_norm = mean_norm_sq.sqrt().max(1e-15);
for j in 0..m {
mean_dir[j] /= mean_norm;
}
let shape: Vec<f64> = (0..n)
.map(|i| {
let dist_sq: f64 = (0..m)
.map(|j| {
let d = directions[i][j] - mean_dir[j];
d * d
})
.sum();
dist_sq.sqrt()
})
.collect();
Ok(MagnitudeShapeResult { magnitude, shape })
}
fn solve_3x3(a: [[f64; 3]; 3], b: [f64; 3]) -> (f64, f64, f64) {
let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
- a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
+ a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
if det.abs() < 1e-15 {
return (0.0, 0.0, 0.0);
}
let det_x = b[0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
- a[0][1] * (b[1] * a[2][2] - a[1][2] * b[2])
+ a[0][2] * (b[1] * a[2][1] - a[1][1] * b[2]);
let det_y = a[0][0] * (b[1] * a[2][2] - a[1][2] * b[2])
- b[0] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
+ a[0][2] * (a[1][0] * b[2] - b[1] * a[2][0]);
let det_z = a[0][0] * (a[1][1] * b[2] - b[1] * a[2][1])
- a[0][1] * (a[1][0] * b[2] - b[1] * a[2][0])
+ b[0] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
(det_x / det, det_y / det, det_z / det)
}
fn iqr_fence(values: &[f64], factor: f64) -> (f64, f64) {
let mut sorted = values.to_vec();
sort_nan_safe(&mut sorted);
let q1 = quantile_sorted(&sorted, 0.25);
let q3 = quantile_sorted(&sorted, 0.75);
let iqr = q3 - q1;
(q1 - factor * iqr, q3 + factor * iqr)
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TvdMssConfig {
pub emp_factor_mss: f64,
pub emp_factor_tvd: f64,
pub central_region_tvd: f64,
}
impl Default for TvdMssConfig {
fn default() -> Self {
Self {
emp_factor_mss: 1.5,
emp_factor_tvd: 1.5,
central_region_tvd: 0.5,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct TvdMssOutliers {
pub magnitude_outliers: Vec<usize>,
pub shape_outliers: Vec<usize>,
pub tvd: Vec<f64>,
pub mss: Vec<f64>,
}
#[must_use = "outlier detection results should not be discarded"]
pub fn tvdmss(data: &FdMatrix, config: TvdMssConfig) -> Result<TvdMssOutliers, FdarError> {
let (n, m) = data.shape();
if n < 3 || m == 0 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "at least 3 curves and 1 column".to_string(),
actual: format!("{n} rows, {m} columns"),
});
}
let depth = total_variation_depth_1d(data, data)?;
let (lower_mss, _) = iqr_fence(&depth.mss, config.emp_factor_mss);
let mean_mss = depth.mss.iter().sum::<f64>() / n as f64;
let shape_outliers: Vec<usize> = (0..n)
.filter(|&i| depth.mss[i] < lower_mss && depth.mss[i] < mean_mss)
.collect();
let keep: Vec<usize> = (0..n).filter(|i| !shape_outliers.contains(i)).collect();
let mut magnitude_outliers = Vec::new();
if keep.len() >= 3 {
let kn = keep.len();
let mut col_major = vec![0.0; kn * m];
for (r, &orig) in keep.iter().enumerate() {
for j in 0..m {
col_major[r + j * kn] = data[(orig, j)];
}
}
let reduced = FdMatrix::from_column_major(col_major, kn, m)?;
let fbp = functional_boxplot(&reduced, DepthMethod::ModifiedBand, config.emp_factor_tvd)?;
magnitude_outliers = fbp.outliers.iter().map(|&r| keep[r]).collect();
}
Ok(TvdMssOutliers {
magnitude_outliers,
shape_outliers,
tvd: depth.tvd,
mss: depth.mss,
})
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct MuodConfig {
pub factor: f64,
}
impl Default for MuodConfig {
fn default() -> Self {
Self { factor: 1.5 }
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct MuodResult {
pub shape_outliers: Vec<usize>,
pub magnitude_outliers: Vec<usize>,
pub amplitude_outliers: Vec<usize>,
pub shape_index: Vec<f64>,
pub magnitude_index: Vec<f64>,
pub amplitude_index: Vec<f64>,
}
fn muod_indices(data: &FdMatrix) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let (n, m) = data.shape();
let mut mu = vec![0.0; m];
for (j, mu_j) in mu.iter_mut().enumerate() {
let mut s = 0.0;
for i in 0..n {
s += data[(i, j)];
}
*mu_j = s / n as f64;
}
let mu_mean = mu.iter().sum::<f64>() / m as f64;
let mu_var = mu.iter().map(|&v| (v - mu_mean).powi(2)).sum::<f64>() / (m as f64 - 1.0);
let mu_std = mu_var.sqrt();
let triples: Vec<(f64, f64, f64)> = iter_maybe_parallel!(0..n)
.map(|i| {
let mut xi_mean = 0.0;
for j in 0..m {
xi_mean += data[(i, j)];
}
xi_mean /= m as f64;
let mut cov = 0.0;
let mut xi_var = 0.0;
for j in 0..m {
let dx = data[(i, j)] - xi_mean;
let dmu = mu[j] - mu_mean;
cov += dx * dmu;
xi_var += dx * dx;
}
cov /= m as f64 - 1.0;
xi_var /= m as f64 - 1.0;
let xi_std = xi_var.sqrt();
let slope = if mu_var < 1e-15 { 1.0 } else { cov / mu_var };
let intercept = xi_mean - slope * mu_mean;
let corr = if xi_std < 1e-15 || mu_std < 1e-15 {
1.0
} else {
cov / (xi_std * mu_std)
};
((corr - 1.0).abs(), intercept.abs(), (slope - 1.0).abs())
})
.collect();
let mut shape = Vec::with_capacity(n);
let mut magnitude = Vec::with_capacity(n);
let mut amplitude = Vec::with_capacity(n);
for (s, mg, a) in triples {
shape.push(s);
magnitude.push(mg);
amplitude.push(a);
}
(shape, magnitude, amplitude)
}
#[must_use = "outlier detection results should not be discarded"]
pub fn muod(data: &FdMatrix, config: MuodConfig) -> Result<MuodResult, FdarError> {
let (n, m) = data.shape();
if n < 3 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "at least 3 curves".to_string(),
actual: format!("{n} rows"),
});
}
if m < 2 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "at least 2 columns".to_string(),
actual: format!("{m} columns"),
});
}
let (shape_index, magnitude_index, amplitude_index) = muod_indices(data);
let flag_upper = |idx: &[f64]| -> Vec<usize> {
let (_, upper) = iqr_fence(idx, config.factor);
(0..n).filter(|&i| idx[i] > upper).collect()
};
let shape_outliers = flag_upper(&shape_index);
let magnitude_outliers = flag_upper(&magnitude_index);
let amplitude_outliers = flag_upper(&litude_index);
Ok(MuodResult {
shape_outliers,
magnitude_outliers,
amplitude_outliers,
shape_index,
magnitude_index,
amplitude_index,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum SeqTransform {
T0,
T1,
T2,
D1,
D2,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SeqTransformConfig {
pub depth_method: DepthMethod,
pub emp_factor: f64,
}
impl Default for SeqTransformConfig {
fn default() -> Self {
Self {
depth_method: DepthMethod::ModifiedBand,
emp_factor: 1.5,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct SeqTransformOutliers {
pub per_transform_outliers: Vec<(SeqTransform, Vec<usize>)>,
pub union_outliers: Vec<usize>,
}
fn seq_transform_apply(current: &FdMatrix, t: SeqTransform) -> Result<FdMatrix, FdarError> {
let (n, m) = current.shape();
match t {
SeqTransform::T0 => Ok(current.clone()),
SeqTransform::T1 => {
let mut cm = vec![0.0; n * m];
for i in 0..n {
let mut mean = 0.0;
for j in 0..m {
mean += current[(i, j)];
}
mean /= m as f64;
for j in 0..m {
cm[i + j * n] = current[(i, j)] - mean;
}
}
FdMatrix::from_column_major(cm, n, m)
}
SeqTransform::T2 => {
let mut cm = vec![0.0; n * m];
for i in 0..n {
let mut norm = 0.0;
for j in 0..m {
norm += current[(i, j)].powi(2);
}
let norm = norm.sqrt();
if norm < 1e-15 {
return Err(FdarError::ComputationFailed {
operation: "T2 normalization",
detail: format!("zero-norm curve at row {i}"),
});
}
for j in 0..m {
cm[i + j * n] = current[(i, j)] / norm;
}
}
FdMatrix::from_column_major(cm, n, m)
}
SeqTransform::D1 | SeqTransform::D2 => {
if m < 2 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "at least 2 columns for lag-1 differencing".to_string(),
actual: format!("{m} columns"),
});
}
let m2 = m - 1;
let mut cm = vec![0.0; n * m2];
for i in 0..n {
for k in 0..m2 {
cm[i + k * n] = current[(i, k + 1)] - current[(i, k)];
}
}
FdMatrix::from_column_major(cm, n, m2)
}
}
}
#[must_use = "outlier detection results should not be discarded"]
pub fn sequential_transform_outliers(
data: &FdMatrix,
sequence: &[SeqTransform],
config: SeqTransformConfig,
) -> Result<SeqTransformOutliers, FdarError> {
let n = data.nrows();
if n < 2 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "at least 2 curves".to_string(),
actual: format!("{n} rows"),
});
}
let mut current = data.clone();
let mut per_transform_outliers = Vec::with_capacity(sequence.len());
for &t in sequence {
current = seq_transform_apply(¤t, t)?;
let fbp = functional_boxplot(¤t, config.depth_method, config.emp_factor)?;
per_transform_outliers.push((t, fbp.outliers));
}
let mut union_outliers: Vec<usize> = per_transform_outliers
.iter()
.flat_map(|(_, v)| v.iter().copied())
.collect();
union_outliers.sort_unstable();
union_outliers.dedup();
Ok(SeqTransformOutliers {
per_transform_outliers,
union_outliers,
})
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DepthgramConfig {
pub outliergram_factor: f64,
pub boxplot_factor: f64,
}
impl Default for DepthgramConfig {
fn default() -> Self {
Self {
outliergram_factor: 1.5,
boxplot_factor: 1.5,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct DepthgramResult {
pub mbd_mei_d: Vec<f64>,
pub mei_mbd_d: Vec<f64>,
pub mbd_mei_t: Vec<f64>,
pub mei_mbd_t: Vec<f64>,
pub mbd_mei_t2: Vec<f64>,
pub mei_mbd_t2: Vec<f64>,
pub shape_outliers: Vec<usize>,
pub magnitude_outliers: Vec<usize>,
pub mbd: Vec<f64>,
pub mei: Vec<f64>,
}
#[must_use = "outlier detection results should not be discarded"]
pub fn depthgram(data: &FdMatrix, config: DepthgramConfig) -> Result<DepthgramResult, FdarError> {
let (n, m) = data.shape();
if n < 2 || m == 0 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "at least 2 curves and 1 column".to_string(),
actual: format!("{n} rows, {m} columns"),
});
}
let mbd = modified_band_1d(data, data);
let mei = modified_epigraph_index_1d(data, data);
let mei_mat = FdMatrix::from_column_major(mei.clone(), n, 1)?;
let mbd_mat = FdMatrix::from_column_major(mbd.clone(), n, 1)?;
let mbd_mei = modified_band_1d(&mei_mat, &mei_mat);
let mei_mbd = modified_epigraph_index_1d(&mbd_mat, &mbd_mat);
let nf = n as f64;
let a2 = -2.0 / (nf * (nf - 1.0));
let a0 = a2;
let a1 = 2.0 * (nf + 1.0) / (nf - 1.0);
let dist: Vec<f64> = (0..n)
.map(|i| (a0 + a1 * mei[i] + a2 * nf * nf * mei[i] * mei[i]) - mbd[i])
.collect();
let (_, upper) = iqr_fence(&dist, config.outliergram_factor);
let shape_outliers: Vec<usize> = (0..n).filter(|&i| dist[i] > upper).collect();
let mbd_mat2 = FdMatrix::from_column_major(mbd.clone(), n, 1)?;
let fbp = functional_boxplot(&mbd_mat2, DepthMethod::ModifiedBand, config.boxplot_factor)?;
let magnitude_outliers = fbp.outliers;
Ok(DepthgramResult {
mbd_mei_d: mbd_mei.clone(),
mei_mbd_d: mei_mbd.clone(),
mbd_mei_t: mbd_mei.clone(),
mei_mbd_t: mei_mbd.clone(),
mbd_mei_t2: mbd_mei,
mei_mbd_t2: mei_mbd,
shape_outliers,
magnitude_outliers,
mbd,
mei,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::PI;
fn generate_normal_fdata(n: usize, m: usize, seed: u64) -> FdMatrix {
let mut rng = StdRng::seed_from_u64(seed);
let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
let mut data = FdMatrix::zeros(n, m);
for i in 0..n {
let phase: f64 = rng.gen::<f64>() * 0.2;
let amp: f64 = 1.0 + rng.gen::<f64>() * 0.1;
for j in 0..m {
let noise: f64 = rng.sample::<f64, _>(StandardNormal) * 0.05;
data[(i, j)] = amp * (2.0 * PI * t[j] + phase).sin() + noise;
}
}
data
}
fn generate_data_with_outlier(n: usize, m: usize, n_outliers: usize) -> FdMatrix {
let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
let mut data = FdMatrix::zeros(n, m);
for i in 0..(n - n_outliers) {
for j in 0..m {
data[(i, j)] = (2.0 * PI * t[j]).sin();
}
}
for i in (n - n_outliers)..n {
for j in 0..m {
data[(i, j)] = (2.0 * PI * t[j]).sin() + 10.0;
}
}
data
}
#[test]
fn test_outliers_threshold_lrt_returns_positive() {
let n = 20;
let m = 30;
let data = generate_normal_fdata(n, m, 42);
let threshold = outliers_threshold_lrt(&data, 50, 0.1, 0.1, 42, 0.95);
assert!(threshold > 0.0, "Threshold should be positive");
}
#[test]
fn test_outliers_threshold_lrt_deterministic() {
let n = 15;
let m = 25;
let data = generate_normal_fdata(n, m, 42);
let t1 = outliers_threshold_lrt(&data, 30, 0.1, 0.1, 123, 0.95);
let t2 = outliers_threshold_lrt(&data, 30, 0.1, 0.1, 123, 0.95);
assert!(
(t1 - t2).abs() < 1e-10,
"Same seed should give same threshold"
);
}
#[test]
fn test_outliers_threshold_lrt_percentile_effect() {
let n = 20;
let m = 30;
let data = generate_normal_fdata(n, m, 42);
let t_low = outliers_threshold_lrt(&data, 50, 0.1, 0.1, 42, 0.50);
let t_high = outliers_threshold_lrt(&data, 50, 0.1, 0.1, 42, 0.99);
assert!(
t_high >= t_low,
"Higher percentile should give higher or equal threshold"
);
}
#[test]
fn test_outliers_threshold_lrt_invalid_input() {
let data = FdMatrix::zeros(2, 30);
let threshold = outliers_threshold_lrt(&data, 50, 0.1, 0.1, 42, 0.95);
assert!(threshold.abs() < 1e-10, "Should return 0 for n < 3");
let data = FdMatrix::zeros(10, 0);
let threshold = outliers_threshold_lrt(&data, 50, 0.1, 0.1, 42, 0.95);
assert!(threshold.abs() < 1e-10);
}
#[test]
fn test_detect_outliers_lrt_finds_obvious_outlier() {
let n = 20;
let m = 30;
let data = generate_data_with_outlier(n, m, 1);
let outliers = detect_outliers_lrt(&data, 3.0, 0.1);
assert_eq!(outliers.len(), n);
assert!(outliers[n - 1], "Obvious outlier should be detected");
let n_detected: usize = outliers.iter().filter(|&&x| x).count();
assert!(n_detected <= 3, "Should not detect too many outliers");
}
#[test]
fn test_detect_outliers_lrt_homogeneous_data() {
let n = 20;
let m = 30;
let data = generate_normal_fdata(n, m, 42);
let outliers = detect_outliers_lrt(&data, 100.0, 0.1);
let n_detected: usize = outliers.iter().filter(|&&x| x).count();
assert_eq!(
n_detected, 0,
"Very high threshold should detect no outliers"
);
}
#[test]
fn test_detect_outliers_lrt_threshold_effect() {
let n = 20;
let m = 30;
let data = generate_data_with_outlier(n, m, 3);
let low_thresh = detect_outliers_lrt(&data, 2.0, 0.1);
let high_thresh = detect_outliers_lrt(&data, 10.0, 0.1);
let n_low: usize = low_thresh.iter().filter(|&&x| x).count();
let n_high: usize = high_thresh.iter().filter(|&&x| x).count();
assert!(
n_low >= n_high,
"Lower threshold should detect more or equal outliers"
);
}
#[test]
fn test_detect_outliers_lrt_invalid_input() {
let data = FdMatrix::zeros(2, 30);
let outliers = detect_outliers_lrt(&data, 3.0, 0.1);
assert_eq!(outliers.len(), 2);
assert!(
outliers.iter().all(|&x| !x),
"Should return all false for n < 3"
);
}
#[test]
fn test_identical_data_outliers() {
let n = 10;
let m = 20;
let data = FdMatrix::from_column_major(vec![1.0; n * m], n, m).unwrap();
let flags = detect_outliers_lrt(&data, 1.0, 0.15);
assert_eq!(flags.len(), n);
for &f in &flags {
assert!(!f);
}
}
#[test]
fn test_n3_minimal_outliers() {
let n = 3;
let m = 10;
let mut data_vec = vec![0.0; n * m];
for j in 0..m {
data_vec[j * n] = 0.0;
data_vec[1 + j * n] = 0.1;
data_vec[2 + j * n] = 100.0;
}
let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
let flags = detect_outliers_lrt(&data, 0.5, 0.15);
assert_eq!(flags.len(), n);
}
#[test]
fn test_with_dist_returns_sorted_distribution() {
let data = generate_normal_fdata(20, 30, 42);
let nb = 50;
let (threshold, dist) = outliers_threshold_lrt_with_dist(&data, nb, 0.1, 0.1, 42, 0.95);
assert_eq!(dist.len(), nb, "Distribution length should equal nb");
for w in dist.windows(2) {
assert!(w[0] <= w[1], "Distribution should be sorted");
}
let idx = ((nb as f64 * 0.95) as usize).min(nb - 1);
assert!(
(threshold - dist[idx]).abs() < 1e-10,
"Threshold should match distribution at percentile index"
);
}
#[test]
fn test_with_dist_matches_scalar() {
let data = generate_normal_fdata(15, 25, 99);
let scalar = outliers_threshold_lrt(&data, 40, 0.1, 0.1, 123, 0.95);
let (with_dist, _) = outliers_threshold_lrt_with_dist(&data, 40, 0.1, 0.1, 123, 0.95);
assert!(
(scalar - with_dist).abs() < 1e-10,
"Scalar version should match with_dist version"
);
}
#[test]
fn test_bootstrap_dist_enables_pvalue() {
let n = 20;
let m = 30;
let data = generate_data_with_outlier(n, m, 1);
let trim = 0.1;
let (_, dist) = outliers_threshold_lrt_with_dist(&data, 200, 0.1, trim, 42, 0.99);
let nb = dist.len();
let n_keep = ((1.0 - trim) * n as f64).ceil() as usize;
let state = SortedReferenceState::from_reference(&data);
let streaming_fm = StreamingFraimanMuniz::new(state, true);
let depths = streaming_fm.depth_batch(&data);
let (tmean, tvar) = compute_trimmed_stats(&data, &depths, n_keep);
let d_outlier = normalized_distance(&data, n - 1, &tmean, &tvar);
let p_outlier =
(dist.iter().filter(|&&v| v >= d_outlier).count() as f64 + 1.0) / (nb as f64 + 1.0);
let d_normal = normalized_distance(&data, 0, &tmean, &tvar);
let p_normal =
(dist.iter().filter(|&&v| v >= d_normal).count() as f64 + 1.0) / (nb as f64 + 1.0);
assert!(
p_outlier < 0.05,
"Outlier should have small p-value, got {p_outlier}"
);
assert!(
p_normal > 0.05,
"Normal curve should have large p-value, got {p_normal}"
);
}
#[test]
fn test_with_dist_invalid_input() {
let data = FdMatrix::zeros(2, 30);
let (threshold, dist) = outliers_threshold_lrt_with_dist(&data, 50, 0.1, 0.1, 42, 0.95);
assert!(threshold.abs() < 1e-10);
assert!(dist.is_empty(), "Should return empty dist for n < 3");
}
#[test]
fn test_all_false_high_threshold() {
let n = 10;
let m = 20;
let data_vec: Vec<f64> = (0..n * m).map(|i| (i as f64 * 0.1).sin()).collect();
let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
let flags = detect_outliers_lrt(&data, 1e10, 0.15);
for &f in &flags {
assert!(!f, "High threshold should produce no outliers");
}
}
#[test]
fn test_trim_zero_no_trimming() {
let data = generate_normal_fdata(10, 20, 42);
let threshold = outliers_threshold_lrt(&data, 30, 0.1, 0.0, 42, 0.95);
assert!(threshold > 0.0);
let flags = detect_outliers_lrt(&data, threshold, 0.0);
assert_eq!(flags.len(), 10);
}
#[test]
fn test_trim_near_one_heavy_trimming() {
let data = generate_normal_fdata(10, 20, 42);
let threshold = outliers_threshold_lrt(&data, 30, 0.1, 0.9, 42, 0.95);
assert!(threshold >= 0.0);
let flags = detect_outliers_lrt(&data, threshold, 0.9);
assert_eq!(flags.len(), 10);
}
#[test]
fn test_trim_one_clamps_to_one() {
let data = generate_normal_fdata(10, 20, 42);
let threshold = outliers_threshold_lrt(&data, 30, 0.1, 1.0, 42, 0.95);
assert!(threshold >= 0.0);
let flags = detect_outliers_lrt(&data, threshold, 1.0);
assert_eq!(flags.len(), 10);
}
#[test]
fn test_trim_negative_clamps_to_n() {
let data = generate_normal_fdata(10, 20, 42);
let threshold = outliers_threshold_lrt(&data, 30, 0.1, -0.5, 42, 0.95);
assert!(threshold > 0.0);
let flags = detect_outliers_lrt(&data, threshold, -0.5);
assert_eq!(flags.len(), 10);
}
#[test]
fn test_smo_zero_no_noise() {
let data = generate_normal_fdata(10, 20, 42);
let (threshold, dist) = outliers_threshold_lrt_with_dist(&data, 30, 0.0, 0.1, 42, 0.95);
assert!(threshold > 0.0);
assert_eq!(dist.len(), 30);
}
#[test]
fn test_nb_zero_empty_bootstrap() {
let data = generate_normal_fdata(10, 20, 42);
let (threshold, dist) = outliers_threshold_lrt_with_dist(&data, 0, 0.1, 0.1, 42, 0.95);
assert!(threshold.abs() < 1e-10);
assert!(dist.is_empty());
}
#[test]
fn test_nb_one_single_bootstrap() {
let data = generate_normal_fdata(10, 20, 42);
let (threshold, dist) = outliers_threshold_lrt_with_dist(&data, 1, 0.1, 0.1, 42, 0.95);
assert_eq!(dist.len(), 1);
assert!((threshold - dist[0]).abs() < 1e-10);
}
#[test]
fn test_percentile_zero_returns_minimum() {
let data = generate_normal_fdata(15, 20, 42);
let nb = 50;
let (_, dist) = outliers_threshold_lrt_with_dist(&data, nb, 0.1, 0.1, 42, 0.95);
let t_zero = outliers_threshold_lrt(&data, nb, 0.1, 0.1, 42, 0.0);
assert!(
(t_zero - dist[0]).abs() < 1e-10,
"percentile=0 should return the minimum of the distribution"
);
}
#[test]
fn test_percentile_one_returns_maximum() {
let data = generate_normal_fdata(15, 20, 42);
let nb = 50;
let (_, dist) = outliers_threshold_lrt_with_dist(&data, nb, 0.1, 0.1, 42, 0.95);
let t_one = outliers_threshold_lrt(&data, nb, 0.1, 0.1, 42, 1.0);
assert!(
(t_one - *dist.last().unwrap()).abs() < 1e-10,
"percentile=1 should return the maximum of the distribution"
);
}
#[test]
fn test_distribution_values_non_negative() {
let data = generate_normal_fdata(15, 20, 42);
let (_, dist) = outliers_threshold_lrt_with_dist(&data, 50, 0.1, 0.1, 42, 0.95);
for &v in &dist {
assert!(v >= 0.0, "Max-distances must be non-negative, got {v}");
}
}
#[test]
fn test_detect_m_zero_returns_all_false() {
let data = FdMatrix::zeros(10, 0);
let flags = detect_outliers_lrt(&data, 3.0, 0.1);
assert_eq!(flags.len(), 10);
assert!(flags.iter().all(|&f| !f));
}
#[test]
fn test_detect_multiple_outliers() {
let data = generate_data_with_outlier(20, 30, 3);
let flags = detect_outliers_lrt(&data, 3.0, 0.1);
let outlier_count = flags[17..20].iter().filter(|&&x| x).count();
assert!(
outlier_count >= 2,
"At least 2 of 3 outliers should be detected, got {outlier_count}"
);
}
#[test]
fn test_end_to_end_threshold_then_detect() {
let data = generate_data_with_outlier(20, 30, 2);
let threshold = outliers_threshold_lrt(&data, 100, 0.1, 0.1, 42, 0.99);
let flags = detect_outliers_lrt(&data, threshold, 0.1);
assert!(
flags[18] || flags[19],
"At least one outlier should be detected in end-to-end flow"
);
let false_positives = flags[..18].iter().filter(|&&x| x).count();
assert!(
false_positives <= 2,
"False positive count should be low, got {false_positives}"
);
}
#[test]
fn test_end_to_end_with_dist_pvalues_all_curves() {
let n = 25;
let m = 30;
let data = generate_data_with_outlier(n, m, 2);
let trim = 0.1;
let (_, dist) = outliers_threshold_lrt_with_dist(&data, 200, 0.1, trim, 42, 0.99);
let nb = dist.len();
let n_keep = ((1.0 - trim) * n as f64).ceil().max(1.0) as usize;
let n_keep = n_keep.min(n);
let state = SortedReferenceState::from_reference(&data);
let streaming_fm = StreamingFraimanMuniz::new(state, true);
let depths = streaming_fm.depth_batch(&data);
let (tmean, tvar) = compute_trimmed_stats(&data, &depths, n_keep);
let pvalues: Vec<f64> = (0..n)
.map(|i| {
let d = normalized_distance(&data, i, &tmean, &tvar);
(dist.iter().filter(|&&v| v >= d).count() as f64 + 1.0) / (nb as f64 + 1.0)
})
.collect();
let normal_small_p = pvalues[..23].iter().filter(|&&p| p < 0.01).count();
assert_eq!(
normal_small_p, 0,
"Normal curves should not have tiny p-values"
);
for &i in &[23, 24] {
assert!(
pvalues[i] < 0.05,
"Outlier curve {i} should have small p-value, got {}",
pvalues[i]
);
}
}
fn outliergram_test_data() -> FdMatrix {
let n = 20;
let m = 30;
let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
let mut vals = vec![0.0; n * m];
for i in 0..n {
for (j, &tj) in t.iter().enumerate() {
let base = tj.sin();
vals[i + j * n] = if i < 18 {
base + 0.1 * (i as f64 * 0.5).sin()
} else {
base + 2.0 * (if i == 18 { 1.0 } else { -1.0 })
};
}
}
FdMatrix::from_column_major(vals, n, m).unwrap()
}
#[test]
fn outliergram_runs() {
let data = outliergram_test_data();
let result = outliergram(&data, 1.5).unwrap();
assert_eq!(result.mei.len(), 20);
assert_eq!(result.mbd.len(), 20);
assert_eq!(result.outlier_flags.len(), 20);
let central_mbd: f64 = result.mbd[..18].iter().sum::<f64>() / 18.0;
assert!(result.mbd[18] < central_mbd || result.mbd[19] < central_mbd);
}
#[test]
fn outliergram_parabola_coefficients() {
let data = outliergram_test_data();
let result = outliergram(&data, 1.5).unwrap();
assert!(result.a0.is_finite());
assert!(result.a1.is_finite());
assert!(result.a2.is_finite());
}
#[test]
fn magnitude_shape_dimensions() {
let data = outliergram_test_data();
let result = magnitude_shape_outlyingness(&data).unwrap();
assert_eq!(result.magnitude.len(), 20);
assert_eq!(result.shape.len(), 20);
assert!(result.magnitude.iter().all(|&v| v >= 0.0));
assert!(result.shape.iter().all(|&v| v >= 0.0));
}
#[test]
fn magnitude_outliers_have_high_magnitude() {
let data = outliergram_test_data();
let result = magnitude_shape_outlyingness(&data).unwrap();
let central_mag: f64 = result.magnitude[..18].iter().sum::<f64>() / 18.0;
assert!(result.magnitude[18] > central_mag || result.magnitude[19] > central_mag);
}
#[test]
fn outliergram_too_few_curves() {
let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0], 2, 2).unwrap();
assert!(outliergram(&data, 1.5).is_err());
}
fn outlier_sample(n: usize, m: usize, outlier_idx: usize, kind: &str) -> FdMatrix {
let inlier_shape = |i: usize, x: f64| -> f64 {
(x * PI).sin() + 0.1 * (x * 4.0 * PI + 0.3 * i as f64).sin()
};
let mut cm = vec![0.0; n * m];
for i in 0..n {
for t in 0..m {
let x = t as f64 / (m as f64 - 1.0);
let val = if i == outlier_idx {
match kind {
"magnitude" => inlier_shape(i, x) + 10.0,
"amplitude" => 5.0 * inlier_shape(i, x),
"shape" => (x * PI).sin() + 0.4 * (x * 12.0 * PI).sin(),
"constant" => 0.5,
_ => inlier_shape(i, x),
}
} else {
inlier_shape(i, x)
};
cm[i + t * n] = val;
}
}
FdMatrix::from_column_major(cm, n, m).unwrap()
}
#[test]
fn tvdmss_flags_magnitude_outlier() {
let idx = 4usize;
let data = outlier_sample(12, 40, idx, "magnitude");
let res = tvdmss(&data, TvdMssConfig::default()).unwrap();
assert_eq!(res.tvd.len(), 12);
assert_eq!(res.mss.len(), 12);
assert!(
res.magnitude_outliers.contains(&idx),
"magnitude outlier {idx} not flagged: {:?}",
res.magnitude_outliers
);
}
#[test]
fn tvdmss_flags_shape_outlier() {
let idx = 7usize;
let data = outlier_sample(12, 60, idx, "shape");
let res = tvdmss(&data, TvdMssConfig::default()).unwrap();
assert!(
res.shape_outliers.contains(&idx),
"shape outlier {idx} not flagged: {:?}",
res.shape_outliers
);
}
#[test]
fn tvdmss_rejects_empty_and_too_few() {
let empty = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
assert!(matches!(
tvdmss(&empty, TvdMssConfig::default()),
Err(FdarError::InvalidDimension { .. })
));
let two = outlier_sample(2, 8, 0, "none");
assert!(matches!(
tvdmss(&two, TvdMssConfig::default()),
Err(FdarError::InvalidDimension { .. })
));
}
#[test]
fn muod_flags_magnitude_amplitude_shape() {
let mag = outlier_sample(12, 40, 3, "magnitude");
let r = muod(&mag, MuodConfig::default()).unwrap();
assert_eq!(r.shape_index.len(), 12);
assert!(
r.magnitude_outliers.contains(&3),
"magnitude: {:?}",
r.magnitude_outliers
);
let amp = outlier_sample(12, 40, 5, "amplitude");
let r = muod(&, MuodConfig::default()).unwrap();
assert!(
r.amplitude_outliers.contains(&5),
"amplitude: {:?}",
r.amplitude_outliers
);
let shp = outlier_sample(12, 60, 8, "shape");
let r = muod(&shp, MuodConfig::default()).unwrap();
assert!(
r.shape_outliers.contains(&8),
"shape: {:?}",
r.shape_outliers
);
}
#[test]
fn muod_constant_curve_no_nan() {
let data = outlier_sample(12, 40, 6, "constant");
let r = muod(&data, MuodConfig::default()).unwrap();
for v in r
.shape_index
.iter()
.chain(&r.magnitude_index)
.chain(&r.amplitude_index)
{
assert!(!v.is_nan(), "index produced NaN");
}
}
#[test]
fn muod_rejects_bad_dims() {
let two = outlier_sample(2, 8, 0, "none");
assert!(matches!(
muod(&two, MuodConfig::default()),
Err(FdarError::InvalidDimension { .. })
));
let one_col = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 3, 1).unwrap();
assert!(matches!(
muod(&one_col, MuodConfig::default()),
Err(FdarError::InvalidDimension { .. })
));
}
#[test]
fn seq_transform_default_sequence_flags_outlier_and_union_is_flatten() {
let idx = 4usize;
let data = outlier_sample(12, 40, idx, "magnitude");
let seq = [SeqTransform::T0, SeqTransform::T1, SeqTransform::D1];
let res =
sequential_transform_outliers(&data, &seq, SeqTransformConfig::default()).unwrap();
assert_eq!(res.per_transform_outliers.len(), 3);
assert!(
res.per_transform_outliers
.iter()
.any(|(_, v)| !v.is_empty()),
"no transform flagged anything"
);
assert!(
res.union_outliers.contains(&idx),
"union {:?} missing outlier {idx}",
res.union_outliers
);
let mut expected: Vec<usize> = res
.per_transform_outliers
.iter()
.flat_map(|(_, v)| v.iter().copied())
.collect();
expected.sort_unstable();
expected.dedup();
assert_eq!(res.union_outliers, expected);
}
#[test]
fn seq_transform_error_paths() {
let one_col = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 3, 1).unwrap();
assert!(matches!(
sequential_transform_outliers(
&one_col,
&[SeqTransform::D1],
SeqTransformConfig::default()
),
Err(FdarError::InvalidDimension { .. })
));
let mut cm = vec![0.0; 3 * 4];
for i in [0usize, 2] {
for t in 0..4 {
cm[i + t * 3] = 1.0 + i as f64 + t as f64;
}
}
let zero_row = FdMatrix::from_column_major(cm, 3, 4).unwrap();
assert!(matches!(
sequential_transform_outliers(
&zero_row,
&[SeqTransform::T2],
SeqTransformConfig::default()
),
Err(FdarError::ComputationFailed { .. })
));
let one_curve = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
assert!(matches!(
sequential_transform_outliers(
&one_curve,
&[SeqTransform::T0],
SeqTransformConfig::default()
),
Err(FdarError::InvalidDimension { .. })
));
}
#[test]
fn depthgram_flags_magnitude_and_shape() {
let mag = outlier_sample(12, 40, 3, "magnitude");
let r = depthgram(&mag, DepthgramConfig::default()).unwrap();
assert_eq!(r.mbd.len(), 12);
assert_eq!(r.mei.len(), 12);
assert_eq!(r.mbd_mei_d.len(), 12);
assert!(
r.magnitude_outliers.contains(&3),
"magnitude: {:?}",
r.magnitude_outliers
);
let shp = outlier_sample(14, 60, 9, "shape");
let r = depthgram(&shp, DepthgramConfig::default()).unwrap();
assert!(
r.shape_outliers.contains(&9),
"shape: {:?}",
r.shape_outliers
);
}
#[test]
fn depthgram_p1_representations_equivalent() {
let data = outlier_sample(10, 30, 2, "magnitude");
let r = depthgram(&data, DepthgramConfig::default()).unwrap();
assert_eq!(r.mbd_mei_d, r.mbd_mei_t);
assert_eq!(r.mbd_mei_d, r.mbd_mei_t2);
assert_eq!(r.mei_mbd_d, r.mei_mbd_t);
assert_eq!(r.mei_mbd_d, r.mei_mbd_t2);
}
#[test]
fn depthgram_rejects_bad_dims() {
let one = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
assert!(matches!(
depthgram(&one, DepthgramConfig::default()),
Err(FdarError::InvalidDimension { .. })
));
let empty = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
assert!(matches!(
depthgram(&empty, DepthgramConfig::default()),
Err(FdarError::InvalidDimension { .. })
));
}
}