use crate::distance::l2_distance_matrix;
use crate::error::FdarError;
use crate::helpers::simpsons_weights;
use crate::matrix::FdMatrix;
use crate::regression::{fdata_to_pc_1d, FpcaResult};
use rand::prelude::*;
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct DbscanConfig {
pub eps: f64,
pub min_points: usize,
}
impl Default for DbscanConfig {
fn default() -> Self {
Self {
eps: 0.5,
min_points: 3,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct DbscanResult {
pub cluster: Vec<Option<usize>>,
pub n_clusters: usize,
pub n_noise: usize,
pub distances: FdMatrix,
}
#[must_use = "expensive computation whose result should not be discarded"]
pub fn dbscan_fd(
data: &FdMatrix,
argvals: &[f64],
config: &DbscanConfig,
) -> Result<DbscanResult, FdarError> {
let (n, m) = data.shape();
if n == 0 || m == 0 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "at least 1 row and 1 column".to_string(),
actual: format!("{n} rows, {m} columns"),
});
}
if argvals.len() != m {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: format!("{m}"),
actual: format!("{}", argvals.len()),
});
}
if config.eps <= 0.0 {
return Err(FdarError::InvalidParameter {
parameter: "eps",
message: format!("eps must be > 0, got {}", config.eps),
});
}
if config.min_points == 0 {
return Err(FdarError::InvalidParameter {
parameter: "min_points",
message: "min_points must be >= 1".to_string(),
});
}
let dist = l2_distance_matrix(data, argvals);
let mut labels: Vec<Option<usize>> = vec![None; n];
let mut visited: Vec<bool> = vec![false; n];
let mut cluster_id: usize = 0;
for i in 0..n {
if visited[i] {
continue;
}
visited[i] = true;
let neighbors: Vec<usize> = (0..n)
.filter(|&j| j != i && dist[(i, j)] <= config.eps)
.collect();
if neighbors.len() + 1 < config.min_points {
continue;
}
labels[i] = Some(cluster_id);
let mut queue = neighbors.clone();
let mut qi = 0;
while qi < queue.len() {
let j = queue[qi];
qi += 1;
if !visited[j] {
visited[j] = true;
let j_neighbors: Vec<usize> = (0..n)
.filter(|&k| k != j && dist[(j, k)] <= config.eps)
.collect();
if j_neighbors.len() + 1 >= config.min_points {
for nb in j_neighbors {
if !queue.contains(&nb) {
queue.push(nb);
}
}
}
}
if labels[j].is_none() {
labels[j] = Some(cluster_id);
}
}
cluster_id += 1;
}
let n_clusters = cluster_id;
let n_noise = labels.iter().filter(|l| l.is_none()).count();
Ok(DbscanResult {
cluster: labels,
n_clusters,
n_noise,
distances: dist,
})
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct KcfcConfig {
pub k: usize,
pub ncomp: usize,
pub max_iter: usize,
pub seed: u64,
}
impl Default for KcfcConfig {
fn default() -> Self {
Self {
k: 2,
ncomp: 3,
max_iter: 50,
seed: 42,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct KcfcResult {
pub cluster: Vec<usize>,
pub fpca_models: Vec<Option<FpcaResult>>,
pub reconstruction_errors: FdMatrix,
pub iterations: usize,
pub converged: bool,
}
#[must_use = "expensive computation whose result should not be discarded"]
pub fn kcfc_cluster(
data: &FdMatrix,
argvals: &[f64],
config: &KcfcConfig,
) -> Result<KcfcResult, FdarError> {
let (n, m) = data.shape();
if n == 0 || m == 0 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "at least 1 row and 1 column".to_string(),
actual: format!("{n} rows, {m} columns"),
});
}
if argvals.len() != m {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: format!("{m}"),
actual: format!("{}", argvals.len()),
});
}
if config.k == 0 {
return Err(FdarError::InvalidParameter {
parameter: "k",
message: "k must be >= 1".to_string(),
});
}
if config.k > n {
return Err(FdarError::InvalidParameter {
parameter: "k",
message: format!("k={} exceeds number of curves n={}", config.k, n),
});
}
if config.ncomp == 0 {
return Err(FdarError::InvalidParameter {
parameter: "ncomp",
message: "ncomp must be >= 1".to_string(),
});
}
let k = config.k;
let weights = simpsons_weights(argvals);
let row_major = data.to_row_major(); let mut rng = StdRng::seed_from_u64(config.seed);
let mut center_indices: Vec<usize> = Vec::with_capacity(k);
center_indices.push(rng.gen_range(0..n));
let mut min_dist_sq: Vec<f64> = (0..n)
.map(|i| {
let c0 = center_indices[0];
let d = l2_dist_rowmajor(&row_major, i, c0, m, &weights);
d * d
})
.collect();
while center_indices.len() < k {
let total: f64 = min_dist_sq.iter().sum();
let chosen = if total < 1e-15 {
rng.gen_range(0..n)
} else {
let r = rng.gen::<f64>() * total;
let mut cumsum = 0.0;
let mut sel = n - 1;
for (i, &d) in min_dist_sq.iter().enumerate() {
cumsum += d;
if cumsum >= r {
sel = i;
break;
}
}
sel
};
center_indices.push(chosen);
for i in 0..n {
let d = l2_dist_rowmajor(&row_major, i, chosen, m, &weights);
let d2 = d * d;
if d2 < min_dist_sq[i] {
min_dist_sq[i] = d2;
}
}
}
let mut cluster: Vec<usize> = (0..n)
.map(|i| {
center_indices
.iter()
.enumerate()
.min_by(|(_, &c1), (_, &c2)| {
let d1 = l2_dist_rowmajor(&row_major, i, c1, m, &weights);
let d2 = l2_dist_rowmajor(&row_major, i, c2, m, &weights);
d1.partial_cmp(&d2).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(ki, _)| ki)
.unwrap_or(0)
})
.collect();
let mut fpca_models: Vec<Option<FpcaResult>> = vec![None; k];
let mut reconstruction_errors = FdMatrix::zeros(n, k);
let mut converged = false;
let mut iterations = 0;
for _iter in 0..config.max_iter {
iterations += 1;
for ki in 0..k {
let member_indices: Vec<usize> = (0..n).filter(|&i| cluster[i] == ki).collect();
if member_indices.is_empty() {
continue;
}
let n_k = member_indices.len();
let mut col_major_k = vec![0.0_f64; n_k * m];
for (row_in_k, &orig_i) in member_indices.iter().enumerate() {
for j in 0..m {
col_major_k[row_in_k + j * n_k] = data[(orig_i, j)];
}
}
let data_k = FdMatrix::from_column_major(col_major_k, n_k, m)?;
match fdata_to_pc_1d(&data_k, config.ncomp, argvals) {
Ok(fpca) => {
fpca_models[ki] = Some(fpca);
}
Err(_) => {
}
}
}
for i in 0..n {
let curve_row = data.row(i);
let curve_mat = FdMatrix::from_slice(&curve_row, 1, m)?;
for ki in 0..k {
let err = match &fpca_models[ki] {
None => f64::INFINITY,
Some(fpca) => {
let ncomp_eff = fpca.rotation.ncols();
match fpca.project(&curve_mat) {
Ok(scores) => {
match fpca.reconstruct(&scores, ncomp_eff) {
Ok(recon) => {
let mut err_sq = 0.0;
for j in 0..m {
let diff = curve_row[j] - recon[(0, j)];
err_sq += diff * diff * weights[j];
}
err_sq
}
Err(_) => f64::INFINITY,
}
}
Err(_) => f64::INFINITY,
}
}
};
reconstruction_errors[(i, ki)] = err;
}
}
let mut changed = false;
for i in 0..n {
let best_k = (0..k)
.min_by(|&a, &b| {
reconstruction_errors[(i, a)]
.partial_cmp(&reconstruction_errors[(i, b)])
.unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or(0);
if cluster[i] != best_k {
cluster[i] = best_k;
changed = true;
}
}
if !changed {
converged = true;
break;
}
}
Ok(KcfcResult {
cluster,
fpca_models,
reconstruction_errors,
iterations,
converged,
})
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct FunFemConfig {
pub k: usize,
pub ncomp: usize,
pub p_disc: usize,
pub max_iter: usize,
pub tol: f64,
pub seed: u64,
}
impl Default for FunFemConfig {
fn default() -> Self {
Self {
k: 2,
ncomp: 10,
p_disc: 0,
max_iter: 50,
tol: 1e-6,
seed: 42,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct FunFemResult {
pub cluster: Vec<usize>,
pub membership: FdMatrix,
pub disc_subspace: FdMatrix,
pub log_likelihood: f64,
pub iterations: usize,
pub converged: bool,
}
#[must_use = "expensive computation whose result should not be discarded"]
pub fn funfem_cluster(
data: &FdMatrix,
argvals: &[f64],
config: &FunFemConfig,
) -> Result<FunFemResult, FdarError> {
let (n, m) = data.shape();
if n == 0 || m == 0 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "at least 1 row and 1 column".to_string(),
actual: format!("{n} rows, {m} columns"),
});
}
if argvals.len() != m {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: format!("{m}"),
actual: format!("{}", argvals.len()),
});
}
if config.k == 0 {
return Err(FdarError::InvalidParameter {
parameter: "k",
message: "k must be >= 1".to_string(),
});
}
if config.k > n {
return Err(FdarError::InvalidParameter {
parameter: "k",
message: format!("k={} exceeds number of curves n={}", config.k, n),
});
}
if config.ncomp == 0 {
return Err(FdarError::InvalidParameter {
parameter: "ncomp",
message: "ncomp must be >= 1".to_string(),
});
}
let k = config.k;
let fpca = fdata_to_pc_1d(data, config.ncomp, argvals)?;
let scores = &fpca.scores; let ncomp_eff = scores.ncols();
let p_disc_eff = if config.p_disc == 0 {
(k - 1).max(1).min(ncomp_eff)
} else {
config.p_disc.min(ncomp_eff)
};
let weights_uniform = vec![1.0; ncomp_eff]; let row_major_scores = scores.to_row_major(); let mut rng = StdRng::seed_from_u64(config.seed);
let mut center_indices: Vec<usize> = Vec::with_capacity(k);
center_indices.push(rng.gen_range(0..n));
let mut min_dist_sq: Vec<f64> = (0..n)
.map(|i| {
let c0 = center_indices[0];
let d = l2_dist_rowmajor(&row_major_scores, i, c0, ncomp_eff, &weights_uniform);
d * d
})
.collect();
while center_indices.len() < k {
let total: f64 = min_dist_sq.iter().sum();
let chosen = if total < 1e-15 {
rng.gen_range(0..n)
} else {
let r = rng.gen::<f64>() * total;
let mut cumsum = 0.0;
let mut sel = n - 1;
for (i, &d) in min_dist_sq.iter().enumerate() {
cumsum += d;
if cumsum >= r {
sel = i;
break;
}
}
sel
};
center_indices.push(chosen);
for i in 0..n {
let d = l2_dist_rowmajor(&row_major_scores, i, chosen, ncomp_eff, &weights_uniform);
let d2 = d * d;
if d2 < min_dist_sq[i] {
min_dist_sq[i] = d2;
}
}
}
let mut cluster: Vec<usize> = (0..n)
.map(|i| {
center_indices
.iter()
.enumerate()
.min_by(|(_, &c1), (_, &c2)| {
let d1 =
l2_dist_rowmajor(&row_major_scores, i, c1, ncomp_eff, &weights_uniform);
let d2 =
l2_dist_rowmajor(&row_major_scores, i, c2, ncomp_eff, &weights_uniform);
d1.partial_cmp(&d2).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(ki, _)| ki)
.unwrap_or(0)
})
.collect();
let mut pi: Vec<f64> = vec![1.0 / k as f64; k];
let mut mu_k: Vec<Vec<f64>> = vec![vec![0.0; ncomp_eff]; k];
let mut sigma_k: Vec<Vec<f64>> = vec![vec![1.0; ncomp_eff]; k];
update_gmm_params_from_hard(
&row_major_scores,
&cluster,
k,
ncomp_eff,
&mut pi,
&mut mu_k,
&mut sigma_k,
);
let mut disc_dirs: Vec<f64> = {
let mut v = vec![0.0_f64; ncomp_eff * p_disc_eff];
for d in 0..p_disc_eff {
if d < ncomp_eff {
v[d + d * ncomp_eff] = 1.0; }
}
v
};
let mut prev_ll = f64::NEG_INFINITY;
let mut resp = vec![0.0_f64; n * k];
for i in 0..n {
let ki = cluster[i].min(k - 1);
resp[i * k + ki] = 1.0;
}
let mut converged = false;
let mut iterations = 0;
for _iter in 0..config.max_iter {
iterations += 1;
let mut proj_scores = vec![0.0_f64; n * p_disc_eff]; for i in 0..n {
for d in 0..p_disc_eff {
let mut val = 0.0;
for j in 0..ncomp_eff {
val += scores[(i, j)] * disc_dirs[j + d * ncomp_eff];
}
proj_scores[i * p_disc_eff + d] = val;
}
}
let mut mu_disc: Vec<Vec<f64>> = vec![vec![0.0; p_disc_eff]; k];
let mut n_k_soft: Vec<f64> = vec![0.0; k];
for i in 0..n {
for ki in 0..k {
let r = resp[i * k + ki];
n_k_soft[ki] += r;
for d in 0..p_disc_eff {
mu_disc[ki][d] += r * proj_scores[i * p_disc_eff + d];
}
}
}
for ki in 0..k {
if n_k_soft[ki] > 1e-10 {
for d in 0..p_disc_eff {
mu_disc[ki][d] /= n_k_soft[ki];
}
}
}
let mut var_disc: Vec<Vec<f64>> = vec![vec![1.0; p_disc_eff]; k];
for ki in 0..k {
if n_k_soft[ki] > 1e-10 {
for d in 0..p_disc_eff {
let mut v = 0.0;
for i in 0..n {
let diff = proj_scores[i * p_disc_eff + d] - mu_disc[ki][d];
v += resp[i * k + ki] * diff * diff;
}
var_disc[ki][d] = (v / n_k_soft[ki]).max(1e-8);
}
}
}
let mut log_resp = vec![0.0_f64; n * k];
let mut ll = 0.0;
for i in 0..n {
let mut log_components = vec![0.0_f64; k];
for ki in 0..k {
let log_pi = if pi[ki] > 1e-300 { pi[ki].ln() } else { -700.0 };
let mut log_lik = log_pi;
for d in 0..p_disc_eff {
let diff = proj_scores[i * p_disc_eff + d] - mu_disc[ki][d];
let var = var_disc[ki][d];
log_lik -= 0.5 * (var.ln() + diff * diff / var);
}
log_lik -= 0.5 * (p_disc_eff as f64) * std::f64::consts::TAU.ln();
log_components[ki] = log_lik;
}
let log_sum = log_sum_exp(&log_components);
ll += log_sum;
for ki in 0..k {
log_resp[i * k + ki] = log_components[ki] - log_sum;
}
}
resp.fill(0.0);
for i in 0..n {
for ki in 0..k {
resp[i * k + ki] = log_resp[i * k + ki].exp().max(1e-300);
}
}
let mut n_k_new: Vec<f64> = vec![0.0; k];
for i in 0..n {
for ki in 0..k {
n_k_new[ki] += resp[i * k + ki];
}
}
let n_total: f64 = n_k_new.iter().sum();
for ki in 0..k {
pi[ki] = (n_k_new[ki] / n_total).max(1e-300);
}
cluster = (0..n)
.map(|i| {
(0..k)
.max_by(|&a, &b| {
resp[i * k + a]
.partial_cmp(&resp[i * k + b])
.unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or(0)
})
.collect();
update_gmm_params_from_soft(
&row_major_scores,
&resp,
k,
ncomp_eff,
n,
&mut pi,
&mut mu_k,
&mut sigma_k,
);
let global_mean: Vec<f64> = (0..ncomp_eff)
.map(|j| {
(0..n)
.map(|i| row_major_scores[i * ncomp_eff + j])
.sum::<f64>()
/ n as f64
})
.collect();
let mut b_soft = vec![0.0_f64; ncomp_eff * ncomp_eff]; let mut w_soft = vec![0.0_f64; ncomp_eff * ncomp_eff];
for ki in 0..k {
let nk = n_k_new[ki].max(1.0);
for j in 0..ncomp_eff {
for l in 0..ncomp_eff {
b_soft[j * ncomp_eff + l] +=
nk * (mu_k[ki][j] - global_mean[j]) * (mu_k[ki][l] - global_mean[l]);
}
}
}
for i in 0..n {
for ki in 0..k {
let r = resp[i * k + ki];
for j in 0..ncomp_eff {
let dj = row_major_scores[i * ncomp_eff + j] - mu_k[ki][j];
for l in 0..ncomp_eff {
let dl = row_major_scores[i * ncomp_eff + l] - mu_k[ki][l];
w_soft[j * ncomp_eff + l] += r * dj * dl;
}
}
}
}
let trace_w: f64 = (0..ncomp_eff)
.map(|j| w_soft[j * ncomp_eff + j])
.sum::<f64>();
let reg_floor = (trace_w / ncomp_eff as f64 * 1e-4).max(1e-8);
for j in 0..ncomp_eff {
w_soft[j * ncomp_eff + j] += reg_floor;
}
match crate::linalg::cholesky_factor(&w_soft, ncomp_eff) {
Ok(l_w) => {
let mut winv_b = vec![0.0_f64; ncomp_eff * ncomp_eff];
for col in 0..ncomp_eff {
let b_col: Vec<f64> = (0..ncomp_eff)
.map(|r| b_soft[r * ncomp_eff + col])
.collect();
let x = crate::linalg::cholesky_forward_back(&l_w, &b_col, ncomp_eff);
for r in 0..ncomp_eff {
winv_b[r * ncomp_eff + col] = x[r];
}
}
use nalgebra::{DMatrix, SVD};
let mat = DMatrix::from_row_slice(ncomp_eff, ncomp_eff, &winv_b);
let svd = SVD::new(mat, true, false);
if let Some(u) = svd.u {
let mut new_dirs = vec![0.0_f64; ncomp_eff * p_disc_eff];
for d in 0..p_disc_eff {
for r in 0..ncomp_eff {
new_dirs[r + d * ncomp_eff] = u[(r, d)];
}
}
disc_dirs = new_dirs;
}
}
Err(_) => {
}
}
let delta = (ll - prev_ll).abs();
prev_ll = ll;
if _iter > 0 && delta < config.tol {
converged = true;
break;
}
}
let mut membership_data = vec![0.0_f64; n * k];
for i in 0..n {
for ki in 0..k {
membership_data[i + ki * n] = resp[i * k + ki];
}
}
let membership = FdMatrix::from_column_major(membership_data, n, k)?;
let disc_subspace = FdMatrix::from_column_major(disc_dirs, ncomp_eff, p_disc_eff)?;
Ok(FunFemResult {
cluster,
membership,
disc_subspace,
log_likelihood: prev_ll,
iterations,
converged,
})
}
fn update_gmm_params_from_hard(
scores_rm: &[f64],
cluster: &[usize],
k: usize,
d: usize,
pi: &mut [f64],
mu_k: &mut [Vec<f64>],
sigma_k: &mut [Vec<f64>],
) {
let n = cluster.len();
let mut counts = vec![0usize; k];
for &c in cluster {
if c < k {
counts[c] += 1;
}
}
for ki in 0..k {
pi[ki] = (counts[ki] as f64 / n as f64).max(1e-300);
mu_k[ki] = vec![0.0; d];
sigma_k[ki] = vec![0.0; d];
for i in 0..n {
if cluster[i] == ki {
for j in 0..d {
mu_k[ki][j] += scores_rm[i * d + j];
}
}
}
if counts[ki] > 0 {
for j in 0..d {
mu_k[ki][j] /= counts[ki] as f64;
}
}
for i in 0..n {
if cluster[i] == ki {
for j in 0..d {
let diff = scores_rm[i * d + j] - mu_k[ki][j];
sigma_k[ki][j] += diff * diff;
}
}
}
if counts[ki] > 1 {
for j in 0..d {
sigma_k[ki][j] = (sigma_k[ki][j] / counts[ki] as f64).max(1e-8);
}
} else {
for j in 0..d {
sigma_k[ki][j] = 1.0;
}
}
}
}
fn update_gmm_params_from_soft(
scores_rm: &[f64],
resp: &[f64],
k: usize,
d: usize,
n: usize,
pi: &mut [f64],
mu_k: &mut [Vec<f64>],
sigma_k: &mut [Vec<f64>],
) {
let mut n_k = vec![0.0_f64; k];
for i in 0..n {
for ki in 0..k {
n_k[ki] += resp[i * k + ki];
}
}
let total: f64 = n_k.iter().sum();
for ki in 0..k {
pi[ki] = (n_k[ki] / total).max(1e-300);
mu_k[ki] = vec![0.0; d];
sigma_k[ki] = vec![1.0; d];
if n_k[ki] > 1e-10 {
for i in 0..n {
let r = resp[i * k + ki];
for j in 0..d {
mu_k[ki][j] += r * scores_rm[i * d + j];
}
}
for j in 0..d {
mu_k[ki][j] /= n_k[ki];
}
let mut var_j = vec![0.0_f64; d];
for i in 0..n {
let r = resp[i * k + ki];
for j in 0..d {
let diff = scores_rm[i * d + j] - mu_k[ki][j];
var_j[j] += r * diff * diff;
}
}
for j in 0..d {
sigma_k[ki][j] = (var_j[j] / n_k[ki]).max(1e-8);
}
}
}
}
fn log_sum_exp(v: &[f64]) -> f64 {
let max_v = v.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
if max_v == f64::NEG_INFINITY {
return f64::NEG_INFINITY;
}
max_v + v.iter().map(|&x| (x - max_v).exp()).sum::<f64>().ln()
}
fn l2_dist_rowmajor(buf: &[f64], i: usize, j: usize, m: usize, weights: &[f64]) -> f64 {
let mut sq = 0.0;
for t in 0..m {
let d = buf[i * m + t] - buf[j * m + t];
sq += d * d * weights[t];
}
sq.sqrt()
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct AlignClusterConfig {
pub k: usize,
pub max_iter: usize,
pub seed: u64,
pub use_amplitude_only: bool,
pub elastic_lambda: f64,
pub karcher_max_iter: usize,
pub karcher_tol: f64,
}
impl Default for AlignClusterConfig {
fn default() -> Self {
Self {
k: 2,
max_iter: 20,
seed: 42,
use_amplitude_only: true,
elastic_lambda: 0.0,
karcher_max_iter: 15,
karcher_tol: 1e-4,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct AlignClusterResult {
pub cluster: Vec<usize>,
pub templates: Vec<Vec<f64>>,
pub distances: FdMatrix,
pub iterations: usize,
pub converged: bool,
}
#[must_use = "expensive computation whose result should not be discarded"]
pub fn align_cluster_fd(
data: &FdMatrix,
argvals: &[f64],
config: &AlignClusterConfig,
) -> Result<AlignClusterResult, FdarError> {
use crate::alignment::{amplitude_distance, elastic_distance, karcher_mean};
let (n, m) = data.shape();
if n == 0 || m == 0 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "at least 1 row and 1 column".to_string(),
actual: format!("{n} rows, {m} columns"),
});
}
if argvals.len() != m {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: format!("{m}"),
actual: format!("{}", argvals.len()),
});
}
if config.k == 0 {
return Err(FdarError::InvalidParameter {
parameter: "k",
message: "k must be >= 1".to_string(),
});
}
if config.k > n {
return Err(FdarError::InvalidParameter {
parameter: "k",
message: format!("k={} exceeds number of curves n={}", config.k, n),
});
}
let k = config.k;
let mut rng = StdRng::seed_from_u64(config.seed);
let mut shuffled: Vec<usize> = (0..n).collect();
for i in (1..n).rev() {
let j = rng.gen_range(0..=i);
shuffled.swap(i, j);
}
let step = n / k;
let template_indices: Vec<usize> = (0..k).map(|ki| shuffled[(ki * step).min(n - 1)]).collect();
let mut templates: Vec<Vec<f64>> = template_indices.iter().map(|&ci| data.row(ci)).collect();
let mut cluster: Vec<usize> = vec![0; n];
let mut distances = FdMatrix::zeros(n, k);
let mut converged = false;
let mut iterations = 0;
for _iter in 0..config.max_iter {
iterations += 1;
for i in 0..n {
let curve_i = data.row(i);
for ki in 0..k {
let dist = if config.use_amplitude_only {
amplitude_distance(&curve_i, &templates[ki], argvals, config.elastic_lambda)
} else {
elastic_distance(&curve_i, &templates[ki], argvals, config.elastic_lambda)
};
distances[(i, ki)] = dist;
}
}
let mut changed = false;
for i in 0..n {
let best_k = (0..k)
.min_by(|&a, &b| {
distances[(i, a)]
.partial_cmp(&distances[(i, b)])
.unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or(0);
if cluster[i] != best_k {
cluster[i] = best_k;
changed = true;
}
}
let mut template_changed = false;
for ki in 0..k {
let member_indices: Vec<usize> = (0..n).filter(|&i| cluster[i] == ki).collect();
if member_indices.is_empty() {
let non_members: Vec<usize> = (0..n).filter(|&i| cluster[i] != ki).collect();
if !non_members.is_empty() {
let rand_idx = rng.gen_range(0..non_members.len());
templates[ki] = data.row(non_members[rand_idx]);
template_changed = true;
}
continue;
}
let n_k = member_indices.len();
let mut col_major_k = vec![0.0_f64; n_k * m];
for (row_in_k, &orig_i) in member_indices.iter().enumerate() {
for j in 0..m {
col_major_k[row_in_k + j * n_k] = data[(orig_i, j)];
}
}
let data_k = FdMatrix::from_column_major(col_major_k, n_k, m)?;
let km = karcher_mean(
&data_k,
argvals,
config.karcher_max_iter,
config.karcher_tol,
config.elastic_lambda,
);
templates[ki] = km.mean;
}
if !changed && !template_changed {
converged = true;
break;
}
}
Ok(AlignClusterResult {
cluster,
templates,
distances,
iterations,
converged,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{adjusted_rand_index, uniform_grid};
use std::f64::consts::PI;
fn two_tight_clusters(n_per: usize, m: usize) -> (FdMatrix, Vec<f64>, Vec<usize>) {
let t = uniform_grid(m);
let n = 2 * n_per;
let mut col_major = vec![0.0_f64; n * m];
for i in 0..n_per {
for (j, &tj) in t.iter().enumerate() {
col_major[i + j * n] = (2.0 * PI * tj).sin();
}
}
for i in 0..n_per {
for (j, &tj) in t.iter().enumerate() {
col_major[(i + n_per) + j * n] = (2.0 * PI * tj).sin() + 5.0;
}
}
let labels: Vec<usize> = (0..n).map(|i| if i < n_per { 0 } else { 1 }).collect();
(
FdMatrix::from_column_major(col_major, n, m).unwrap(),
t,
labels,
)
}
fn clusters_with_noise(n_per: usize, m: usize) -> (FdMatrix, Vec<f64>) {
let t = uniform_grid(m);
let n = 2 * n_per + 2;
let mut col_major = vec![0.0_f64; n * m];
for i in 0..n_per {
for (j, &tj) in t.iter().enumerate() {
col_major[i + j * n] = (2.0 * PI * tj).sin();
}
}
for i in 0..n_per {
for (j, &tj) in t.iter().enumerate() {
col_major[(i + n_per) + j * n] = (2.0 * PI * tj).sin() + 5.0;
}
}
let o0 = 2 * n_per;
for j in 0..m {
col_major[o0 + j * n] = 100.0;
}
let o1 = 2 * n_per + 1;
for j in 0..m {
col_major[o1 + j * n] = -100.0;
}
(FdMatrix::from_column_major(col_major, n, m).unwrap(), t)
}
fn two_separated_clusters(n_per: usize, m: usize) -> (FdMatrix, Vec<f64>, Vec<usize>) {
let t = uniform_grid(m);
let n = 2 * n_per;
let mut col_major = vec![0.0_f64; n * m];
for i in 0..n_per {
for (j, &tj) in t.iter().enumerate() {
col_major[i + j * n] = (2.0 * PI * tj).sin() + 0.05 * (i as f64 / n_per as f64);
}
}
for i in 0..n_per {
for (j, &tj) in t.iter().enumerate() {
col_major[(i + n_per) + j * n] =
(2.0 * PI * tj).cos() + 8.0 + 0.05 * (i as f64 / n_per as f64);
}
}
let labels: Vec<usize> = (0..n).map(|i| if i < n_per { 0 } else { 1 }).collect();
(
FdMatrix::from_column_major(col_major, n, m).unwrap(),
t,
labels,
)
}
#[test]
fn test_dbscan_core_points() {
let m = 30;
let n_per = 5;
let (data, t, _labels) = two_tight_clusters(n_per, m);
let result = dbscan_fd(
&data,
&t,
&DbscanConfig {
eps: 1.0,
min_points: 2,
..Default::default()
},
)
.unwrap();
assert_eq!(result.n_clusters, 2, "expected 2 clusters");
assert_eq!(result.n_noise, 0, "expected 0 noise points");
assert_eq!(result.cluster.len(), 2 * n_per);
}
#[test]
fn test_dbscan_noise_flagging() {
let m = 30;
let n_per = 5;
let (data, t) = clusters_with_noise(n_per, m);
let result = dbscan_fd(
&data,
&t,
&DbscanConfig {
eps: 1.5,
min_points: 2,
..Default::default()
},
)
.unwrap();
assert_eq!(
result.n_noise, 2,
"expected exactly 2 noise points, got {}",
result.n_noise
);
assert_eq!(result.n_clusters, 2, "expected 2 clusters");
let n = data.nrows();
assert!(result.cluster[n - 2].is_none(), "outlier 0 should be noise");
assert!(result.cluster[n - 1].is_none(), "outlier 1 should be noise");
}
#[test]
fn test_dbscan_zero_eps_returns_err() {
let m = 20;
let (data, t, _) = two_tight_clusters(5, m);
assert!(
dbscan_fd(
&data,
&t,
&DbscanConfig {
eps: 0.0,
..Default::default()
}
)
.is_err(),
"eps=0 should return Err"
);
}
#[test]
fn test_dbscan_negative_eps_returns_err() {
let m = 20;
let (data, t, _) = two_tight_clusters(5, m);
assert!(
dbscan_fd(
&data,
&t,
&DbscanConfig {
eps: -1.0,
..Default::default()
}
)
.is_err(),
"eps=-1 should return Err"
);
}
#[test]
fn test_dbscan_invalid_min_points_zero() {
let m = 20;
let (data, t, _) = two_tight_clusters(5, m);
assert!(
dbscan_fd(
&data,
&t,
&DbscanConfig {
min_points: 0,
..Default::default()
}
)
.is_err(),
"min_points=0 should return Err"
);
}
#[test]
fn test_dbscan_empty_data() {
let data = FdMatrix::zeros(0, 0);
let t: Vec<f64> = vec![];
assert!(
dbscan_fd(&data, &t, &DbscanConfig::default()).is_err(),
"empty data should return Err"
);
}
#[test]
fn test_dbscan_mismatched_argvals() {
let m = 20;
let (data, _t, _) = two_tight_clusters(5, m);
let wrong_t = uniform_grid(m + 1);
assert!(
dbscan_fd(&data, &wrong_t, &DbscanConfig::default()).is_err(),
"mismatched argvals should return Err"
);
}
#[test]
fn test_dbscan_distances_shape() {
let m = 20;
let n_per = 4;
let (data, t, _) = two_tight_clusters(n_per, m);
let result = dbscan_fd(
&data,
&t,
&DbscanConfig {
eps: 1.0,
min_points: 2,
..Default::default()
},
)
.unwrap();
let n = 2 * n_per;
assert_eq!(
result.distances.shape(),
(n, n),
"distance matrix must be n x n"
);
}
#[test]
fn test_kcfc_recovery() {
let m = 40;
let n_per = 10;
let (data, t, ground_truth) = two_separated_clusters(n_per, m);
let result = kcfc_cluster(
&data,
&t,
&KcfcConfig {
k: 2,
ncomp: 3,
max_iter: 50,
seed: 42,
..Default::default()
},
)
.unwrap();
let ari = adjusted_rand_index(&result.cluster, &ground_truth);
assert!(
ari >= 0.90,
"kCFC ARI={ari:.3} should be >= 0.90 on well-separated data"
);
}
#[test]
fn test_kcfc_errors_ordering() {
let m = 40;
let n_per = 10;
let (data, t, ground_truth) = two_separated_clusters(n_per, m);
let result = kcfc_cluster(
&data,
&t,
&KcfcConfig {
k: 2,
ncomp: 3,
max_iter: 50,
seed: 42,
..Default::default()
},
)
.unwrap();
let gt0_cluster = result.cluster[0]; let gt1_cluster = result.cluster[n_per];
let mut correct_ordering = 0;
let mut total = 0;
for i in 0..data.nrows() {
let expected_cluster = if ground_truth[i] == 0 {
gt0_cluster
} else {
gt1_cluster
};
let other_cluster = (0..2).find(|&c| c != expected_cluster).unwrap_or(0);
let err_own = result.reconstruction_errors[(i, expected_cluster)];
let err_other = result.reconstruction_errors[(i, other_cluster)];
if err_own.is_finite() && err_other.is_finite() {
if err_own < err_other {
correct_ordering += 1;
}
total += 1;
}
}
assert!(
correct_ordering * 10 >= total * 8,
"only {correct_ordering}/{total} curves had smaller error for their true cluster"
);
}
#[test]
fn test_kcfc_deterministic() {
let m = 30;
let n_per = 8;
let (data, t, _) = two_separated_clusters(n_per, m);
let cfg = KcfcConfig {
k: 2,
ncomp: 2,
max_iter: 30,
seed: 7,
..Default::default()
};
let r1 = kcfc_cluster(&data, &t, &cfg).unwrap();
let r2 = kcfc_cluster(&data, &t, &cfg).unwrap();
assert_eq!(
r1.cluster, r2.cluster,
"identical seed must produce identical assignments"
);
}
#[test]
fn test_kcfc_invalid_k_zero() {
let m = 20;
let (data, t, _) = two_tight_clusters(5, m);
assert!(
kcfc_cluster(
&data,
&t,
&KcfcConfig {
k: 0,
..Default::default()
}
)
.is_err(),
"k=0 should return Err"
);
}
#[test]
fn test_kcfc_invalid_k_gt_n() {
let m = 20;
let n = 4;
let (data, t, _) = two_tight_clusters(n / 2, m);
assert!(
kcfc_cluster(
&data,
&t,
&KcfcConfig {
k: n + 1,
..Default::default()
}
)
.is_err(),
"k>n should return Err"
);
}
#[test]
fn test_kcfc_empty_data() {
let data = FdMatrix::zeros(0, 0);
let t: Vec<f64> = vec![];
assert!(
kcfc_cluster(&data, &t, &KcfcConfig::default()).is_err(),
"empty data should return Err"
);
}
#[test]
fn test_kcfc_mismatched_argvals() {
let m = 20;
let (data, _t, _) = two_tight_clusters(5, m);
let wrong_t = uniform_grid(m + 3);
assert!(
kcfc_cluster(&data, &wrong_t, &KcfcConfig::default()).is_err(),
"mismatched argvals should return Err"
);
}
#[test]
fn test_kcfc_result_shapes() {
let m = 20;
let n_per = 5;
let (data, t, _) = two_separated_clusters(n_per, m);
let n = 2 * n_per;
let result = kcfc_cluster(
&data,
&t,
&KcfcConfig {
k: 2,
ncomp: 2,
..Default::default()
},
)
.unwrap();
assert_eq!(result.cluster.len(), n);
assert_eq!(result.fpca_models.len(), 2);
assert_eq!(result.reconstruction_errors.shape(), (n, 2));
}
#[test]
fn test_kcfc_ncomp_zero_returns_err() {
let m = 20;
let (data, t, _) = two_tight_clusters(5, m);
let result = kcfc_cluster(
&data,
&t,
&KcfcConfig {
k: 2,
ncomp: 0,
..Default::default()
},
);
assert!(result.is_err(), "ncomp=0 must return Err, got Ok");
if let Err(FdarError::InvalidParameter { parameter, .. }) = result {
assert_eq!(parameter, "ncomp");
} else {
panic!("expected InvalidParameter {{ parameter: \"ncomp\" }}");
}
}
#[test]
fn test_funfem_recovery() {
let m = 40;
let n_per = 10;
let (data, t, ground_truth) = two_separated_clusters(n_per, m);
let result = funfem_cluster(
&data,
&t,
&FunFemConfig {
k: 2,
ncomp: 5,
p_disc: 1,
max_iter: 30,
tol: 1e-5,
seed: 42,
},
)
.unwrap();
let ari = adjusted_rand_index(&result.cluster, &ground_truth);
assert!(
ari >= 0.90,
"funFEM ARI={ari:.3} should be >= 0.90 on well-separated data"
);
}
#[test]
fn test_funfem_deterministic() {
let m = 30;
let n_per = 8;
let (data, t, _) = two_separated_clusters(n_per, m);
let cfg = FunFemConfig {
k: 2,
ncomp: 4,
p_disc: 1,
max_iter: 20,
tol: 1e-5,
seed: 99,
};
let r1 = funfem_cluster(&data, &t, &cfg).unwrap();
let r2 = funfem_cluster(&data, &t, &cfg).unwrap();
assert_eq!(
r1.cluster, r2.cluster,
"same seed must produce identical assignments"
);
}
#[test]
fn test_funfem_invalid_k_zero() {
let m = 20;
let (data, t, _) = two_tight_clusters(5, m);
assert!(
funfem_cluster(
&data,
&t,
&FunFemConfig {
k: 0,
..FunFemConfig::default()
}
)
.is_err(),
"k=0 must return Err"
);
}
#[test]
fn test_funfem_invalid_k_gt_n() {
let m = 20;
let (data, t, _) = two_tight_clusters(3, m);
assert!(
funfem_cluster(
&data,
&t,
&FunFemConfig {
k: 10,
..FunFemConfig::default()
}
)
.is_err(),
"k>n must return Err"
);
}
#[test]
fn test_funfem_invalid_ncomp_zero() {
let m = 20;
let (data, t, _) = two_tight_clusters(5, m);
assert!(
funfem_cluster(
&data,
&t,
&FunFemConfig {
ncomp: 0,
..FunFemConfig::default()
}
)
.is_err(),
"ncomp=0 must return Err"
);
}
#[test]
fn test_funfem_invalid_empty_data() {
let data = FdMatrix::zeros(0, 0);
let t: Vec<f64> = vec![];
assert!(
funfem_cluster(&data, &t, &FunFemConfig::default()).is_err(),
"empty data must return Err"
);
}
#[test]
fn test_funfem_invalid_argvals_mismatch() {
let m = 20;
let (data, _t, _) = two_tight_clusters(5, m);
let wrong_t = uniform_grid(m + 2);
assert!(
funfem_cluster(&data, &wrong_t, &FunFemConfig::default()).is_err(),
"argvals mismatch must return Err"
);
}
#[test]
fn test_funfem_output_shapes() {
let m = 30;
let n_per = 6;
let (data, t, _) = two_separated_clusters(n_per, m);
let n = 2 * n_per;
let result = funfem_cluster(
&data,
&t,
&FunFemConfig {
k: 2,
ncomp: 4,
p_disc: 1,
max_iter: 10,
tol: 1e-4,
seed: 1,
},
)
.unwrap();
assert_eq!(result.cluster.len(), n);
assert_eq!(result.membership.shape(), (n, 2));
}
fn time_warped_clusters(n_per: usize, m: usize) -> (FdMatrix, Vec<f64>, Vec<usize>) {
let t = uniform_grid(m);
let n = 2 * n_per;
let mut col_major = vec![0.0_f64; n * m];
for i in 0..n_per {
let alpha = 1.0 + 0.1 * (i as f64 / n_per as f64); for (j, &tj) in t.iter().enumerate() {
let warped = tj.powf(alpha);
col_major[i + j * n] = (2.0 * PI * warped).sin();
}
}
for i in 0..n_per {
for j in 0..m {
col_major[(i + n_per) + j * n] = 8.0 + 0.05 * i as f64;
}
}
let labels: Vec<usize> = (0..n).map(|i| if i < n_per { 0 } else { 1 }).collect();
(
FdMatrix::from_column_major(col_major, n, m).unwrap(),
t,
labels,
)
}
#[test]
fn test_align_cluster_shape_shift() {
let m = 30;
let n_per = 6;
let (data, t, ground_truth) = time_warped_clusters(n_per, m);
let result = align_cluster_fd(
&data,
&t,
&AlignClusterConfig {
k: 2,
max_iter: 15,
seed: 42,
use_amplitude_only: true,
elastic_lambda: 0.0,
karcher_max_iter: 10,
karcher_tol: 1e-3,
},
)
.unwrap();
let ari = adjusted_rand_index(&result.cluster, &ground_truth);
assert!(
ari >= 0.90,
"align_cluster ARI={ari:.3} on shape-distinct data should be >= 0.90"
);
}
#[test]
fn test_align_cluster_recovery() {
let m = 30;
let n_per = 6;
let (data, t, ground_truth) = two_separated_clusters(n_per, m);
let result = align_cluster_fd(
&data,
&t,
&AlignClusterConfig {
k: 2,
max_iter: 15,
seed: 7,
use_amplitude_only: true,
elastic_lambda: 0.0,
karcher_max_iter: 10,
karcher_tol: 1e-3,
},
)
.unwrap();
let ari = adjusted_rand_index(&result.cluster, &ground_truth);
assert!(
ari >= 0.90,
"align_cluster ARI={ari:.3} on amplitude-separated data should be >= 0.90"
);
}
#[test]
fn test_align_cluster_invalid_k_zero() {
let m = 20;
let (data, t, _) = two_tight_clusters(5, m);
assert!(
align_cluster_fd(
&data,
&t,
&AlignClusterConfig {
k: 0,
..AlignClusterConfig::default()
}
)
.is_err(),
"k=0 must return Err"
);
}
#[test]
fn test_align_cluster_invalid_k_gt_n() {
let m = 20;
let (data, t, _) = two_tight_clusters(3, m);
assert!(
align_cluster_fd(
&data,
&t,
&AlignClusterConfig {
k: 20,
..AlignClusterConfig::default()
}
)
.is_err(),
"k>n must return Err"
);
}
#[test]
fn test_align_cluster_invalid_empty_data() {
let data = FdMatrix::zeros(0, 0);
let t: Vec<f64> = vec![];
assert!(
align_cluster_fd(&data, &t, &AlignClusterConfig::default()).is_err(),
"empty data must return Err"
);
}
#[test]
fn test_align_cluster_invalid_argvals_mismatch() {
let m = 20;
let (data, _t, _) = two_tight_clusters(5, m);
let wrong_t = uniform_grid(m + 5);
assert!(
align_cluster_fd(&data, &wrong_t, &AlignClusterConfig::default()).is_err(),
"argvals mismatch must return Err"
);
}
#[test]
fn test_align_cluster_output_shapes() {
let m = 20;
let n_per = 4;
let (data, t, _) = two_separated_clusters(n_per, m);
let n = 2 * n_per;
let result = align_cluster_fd(
&data,
&t,
&AlignClusterConfig {
k: 2,
max_iter: 5,
seed: 1,
karcher_max_iter: 5,
karcher_tol: 1e-2,
..AlignClusterConfig::default()
},
)
.unwrap();
assert_eq!(result.cluster.len(), n);
assert_eq!(result.templates.len(), 2);
assert!(result.templates.iter().all(|t| t.len() == m));
assert_eq!(result.distances.shape(), (n, 2));
}
}