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}