Skip to main content

fdars_core/
kernel_kmeans.rs

1//! Kernel-k-means clustering of curve sets through the Global Alignment Kernel.
2//!
3//! Standard k-means minimizes Euclidean distance to an explicit centroid. Kernel
4//! k-means lifts the data into the reproducing-kernel feature space induced by the
5//! GAK kernel and minimizes the *feature-space* distance to each cluster mean —
6//! **without ever materializing a centroid curve**. Every quantity the algorithm
7//! needs is read from the n×n GAK Gram matrix `K` (built once via
8//! [`gak_gram_train`]).
9//!
10//! ## The kernel trick (no centroid)
11//!
12//! For a cluster `C_k` the squared feature-space distance of point `i` is
13//! ```text
14//! d²(i, k) = K[i,i] − (2/|C_k|)·Σ_{j∈C_k} K[i,j] + (1/|C_k|²)·Σ_{j,l∈C_k} K[j,l]
15//! ```
16//! The last term `within_k = (1/|C_k|²)·Σ_{j,l∈C_k} K[j,l]` depends only on the
17//! cluster, so it is precomputed once per cluster per iteration and reused across
18//! all points — the assignment sweep is then O(n²) once the Gram is in memory.
19//! With the normalized GAK `K[i,i] = 1`, so the diagonal term is a constant that
20//! drops out of the argmin (kept in the returned inertia for interpretability).
21//!
22//! Because there is no centroid, [`KernelKmeansResult`] has **no `centers` field**
23//! — this is a hard correctness property of kernel k-means, not an omission.
24//!
25//! ## Robustness
26//!
27//! - **Init:** `n_init` *random-partition* restarts (k-means++ is wrong here — it
28//!   assumes L2 curve vectors, but we only have similarity-valued Gram entries).
29//!   Each restart is seeded `seed_from_u64(seed + restart_idx)` for reproducibility.
30//!   The lowest-total-inertia restart is returned.
31//! - **Empty clusters:** if a cluster empties mid-iteration (or `k` exceeds the
32//!   number of natural clusters), it is reseeded with the point currently farthest
33//!   (max `d²`) from its assigned cluster — the algorithm never panics.
34//! - **Determinism:** the Gram is built once and reused across all restarts; the
35//!   same `seed` yields identical labels.
36//!
37//! ## Out-of-sample prediction
38//!
39//! [`KernelKmeansResult::predict`] assigns new curves via the cross-Gram from
40//! [`gak_gram_predict`] (n_test × n_train, normalized so `k(test,test)=1`), reusing
41//! the fitted σ, the training within-cluster sums, and the training cluster sizes —
42//! no re-estimation.
43
44use crate::error::FdarError;
45use crate::matrix::FdMatrix;
46use crate::metric::gak::{gak_gram_predict, gak_gram_train, GakConfig, GakGramTrain};
47use rand::prelude::*;
48
49/// Configuration for kernel-k-means clustering ([`kernel_kmeans_fd`]).
50///
51/// Defaults: `n_init = 10` (robustness over tslearn's default of 1 — kernel
52/// k-means routinely lands in poor local minima from a single random partition),
53/// `max_iter = 300`, `tol = 1e-4`.
54#[derive(Debug, Clone, PartialEq)]
55#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
56#[non_exhaustive]
57pub struct KernelKmeansConfig {
58    /// Number of clusters `k` (≥ 1, ≤ number of curves).
59    pub n_clusters: usize,
60    /// Number of random-partition restarts; the lowest-inertia run is returned (≥ 1).
61    pub n_init: usize,
62    /// Maximum Lloyd iterations per restart.
63    pub max_iter: usize,
64    /// Convergence tolerance on the relative inertia decrease.
65    pub tol: f64,
66    /// Base RNG seed; restart `r` is seeded `seed + r`.
67    pub seed: u64,
68    /// GAK kernel configuration (bandwidth σ, or the median heuristic).
69    pub gak: GakConfig,
70}
71
72impl Default for KernelKmeansConfig {
73    fn default() -> Self {
74        Self {
75            n_clusters: 2,
76            n_init: 10,
77            max_iter: 300,
78            tol: 1e-4,
79            seed: 0,
80            gak: GakConfig::default(),
81        }
82    }
83}
84
85impl KernelKmeansConfig {
86    /// Construct a config for `n_clusters` with an explicit GAK bandwidth σ,
87    /// keeping the other defaults.
88    #[must_use]
89    pub fn new(n_clusters: usize, sigma: f64) -> Self {
90        Self {
91            n_clusters,
92            gak: GakConfig::with_sigma(sigma),
93            ..Self::default()
94        }
95    }
96}
97
98/// Result of [`kernel_kmeans_fd`].
99///
100/// Carries the cluster assignments and fit diagnostics plus the internal state
101/// [`KernelKmeansResult::predict`] needs (the fitted [`GakGramTrain`], the per-cluster
102/// within-cluster kernel sums, and the cluster sizes). There is **no centroid /
103/// `centers` field**: kernel k-means has no explicit centroid curve.
104#[derive(Debug, Clone, PartialEq)]
105#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
106#[non_exhaustive]
107pub struct KernelKmeansResult {
108    /// Cluster assignment for each training curve (values in `0..n_clusters`).
109    pub cluster: Vec<usize>,
110    /// Total feature-space inertia `Σ_i d²(i, cluster[i])` of the returned run.
111    pub inertia: f64,
112    /// Number of Lloyd iterations the winning restart ran.
113    pub iter: usize,
114    /// Whether the winning restart converged before hitting `max_iter`.
115    pub converged: bool,
116    /// Index of the restart (0-based) that produced this result.
117    pub n_init_best: usize,
118    /// Fitted GAK training Gram + σ, retained so `predict` reuses the exact kernel.
119    train: GakGramTrain,
120    /// Per-cluster within-cluster kernel sum `within_k = (1/|C_k|²)·ΣΣ K[j,l]`.
121    within: Vec<f64>,
122    /// Per-cluster size `|C_k|` at convergence (used by `predict`).
123    sizes: Vec<usize>,
124}
125
126impl KernelKmeansResult {
127    /// The number of clusters (`k`).
128    #[must_use]
129    pub fn n_clusters(&self) -> usize {
130        self.within.len()
131    }
132
133    /// Assign new (out-of-sample) curves to the fitted clusters.
134    ///
135    /// Builds the normalized cross-Gram `Kcross = gak_gram_predict(train, new_data)`
136    /// (n_test × n_train) and assigns each new curve `t` to the cluster minimizing
137    /// ```text
138    /// d²(t, k) = 1 − (2/|C_k|)·Σ_{j∈C_k} Kcross[t,j] + within_k
139    /// ```
140    /// where `1 = k(test,test)` (normalized GAK), and `within_k`, `|C_k|` are the
141    /// **fitted training** quantities. The same σ and normalization as the fit are
142    /// reused (via [`gak_gram_predict`]) — nothing is re-estimated on the test set.
143    ///
144    /// # Errors
145    /// Propagates [`gak_gram_predict`] errors: [`FdarError::InvalidDimension`] if
146    /// `new_data` is empty or its evaluation-grid width differs from the training
147    /// set's, or [`FdarError::InvalidParameter`] if the stored σ is invalid.
148    pub fn predict(&self, new_data: &FdMatrix) -> Result<Vec<usize>, FdarError> {
149        let kcross = gak_gram_predict(&self.train, new_data)?;
150        let n_test = kcross.nrows();
151        let n_train = kcross.ncols();
152        let k = self.within.len();
153
154        let mut labels = vec![0usize; n_test];
155        for t in 0..n_test {
156            // Per-cluster cross-kernel sums Σ_{j∈C_k} Kcross[t,j].
157            let mut cross_sum = vec![0.0f64; k];
158            for j in 0..n_train {
159                cross_sum[self.cluster[j]] += kcross[(t, j)];
160            }
161            let mut best = 0usize;
162            let mut best_d2 = f64::INFINITY;
163            for (c, &sz) in self.sizes.iter().enumerate() {
164                // k(test,test) = 1 (normalized); constant across clusters but kept
165                // for a faithful d². Empty training clusters are unreachable.
166                let d2 = if sz == 0 {
167                    f64::INFINITY
168                } else {
169                    1.0 - (2.0 / sz as f64) * cross_sum[c] + self.within[c]
170                };
171                if d2 < best_d2 {
172                    best_d2 = d2;
173                    best = c;
174                }
175            }
176            labels[t] = best;
177        }
178        Ok(labels)
179    }
180}
181
182/// Cluster a curve set with kernel-k-means through the GAK kernel.
183///
184/// Builds the GAK Gram **once** ([`gak_gram_train`]), then runs `config.n_init`
185/// random-partition restarts (each seeded `config.seed + restart_idx`), keeping the
186/// lowest-total-inertia run. Assignments are computed purely from Gram-matrix kernel
187/// distances via the kernel trick — the result has **no centroid field**. Empty
188/// clusters are recovered by reseeding the farthest point; the algorithm never panics.
189///
190/// # Errors
191/// - [`FdarError::InvalidDimension`] if `data` is empty (no curves or no points).
192/// - [`FdarError::InvalidParameter`] if `n_clusters < 1`, `n_clusters > n`, or
193///   `n_init < 1`.
194/// - Propagates any error from [`gak_gram_train`] (e.g. an invalid σ).
195///
196/// # Examples
197/// ```
198/// use fdars_core::{kernel_kmeans_fd, KernelKmeansConfig, FdMatrix};
199/// // Two well-separated groups of two curves each (column-major FdMatrix).
200/// let rows = [
201///     [0.0, 0.1, 0.2, 0.3],
202///     [0.0, 0.1, 0.2, 0.25],
203///     [9.0, 9.1, 9.2, 9.3],
204///     [9.0, 9.1, 9.2, 9.25],
205/// ];
206/// let (n, m) = (4, 4);
207/// let mut data = vec![0.0; n * m];
208/// for (i, r) in rows.iter().enumerate() {
209///     for (j, &v) in r.iter().enumerate() {
210///         data[i + j * n] = v; // column-major
211///     }
212/// }
213/// let data = FdMatrix::from_slice(&data, n, m).unwrap();
214///
215/// let cfg = KernelKmeansConfig::new(2, 1.0);
216/// let res = kernel_kmeans_fd(&data, &cfg).unwrap();
217/// assert_eq!(res.cluster.len(), 4);
218/// // The two curves in each group land in the same cluster.
219/// assert_eq!(res.cluster[0], res.cluster[1]);
220/// assert_eq!(res.cluster[2], res.cluster[3]);
221/// assert_ne!(res.cluster[0], res.cluster[2]);
222/// ```
223#[must_use = "expensive computation whose result should not be discarded"]
224pub fn kernel_kmeans_fd(
225    data: &FdMatrix,
226    config: &KernelKmeansConfig,
227) -> Result<KernelKmeansResult, FdarError> {
228    let n = data.nrows();
229    let m = data.ncols();
230    if n == 0 || m == 0 {
231        return Err(FdarError::InvalidDimension {
232            parameter: "data",
233            expected: "non-empty matrix (nrows > 0, ncols > 0)".to_string(),
234            actual: format!("{n}x{m}"),
235        });
236    }
237    let k = config.n_clusters;
238    if k < 1 {
239        return Err(FdarError::InvalidParameter {
240            parameter: "n_clusters",
241            message: "number of clusters must be >= 1".to_string(),
242        });
243    }
244    if k > n {
245        return Err(FdarError::InvalidParameter {
246            parameter: "n_clusters",
247            message: format!("n_clusters={k} exceeds number of curves n={n}"),
248        });
249    }
250    if config.n_init < 1 {
251        return Err(FdarError::InvalidParameter {
252            parameter: "n_init",
253            message: "n_init must be >= 1".to_string(),
254        });
255    }
256
257    // Build the GAK Gram ONCE; reused across every restart.
258    let train = gak_gram_train(data, &config.gak)?;
259    let gram = &train.gram;
260
261    let mut best: Option<RestartOutcome> = None;
262    for restart in 0..config.n_init {
263        let mut rng = StdRng::seed_from_u64(config.seed.wrapping_add(restart as u64));
264        let outcome = run_restart(gram, n, k, config.max_iter, config.tol, &mut rng, restart);
265        let take = match &best {
266            None => true,
267            Some(b) => outcome.inertia < b.inertia,
268        };
269        if take {
270            best = Some(outcome);
271        }
272    }
273
274    // n_init >= 1 guarantees at least one restart ran.
275    let RestartOutcome {
276        cluster,
277        inertia,
278        iter,
279        converged,
280        within,
281        sizes,
282        restart_idx,
283    } = best.expect("n_init >= 1 guarantees a restart outcome");
284
285    Ok(KernelKmeansResult {
286        cluster,
287        inertia,
288        iter,
289        converged,
290        n_init_best: restart_idx,
291        train,
292        within,
293        sizes,
294    })
295}
296
297/// Outcome of a single random-partition restart.
298struct RestartOutcome {
299    cluster: Vec<usize>,
300    inertia: f64,
301    iter: usize,
302    converged: bool,
303    within: Vec<f64>,
304    sizes: Vec<usize>,
305    restart_idx: usize,
306}
307
308/// Run one kernel-k-means restart (Lloyd iterations) on the fixed Gram.
309fn run_restart(
310    gram: &FdMatrix,
311    n: usize,
312    k: usize,
313    max_iter: usize,
314    tol: f64,
315    rng: &mut StdRng,
316    restart_idx: usize,
317) -> RestartOutcome {
318    // Random-partition init: assign each point to one of k clusters, then repair
319    // any empty cluster so every cluster starts non-empty.
320    let mut cluster: Vec<usize> = (0..n).map(|_| rng.gen_range(0..k)).collect();
321    ensure_no_empty_random(&mut cluster, n, k, rng);
322
323    let mut sizes = vec![0usize; k];
324    let mut within = vec![0.0f64; k];
325    let mut d2 = vec![0.0f64; n * k]; // row-major d²(i, c) scratch
326    let mut prev_inertia = f64::INFINITY;
327    let mut iter = 0usize;
328    let mut converged = false;
329
330    while iter < max_iter {
331        iter += 1;
332
333        // Per-cluster sizes and within-cluster sums for the current assignment.
334        compute_cluster_stats(gram, &cluster, n, k, &mut sizes, &mut within);
335
336        // d²(i, c) = K[i,i] − (2/|C_c|)·Σ_{j∈C_c} K[i,j] + within_c.
337        for i in 0..n {
338            // Cross sums Σ_{j∈C_c} K[i,j] per cluster.
339            let mut cross = vec![0.0f64; k];
340            for j in 0..n {
341                cross[cluster[j]] += gram[(i, j)];
342            }
343            let kii = gram[(i, i)];
344            for c in 0..k {
345                d2[i * k + c] = if sizes[c] == 0 {
346                    f64::INFINITY
347                } else {
348                    kii - (2.0 / sizes[c] as f64) * cross[c] + within[c]
349                };
350            }
351        }
352
353        // Reassign each point to its argmin cluster.
354        let mut new_cluster = vec![0usize; n];
355        for i in 0..n {
356            let mut best_c = 0usize;
357            let mut best_d = f64::INFINITY;
358            for c in 0..k {
359                let v = d2[i * k + c];
360                if v < best_d {
361                    best_d = v;
362                    best_c = c;
363                }
364            }
365            new_cluster[i] = best_c;
366        }
367
368        // Empty-cluster recovery: reseed each empty cluster with the point that is
369        // currently farthest (max d²) from its assigned cluster. Never panics.
370        recover_empty_clusters(&mut new_cluster, &d2, n, k);
371
372        // Inertia of the new assignment.
373        let inertia: f64 = (0..n).map(|i| d2[i * k + new_cluster[i]]).sum();
374
375        let changed = new_cluster != cluster;
376        cluster = new_cluster;
377
378        // Convergence: labels stable, or relative inertia change below tol.
379        let rel = if prev_inertia.is_finite() && prev_inertia.abs() > 0.0 {
380            (prev_inertia - inertia).abs() / prev_inertia.abs()
381        } else {
382            f64::INFINITY
383        };
384        if !changed || rel < tol {
385            converged = true;
386            break;
387        }
388        prev_inertia = inertia;
389    }
390
391    // Final stats for the converged assignment (used by predict).
392    compute_cluster_stats(gram, &cluster, n, k, &mut sizes, &mut within);
393    let inertia = final_inertia(gram, &cluster, &sizes, &within, n, k);
394
395    RestartOutcome {
396        cluster,
397        inertia,
398        iter,
399        converged,
400        within,
401        sizes,
402        restart_idx,
403    }
404}
405
406/// Compute per-cluster sizes and `within_c = (1/|C_c|²)·Σ_{j,l∈C_c} K[j,l]`.
407fn compute_cluster_stats(
408    gram: &FdMatrix,
409    cluster: &[usize],
410    n: usize,
411    k: usize,
412    sizes: &mut [usize],
413    within: &mut [f64],
414) {
415    sizes.iter_mut().for_each(|s| *s = 0);
416    within.iter_mut().for_each(|w| *w = 0.0);
417    for &c in cluster.iter() {
418        sizes[c] += 1;
419    }
420    // Σ_{j,l∈C_c} K[j,l] accumulated by scanning all pairs once.
421    let mut sums = vec![0.0f64; k];
422    for j in 0..n {
423        let cj = cluster[j];
424        for l in 0..n {
425            if cluster[l] == cj {
426                sums[cj] += gram[(j, l)];
427            }
428        }
429    }
430    for c in 0..k {
431        if sizes[c] > 0 {
432            let sz = sizes[c] as f64;
433            within[c] = sums[c] / (sz * sz);
434        } else {
435            within[c] = 0.0;
436        }
437    }
438}
439
440/// Total inertia `Σ_i d²(i, cluster[i])` for a settled assignment.
441fn final_inertia(
442    gram: &FdMatrix,
443    cluster: &[usize],
444    sizes: &[usize],
445    within: &[f64],
446    n: usize,
447    k: usize,
448) -> f64 {
449    let mut total = 0.0;
450    for i in 0..n {
451        let mut cross = vec![0.0f64; k];
452        for j in 0..n {
453            cross[cluster[j]] += gram[(i, j)];
454        }
455        let c = cluster[i];
456        if sizes[c] > 0 {
457            let d2 = gram[(i, i)] - (2.0 / sizes[c] as f64) * cross[c] + within[c];
458            total += d2;
459        }
460    }
461    total
462}
463
464/// Ensure a random-partition init leaves no cluster empty by moving distinct
465/// points into empty clusters (deterministic given the RNG state).
466fn ensure_no_empty_random(cluster: &mut [usize], n: usize, k: usize, rng: &mut StdRng) {
467    loop {
468        let mut sizes = vec![0usize; k];
469        for &c in cluster.iter() {
470            sizes[c] += 1;
471        }
472        let empty: Vec<usize> = (0..k).filter(|&c| sizes[c] == 0).collect();
473        if empty.is_empty() {
474            return;
475        }
476        for c in empty {
477            // Steal a random point from a cluster that currently has ≥ 2 members.
478            let donors: Vec<usize> = (0..n).filter(|&i| sizes[cluster[i]] > 1).collect();
479            if donors.is_empty() {
480                // Not enough distinct points to fill every cluster (k == n edge);
481                // give up — the assignment loop tolerates this.
482                return;
483            }
484            let pick = donors[rng.gen_range(0..donors.len())];
485            sizes[cluster[pick]] -= 1;
486            cluster[pick] = c;
487            sizes[c] += 1;
488        }
489    }
490}
491
492/// Recover empty clusters after a reassignment by moving the point currently
493/// farthest from its assigned cluster into each empty cluster. Never panics.
494fn recover_empty_clusters(cluster: &mut [usize], d2: &[f64], n: usize, k: usize) {
495    loop {
496        let mut sizes = vec![0usize; k];
497        for &c in cluster.iter() {
498            sizes[c] += 1;
499        }
500        let Some(empty) = (0..k).find(|&c| sizes[c] == 0) else {
501            return;
502        };
503        // Farthest point (max d² to its own cluster) that can be safely moved
504        // (its current cluster has > 1 member, so moving it never creates a new
505        // empty cluster).
506        let mut best_i = None;
507        let mut best_d = f64::NEG_INFINITY;
508        for i in 0..n {
509            if sizes[cluster[i]] <= 1 {
510                continue;
511            }
512            let d = d2[i * k + cluster[i]];
513            if d > best_d {
514                best_d = d;
515                best_i = Some(i);
516            }
517        }
518        match best_i {
519            Some(i) => {
520                cluster[i] = empty;
521            }
522            None => return, // no movable point (k == n); leave as-is.
523        }
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    /// Build an FdMatrix from row-major curves (each inner Vec is one curve/row).
532    fn matrix_from_rows(rows: &[Vec<f64>]) -> FdMatrix {
533        let n = rows.len();
534        let m = rows[0].len();
535        let mut data = vec![0.0; n * m];
536        for (i, r) in rows.iter().enumerate() {
537            for (j, &v) in r.iter().enumerate() {
538                data[i + j * n] = v; // column-major
539            }
540        }
541        FdMatrix::from_slice(&data, n, m).unwrap()
542    }
543
544    /// Two well-separated groups: a low-flat band and a high-flat band.
545    fn two_groups() -> (FdMatrix, Vec<usize>) {
546        let m = 20;
547        let mut rows = Vec::new();
548        let mut truth = Vec::new();
549        // Group 0: near 0.
550        for i in 0..5 {
551            let off = i as f64 * 0.01;
552            rows.push(
553                (0..m)
554                    .map(|k| (k as f64 * 0.05).sin() * 0.2 + off)
555                    .collect(),
556            );
557            truth.push(0);
558        }
559        // Group 1: near 10.
560        for i in 0..5 {
561            let off = i as f64 * 0.01;
562            rows.push(
563                (0..m)
564                    .map(|k| (k as f64 * 0.05).sin() * 0.2 + 10.0 + off)
565                    .collect(),
566            );
567            truth.push(1);
568        }
569        (matrix_from_rows(&rows), truth)
570    }
571
572    /// Permutation-invariant purity of `labels` against `truth`.
573    fn purity(labels: &[usize], truth: &[usize], k: usize) -> f64 {
574        let n = labels.len();
575        let n_truth = truth.iter().copied().max().unwrap_or(0) + 1;
576        let mut correct = 0usize;
577        for c in 0..k {
578            // Majority true-label count within predicted cluster c.
579            let mut counts = vec![0usize; n_truth];
580            for i in 0..n {
581                if labels[i] == c {
582                    counts[truth[i]] += 1;
583                }
584            }
585            correct += counts.iter().copied().max().unwrap_or(0);
586        }
587        correct as f64 / n as f64
588    }
589
590    #[test]
591    fn test_kernel_kmeans_recovers_groups() {
592        let (data, truth) = two_groups();
593        let cfg = KernelKmeansConfig::new(2, 1.0);
594        let res = kernel_kmeans_fd(&data, &cfg).unwrap();
595        assert_eq!(res.cluster.len(), 10);
596        let p = purity(&res.cluster, &truth, 2);
597        assert!((p - 1.0).abs() < 1e-12, "purity {p} != 1.0");
598        assert_eq!(res.n_clusters(), 2);
599    }
600
601    #[test]
602    fn test_kernel_kmeans_deterministic() {
603        let (data, _) = two_groups();
604        let cfg = KernelKmeansConfig::new(2, 1.0);
605        let a = kernel_kmeans_fd(&data, &cfg).unwrap();
606        let b = kernel_kmeans_fd(&data, &cfg).unwrap();
607        assert_eq!(a.cluster, b.cluster, "same seed must give identical labels");
608        assert_eq!(a.inertia.to_bits(), b.inertia.to_bits());
609        assert_eq!(a.n_init_best, b.n_init_best);
610    }
611
612    #[test]
613    fn test_kernel_kmeans_empty_cluster_recovery() {
614        // k = 4 but only two natural groups (n = 10). Must not panic and must
615        // return valid labels with exactly k distinct non-empty clusters.
616        let (data, _) = two_groups();
617        let cfg = KernelKmeansConfig {
618            n_clusters: 4,
619            ..KernelKmeansConfig::new(4, 1.0)
620        };
621        let res = kernel_kmeans_fd(&data, &cfg).unwrap();
622        assert_eq!(res.cluster.len(), 10);
623        assert!(res.cluster.iter().all(|&c| c < 4));
624        // Sizes are internally consistent (no empty cluster left behind).
625        let mut sizes = vec![0usize; 4];
626        for &c in &res.cluster {
627            sizes[c] += 1;
628        }
629        assert!(
630            sizes.iter().all(|&s| s >= 1),
631            "an empty cluster survived: {sizes:?}"
632        );
633    }
634
635    #[test]
636    fn test_kernel_kmeans_empty_cluster_k_equals_n() {
637        // Extreme case k == n: every point its own cluster; must not panic.
638        let rows: Vec<Vec<f64>> = (0..4)
639            .map(|i| (0..12).map(|k| (k as f64 * 0.1 + i as f64).sin()).collect())
640            .collect();
641        let data = matrix_from_rows(&rows);
642        let cfg = KernelKmeansConfig::new(4, 1.0);
643        let res = kernel_kmeans_fd(&data, &cfg).unwrap();
644        assert_eq!(res.cluster.len(), 4);
645        assert!(res.cluster.iter().all(|&c| c < 4));
646    }
647
648    #[test]
649    fn test_kernel_kmeans_n_init() {
650        // n_init > 1 must return inertia no worse than a single-init baseline on
651        // the same seed, and build the Gram once (implicit — single train call).
652        let (data, _) = two_groups();
653        let multi = KernelKmeansConfig {
654            n_init: 10,
655            ..KernelKmeansConfig::new(2, 1.0)
656        };
657        let single = KernelKmeansConfig {
658            n_init: 1,
659            ..KernelKmeansConfig::new(2, 1.0)
660        };
661        let rm = kernel_kmeans_fd(&data, &multi).unwrap();
662        let rs = kernel_kmeans_fd(&data, &single).unwrap();
663        assert!(
664            rm.inertia <= rs.inertia + 1e-12,
665            "multi-init inertia {} worse than single-init {}",
666            rm.inertia,
667            rs.inertia
668        );
669    }
670
671    #[test]
672    fn test_kernel_kmeans_predict() {
673        let (data, _) = two_groups();
674        let cfg = KernelKmeansConfig::new(2, 1.0);
675        let res = kernel_kmeans_fd(&data, &cfg).unwrap();
676
677        // Cluster label of the low band (training curve 0) and high band (curve 5).
678        let low_label = res.cluster[0];
679        let high_label = res.cluster[5];
680        assert_ne!(low_label, high_label);
681
682        let m = 20;
683        // Test set: a novel low curve, a novel high curve, and an exact copy of
684        // training curve 0.
685        let low_curve: Vec<f64> = (0..m)
686            .map(|k| (k as f64 * 0.05).sin() * 0.2 + 0.03)
687            .collect();
688        let high_curve: Vec<f64> = (0..m)
689            .map(|k| (k as f64 * 0.05).sin() * 0.2 + 10.03)
690            .collect();
691        let copy0 = data.row(0);
692        let test = matrix_from_rows(&[low_curve, high_curve, copy0]);
693
694        let preds = res.predict(&test).unwrap();
695        assert_eq!(preds.len(), 3);
696        assert_eq!(
697            preds[0], low_label,
698            "novel low curve should route to low cluster"
699        );
700        assert_eq!(
701            preds[1], high_label,
702            "novel high curve should route to high cluster"
703        );
704        assert_eq!(
705            preds[2], res.cluster[0],
706            "exact copy should match its training label"
707        );
708    }
709
710    #[test]
711    fn test_kernel_kmeans_validation() {
712        let (data, _) = two_groups();
713        // n_clusters = 0.
714        let cfg0 = KernelKmeansConfig {
715            n_clusters: 0,
716            ..KernelKmeansConfig::new(0, 1.0)
717        };
718        assert!(matches!(
719            kernel_kmeans_fd(&data, &cfg0),
720            Err(FdarError::InvalidParameter { .. })
721        ));
722        // n_clusters > n.
723        let cfg_big = KernelKmeansConfig {
724            n_clusters: 999,
725            ..KernelKmeansConfig::new(999, 1.0)
726        };
727        assert!(matches!(
728            kernel_kmeans_fd(&data, &cfg_big),
729            Err(FdarError::InvalidParameter { .. })
730        ));
731        // n_init = 0.
732        let cfg_ni = KernelKmeansConfig {
733            n_init: 0,
734            ..KernelKmeansConfig::new(2, 1.0)
735        };
736        assert!(matches!(
737            kernel_kmeans_fd(&data, &cfg_ni),
738            Err(FdarError::InvalidParameter { .. })
739        ));
740        // Empty data.
741        let empty = FdMatrix::zeros(0, 0);
742        assert!(matches!(
743            kernel_kmeans_fd(&empty, &KernelKmeansConfig::new(2, 1.0)),
744            Err(FdarError::InvalidDimension { .. })
745        ));
746    }
747
748    #[test]
749    fn test_kernel_kmeans_no_centroid() {
750        // Structural: the result exposes only the documented public fields; there
751        // is no centroid/centers field. This test destructures the public API —
752        // if a `centers` field were added it would still compile, so we assert the
753        // intent by using ONLY the documented fields and confirming no centroid is
754        // needed for predict (predict works from stored Gram state alone).
755        let (data, _) = two_groups();
756        let res = kernel_kmeans_fd(&data, &KernelKmeansConfig::new(2, 1.0)).unwrap();
757        let KernelKmeansResult {
758            cluster,
759            inertia,
760            iter,
761            converged,
762            n_init_best,
763            ..
764        } = &res;
765        assert_eq!(cluster.len(), 10);
766        assert!(inertia.is_finite());
767        assert!(*iter >= 1);
768        let _ = converged;
769        let _ = n_init_best;
770        // predict needs no centroid — it works purely from the stored kernel state.
771        let preds = res.predict(&data).unwrap();
772        assert_eq!(preds, res.cluster);
773    }
774}