fdars_core/coclustering.rs
1//! Functional co-clustering via the funLBM latent block model.
2//!
3//! This module implements a functional latent block model (funLBM) using a
4//! Classification EM (CEM) algorithm. It simultaneously clusters:
5//! - **Row clusters**: partitions the n curves into K row-clusters.
6//! - **Column clusters**: partitions the m argument points into L column-clusters.
7//!
8//! ## Column-cluster semantics (RESOLVED)
9//!
10//! `col_labels` has length **m** — the m argument/evaluation points are partitioned
11//! into L column-clusters (need not be contiguous). This is true funLBM. The column
12//! clusters do NOT range over the FPC components.
13//!
14//! ## Global FPCA reuse with block-score projection
15//!
16//! ONE global FPCA is computed via [`fdata_to_pc_1d`]. For a curve i in column-block l,
17//! the block score is the projection of Y_i **restricted to column-block l's argument points**
18//! onto the global FPC loadings restricted to those same points:
19//!
20//! ```text
21//! block_score[i][l][k] = Σ_{j: col_labels[j]==l} weights[j] * (data[(i,j)] - mean[j]) * rotation[(j,k)]
22//! ```
23//!
24//! This restricts the standard weighted FPC inner product to a column-block's argument-point
25//! subset, keeping columns = argument points while reusing a single global FPCA.
26//!
27//! ## Divergences from R funLBM 2.3.1
28//!
29//! | Aspect | fdars (this module) | R funLBM 2.3.1 |
30//! |-----------------------|-------------------------------|-------------------------|
31//! | FPCA scope | One global FPCA | Per-block FPCA |
32//! | EM variant | Deterministic CEM (hard) | SEM-Gibbs (stochastic) |
33//! | Block covariance | Diagonal (ncomp variances) | Full covariance matrix |
34//! | Column semantics | m argument points | m argument points (same)|
35//!
36//! ## References
37//!
38//! - Bouveyron et al. (2018), "Co-clustering of Multivariate Functional Data", JASA.
39//! - Govaert & Nadif (2008), "Block clustering with Bernoulli mixture models", CIS.
40
41use std::f64::consts::PI;
42
43use rand::prelude::*;
44
45use crate::error::FdarError;
46use crate::iter_maybe_parallel;
47use crate::matrix::FdMatrix;
48use crate::regression::fdata_to_pc_1d;
49#[cfg(feature = "parallel")]
50use rayon::iter::ParallelIterator;
51
52/// Below this init count, the multi-restart CEM loop dispatches sequentially to avoid rayon
53/// overhead exceeding the per-init compute. Payback threshold — mirrors the v0.17.0
54/// `SCORES_PARALLEL_THRESHOLD` precedent; refined by `perf_parallelism` thread-scaling in Wave 3.
55pub(crate) const CO_CLUSTER_INIT_PARALLEL_THRESHOLD: usize = 3;
56
57/// Per-block Gaussian parameters (diagonal covariance in the FPC score space).
58///
59/// Each block (k, l) — row-cluster k, column-cluster l — is modelled by a
60/// diagonal multivariate Gaussian on the `ncomp`-dimensional block scores.
61///
62/// Indexed as `block_params[k * n_col_blocks + l]`.
63#[derive(Debug, Clone, PartialEq)]
64#[non_exhaustive]
65#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66pub struct BlockParams {
67 /// Per-component block mean (length `eff_ncomp`).
68 pub mean: Vec<f64>,
69 /// Per-component block variance, diagonal (length `eff_ncomp`).
70 pub variance: Vec<f64>,
71}
72
73/// Result of [`co_cluster`].
74///
75/// The block structure is indexed as `block_params[k * n_col_blocks + l]`
76/// where k ∈ 0..n_row_blocks and l ∈ 0..n_col_blocks.
77///
78/// ## ICL formula
79///
80/// The ICL (Integrated Completed Likelihood) uses the symmetric Govaert-Nadif penalty:
81/// ```text
82/// p_KL = (K-1) + (L-1) + 2 * K * L * eff_ncomp
83/// ICL = log_likelihood - 0.5 * p_KL * (ln(n) + ln(m))
84/// ```
85/// Here `ln(n)` penalises the n-curve row dimension and `ln(m)` penalises the
86/// m-argument-point column dimension — reflecting that column-clusters partition
87/// the m argument points (not the FPC components).
88#[derive(Debug, Clone, PartialEq)]
89#[non_exhaustive]
90#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
91pub struct CoClusterResult {
92 /// Hard row-cluster assignments, length n.
93 /// Values in `0..n_row_blocks`.
94 pub row_labels: Vec<usize>,
95
96 /// Hard column-cluster assignments, length **m** (the number of argument points).
97 ///
98 /// Values in `0..n_col_blocks`. This always satisfies `col_labels.len() == m` —
99 /// columns cluster the argument points, NOT the FPC components.
100 pub col_labels: Vec<usize>,
101
102 /// Number of row clusters K.
103 pub n_row_blocks: usize,
104
105 /// Number of column clusters L.
106 pub n_col_blocks: usize,
107
108 /// Per-block Gaussian parameters, length K*L, indexed `k*L + l`.
109 /// Each element describes the diagonal Gaussian on the `eff_ncomp`-dimensional
110 /// block scores for the (k, l) block.
111 pub block_params: Vec<BlockParams>,
112
113 /// Mixing proportions for row clusters, length K. Sums to 1.
114 pub row_props: Vec<f64>,
115
116 /// Mixing proportions for column clusters, length L. Sums to 1.
117 pub col_props: Vec<f64>,
118
119 /// Converged classification log-likelihood (non-decreasing across CEM iterations).
120 pub log_likelihood: f64,
121
122 /// ICL model-selection criterion (finite; lower = better model).
123 ///
124 /// Formula: `ICL = log_likelihood - 0.5 * p_KL * (ln(n) + ln(m))`
125 /// where `p_KL = (K-1) + (L-1) + 2*K*L*eff_ncomp`.
126 pub icl: f64,
127
128 /// Number of CEM iterations performed.
129 pub iterations: usize,
130
131 /// Whether the algorithm converged before `max_iter`.
132 pub converged: bool,
133}
134
135/// Configuration for funLBM functional co-clustering.
136///
137/// Builder-style config mirroring [`GmmClusterConfig`](crate::gmm::cluster::GmmClusterConfig).
138/// Modify fields directly after calling [`CoClusterConfig::default()`].
139///
140/// # Example
141/// ```no_run
142/// use fdars_core::coclustering::CoClusterConfig;
143///
144/// let mut cfg = CoClusterConfig::default();
145/// cfg.n_row_blocks = 3;
146/// cfg.n_col_blocks = 4;
147/// cfg.ncomp = 3;
148/// cfg.n_init = 5;
149/// ```
150#[derive(Debug, Clone, PartialEq)]
151#[non_exhaustive]
152#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
153pub struct CoClusterConfig {
154 /// Number of row clusters K (default: 2).
155 pub n_row_blocks: usize,
156 /// Number of column clusters L (default: 2).
157 pub n_col_blocks: usize,
158 /// Number of FPC components for the block-score projection (default: 5).
159 /// The effective ncomp may be reduced to `min(ncomp, n, m)` by the FPCA.
160 pub ncomp: usize,
161 /// Maximum CEM iterations per initialization (default: 200).
162 pub max_iter: usize,
163 /// Convergence tolerance on the classification log-likelihood (default: 1e-6).
164 pub tol: f64,
165 /// Number of random initializations; the best by log-likelihood is returned (default: 3).
166 pub n_init: usize,
167 /// Base random seed for deterministic results (default: 42).
168 pub seed: u64,
169}
170
171impl Default for CoClusterConfig {
172 fn default() -> Self {
173 Self {
174 n_row_blocks: 2,
175 n_col_blocks: 2,
176 ncomp: 5,
177 max_iter: 200,
178 tol: 1e-6,
179 n_init: 3,
180 seed: 42,
181 }
182 }
183}
184
185// ---------------------------------------------------------------------------
186// Internal helpers
187// ---------------------------------------------------------------------------
188
189/// Log-density of a scalar under a 1-D Gaussian N(x; mu, var).
190///
191/// Returns -∞ if `var <= 0`.
192#[inline]
193fn log_gaussian_1d(x: f64, mu: f64, var: f64) -> f64 {
194 if var <= 0.0 {
195 return f64::NEG_INFINITY;
196 }
197 -0.5 * ((x - mu).powi(2) / var + var.ln() + (2.0 * PI).ln())
198}
199
200/// Build the flat block-score buffer (n * L * eff_ncomp, indexed `(i*L + l)*e + k`).
201///
202/// For each curve i and column-cluster l:
203/// `bscore[i][l][k] = Σ_{j: col_labels[j]==l} weights[j] * (data[(i,j)] - mean[j]) * rotation[(j,k)]`
204fn build_block_scores(
205 data: &FdMatrix,
206 rotation: &FdMatrix,
207 mean: &[f64],
208 weights: &[f64],
209 col_labels: &[usize],
210 n: usize,
211 m: usize,
212 l_blocks: usize,
213 eff_ncomp: usize,
214) -> Vec<f64> {
215 let total = n * l_blocks * eff_ncomp;
216 let mut buf = vec![0.0_f64; total];
217
218 // Iterate over argument points j; for each j accumulate into the correct l-block.
219 for j in 0..m {
220 let l = col_labels[j];
221 let w = weights[j];
222 let mean_j = mean[j];
223 // rotation is m×eff_ncomp column-major: rotation[(j, k)] at j + k*m
224 for i in 0..n {
225 let val = data[(i, j)] - mean_j;
226 let base = (i * l_blocks + l) * eff_ncomp;
227 for k in 0..eff_ncomp {
228 // rotation[(j, k)] = rotation.data[j + k*m], but use column() for the k-th column
229 buf[base + k] += w * val * rotation[(j, k)];
230 }
231 }
232 }
233
234 buf
235}
236
237/// Compute data-scaled regularization floor over block scores (1-D analogue of data_scaled_reg).
238fn block_score_reg(block_scores: &[f64], n: usize, l_blocks: usize, eff_ncomp: usize) -> f64 {
239 const REG_REL: f64 = 1e-6;
240 if n == 0 || l_blocks == 0 || eff_ncomp == 0 {
241 return REG_REL;
242 }
243 let total_blocks = l_blocks * eff_ncomp;
244 let mut total_var = 0.0_f64;
245 let mut n_dims = 0u64;
246 for l in 0..l_blocks {
247 for comp in 0..eff_ncomp {
248 // Collect all n scores for this (l, comp)
249 let mut sum = 0.0_f64;
250 let mut ss = 0.0_f64;
251 for i in 0..n {
252 let v = block_scores[(i * l_blocks + l) * eff_ncomp + comp];
253 sum += v;
254 ss += v * v;
255 }
256 let mean = sum / n as f64;
257 let var = ss / n as f64 - mean * mean;
258 total_var += var;
259 n_dims += 1;
260 }
261 }
262 let _ = total_blocks; // suppress unused warning
263 let mean_var = if n_dims > 0 {
264 total_var / n_dims as f64
265 } else {
266 0.0
267 };
268 if mean_var > 0.0 {
269 REG_REL * mean_var
270 } else {
271 REG_REL
272 }
273}
274
275/// M-step: recompute row_props, col_props, and block_params from current labels.
276fn m_step(
277 block_scores: &[f64],
278 row_labels: &[usize],
279 col_labels: &[usize],
280 n: usize,
281 m: usize,
282 k_blocks: usize,
283 l_blocks: usize,
284 eff_ncomp: usize,
285 reg: f64,
286) -> (Vec<f64>, Vec<f64>, Vec<BlockParams>) {
287 // Row proportions
288 let mut row_counts = vec![0usize; k_blocks];
289 for &r in row_labels {
290 row_counts[r] += 1;
291 }
292 let row_props: Vec<f64> = row_counts.iter().map(|&c| c as f64 / n as f64).collect();
293
294 // Column proportions
295 let mut col_counts = vec![0usize; l_blocks];
296 for &c in col_labels {
297 col_counts[c] += 1;
298 }
299 let col_props: Vec<f64> = col_counts.iter().map(|&c| c as f64 / m as f64).collect();
300
301 // Per-block Gaussian parameters
302 let mut block_params = Vec::with_capacity(k_blocks * l_blocks);
303 for k in 0..k_blocks {
304 for l in 0..l_blocks {
305 let mut mean = vec![0.0_f64; eff_ncomp];
306 let mut var = vec![0.0_f64; eff_ncomp];
307 let mut cnt = 0u64;
308
309 for i in 0..n {
310 if row_labels[i] != k {
311 continue;
312 }
313 cnt += 1;
314 let base = (i * l_blocks + l) * eff_ncomp;
315 for comp in 0..eff_ncomp {
316 mean[comp] += block_scores[base + comp];
317 }
318 }
319
320 if cnt > 0 {
321 let nf = cnt as f64;
322 for comp in 0..eff_ncomp {
323 mean[comp] /= nf;
324 }
325 // Second pass for variance
326 for i in 0..n {
327 if row_labels[i] != k {
328 continue;
329 }
330 let base = (i * l_blocks + l) * eff_ncomp;
331 for comp in 0..eff_ncomp {
332 let d = block_scores[base + comp] - mean[comp];
333 var[comp] += d * d;
334 }
335 }
336 for comp in 0..eff_ncomp {
337 var[comp] = var[comp] / nf + reg;
338 }
339 } else {
340 // Empty block: use flat variance = reg to avoid NaN
341 for comp in 0..eff_ncomp {
342 var[comp] = reg;
343 }
344 }
345
346 block_params.push(BlockParams {
347 mean,
348 variance: var,
349 });
350 }
351 }
352
353 (row_props, col_props, block_params)
354}
355
356/// Compute classification log-likelihood from current hard labels + parameters.
357fn classification_log_likelihood(
358 block_scores: &[f64],
359 row_labels: &[usize],
360 _col_labels: &[usize],
361 row_props: &[f64],
362 col_props: &[f64],
363 block_params: &[BlockParams],
364 n: usize,
365 _m: usize,
366 _k_blocks: usize,
367 l_blocks: usize,
368 eff_ncomp: usize,
369) -> f64 {
370 let mut ll = 0.0_f64;
371
372 for i in 0..n {
373 let k = row_labels[i];
374 let rp = row_props[k];
375 if rp < 1e-15 {
376 continue;
377 }
378 ll += rp.ln();
379
380 // Sum log-density over all l blocks (the block score for l already encodes col assignment)
381 for l in 0..l_blocks {
382 let cp = col_props[l];
383 if cp < 1e-15 {
384 continue;
385 }
386 let bp = &block_params[k * l_blocks + l];
387 let base = (i * l_blocks + l) * eff_ncomp;
388 let mut block_ld = 0.0_f64;
389 for comp in 0..eff_ncomp {
390 block_ld +=
391 log_gaussian_1d(block_scores[base + comp], bp.mean[comp], bp.variance[comp]);
392 }
393 ll += cp.ln() + block_ld;
394 }
395 }
396
397 ll
398}
399
400/// E-row: for each curve i, pick argmax_k classification log-density.
401fn e_row_step(
402 block_scores: &[f64],
403 row_props: &[f64],
404 col_props: &[f64],
405 block_params: &[BlockParams],
406 n: usize,
407 k_blocks: usize,
408 l_blocks: usize,
409 eff_ncomp: usize,
410) -> Vec<usize> {
411 let mut row_labels = vec![0usize; n];
412 for i in 0..n {
413 let mut best_k = 0usize;
414 let mut best_score = f64::NEG_INFINITY;
415 for k in 0..k_blocks {
416 let rp = row_props[k];
417 if rp < 1e-15 {
418 continue;
419 }
420 let mut score = rp.ln();
421 for l in 0..l_blocks {
422 let cp = col_props[l];
423 if cp < 1e-15 {
424 continue;
425 }
426 let bp = &block_params[k * l_blocks + l];
427 let base = (i * l_blocks + l) * eff_ncomp;
428 let mut block_ld = 0.0_f64;
429 for comp in 0..eff_ncomp {
430 block_ld += log_gaussian_1d(
431 block_scores[base + comp],
432 bp.mean[comp],
433 bp.variance[comp],
434 );
435 }
436 score += cp.ln() + block_ld;
437 }
438 if score > best_score {
439 best_score = score;
440 best_k = k;
441 }
442 }
443 row_labels[i] = best_k;
444 }
445 row_labels
446}
447
448/// E-col: for each argument point j, pick argmax_l of the classification log-density gain.
449///
450/// The gain of assigning argument point j to column-cluster l is:
451/// Σ_i [ log π_k(i) + Σ_{l'} (cp[l'] + block_ld(i,l')) ] where l's contribution changes.
452///
453/// We use a simpler but equivalent approach: for each j, try each candidate l, compute
454/// the change in total classification LL from reassigning j from current label to l.
455/// Since block scores depend on col_labels, we compute this by holding all other j fixed
456/// and computing the marginal LL contribution of adding point j to column-cluster l for
457/// each curve i. This is computed as:
458///
459/// For each l_candidate: Δ_j(l_candidate) = Σ_i Σ_k [I(row_labels[i]==k) *
460/// weights[j] * (data[(i,j)] - mean[j]) * Σ_comp rotation[(j,comp)] *
461/// (log N(b_score | mu_kl, var_kl))] — a per-j marginal computation.
462///
463/// In practice we compute it as the direct contribution to the classification LL
464/// of reassigning j → l_candidate (approximation: fix other j's col_labels unchanged).
465fn e_col_step(
466 data: &FdMatrix,
467 rotation: &FdMatrix,
468 mean: &[f64],
469 weights: &[f64],
470 col_labels: &[usize],
471 row_labels: &[usize],
472 row_props: &[f64],
473 col_props: &[f64],
474 block_params: &[BlockParams],
475 n: usize,
476 m: usize,
477 _k_blocks: usize,
478 l_blocks: usize,
479 eff_ncomp: usize,
480) -> Vec<usize> {
481 let mut new_col_labels = col_labels.to_vec();
482
483 // For each argument point j, compute the incremental block-score contribution
484 // from point j to each possible column-cluster l_cand, then pick argmax_l_cand
485 // of the sum (over curves i) of the resulting log-density gain.
486 for j in 0..m {
487 let w_j = weights[j];
488 let mean_j = mean[j];
489
490 // Precompute for each curve i and FPC component: the weighted centered value at j
491 // s[i][comp] = weights[j] * (data[(i,j)] - mean[j]) * rotation[(j, comp)]
492 let mut s = vec![0.0_f64; n * eff_ncomp];
493 for i in 0..n {
494 let val = w_j * (data[(i, j)] - mean_j);
495 for comp in 0..eff_ncomp {
496 s[i * eff_ncomp + comp] = val * rotation[(j, comp)];
497 }
498 }
499
500 let mut best_l = 0usize;
501 let mut best_gain = f64::NEG_INFINITY;
502
503 for l_cand in 0..l_blocks {
504 let cp = col_props[l_cand];
505 if cp < 1e-15 {
506 continue;
507 }
508 // Compute the gain from assigning j → l_cand.
509 // For each curve i: the block score for (i, l_cand) gains s[i][·].
510 // We compute the log-density gain vs. the current assignment.
511 let l_curr = col_labels[j];
512 let mut gain = 0.0_f64;
513
514 for i in 0..n {
515 let k = row_labels[i];
516 let rp = row_props[k];
517 if rp < 1e-15 {
518 continue;
519 }
520
521 let bp_cand = &block_params[k * l_blocks + l_cand];
522
523 // Log-density for l_cand: use the marginal contribution of point j
524 // (s[i][comp] = weights[j]*(data[(i,j)]-mean[j])*rotation[(j,comp)]) as a
525 // proxy for the gain from assigning j to l_cand. Terms constant across l_cand
526 // choices cancel in the argmax.
527 let mut ld_cand_new = 0.0_f64;
528 for comp in 0..eff_ncomp {
529 ld_cand_new += log_gaussian_1d(
530 s[i * eff_ncomp + comp],
531 bp_cand.mean[comp],
532 bp_cand.variance[comp],
533 );
534 }
535 gain += cp.ln() + ld_cand_new;
536
537 // Subtract the current assignment's contribution for l_curr
538 if l_curr != l_cand {
539 let bp_curr = &block_params[k * l_blocks + l_curr];
540 let mut ld_curr = 0.0_f64;
541 for comp in 0..eff_ncomp {
542 ld_curr += log_gaussian_1d(
543 s[i * eff_ncomp + comp],
544 bp_curr.mean[comp],
545 bp_curr.variance[comp],
546 );
547 }
548 let cp_curr = col_props[l_curr];
549 if cp_curr >= 1e-15 {
550 gain -= cp_curr.ln() + ld_curr;
551 }
552 }
553 }
554
555 if gain > best_gain {
556 best_gain = gain;
557 best_l = l_cand;
558 }
559 }
560
561 new_col_labels[j] = best_l;
562 }
563
564 new_col_labels
565}
566
567/// Column k-means++ initialization on argument-point profiles (each point j has n-dim profile).
568fn col_kmeans_init(data: &FdMatrix, n: usize, m: usize, l_blocks: usize, seed: u64) -> Vec<usize> {
569 if l_blocks >= m {
570 // Each point gets its own cluster (degenerate case handled upstream)
571 return (0..m).map(|j| j % l_blocks).collect();
572 }
573
574 let mut rng = StdRng::seed_from_u64(seed);
575
576 // Profile of point j: data.column(j) — length n, column-major so contiguous.
577 // Compute squared L2 distance between two argument-point profiles.
578 let profile_l2sq = |j1: usize, j2: usize| -> f64 {
579 let c1 = data.column(j1);
580 let c2 = data.column(j2);
581 c1.iter().zip(c2.iter()).map(|(a, b)| (a - b).powi(2)).sum()
582 };
583
584 // k-means++ initialization
585 let first = rng.gen_range(0..m);
586 let mut centers: Vec<usize> = vec![first];
587
588 for _ in 1..l_blocks {
589 // Compute distance from each point to nearest center
590 let dists: Vec<f64> = (0..m)
591 .map(|j| {
592 centers
593 .iter()
594 .map(|&c| profile_l2sq(j, c))
595 .fold(f64::INFINITY, f64::min)
596 })
597 .collect();
598 let total: f64 = dists.iter().sum();
599 if total < 1e-15 {
600 // All points are identical; assign round-robin
601 centers.push(centers.len() % m);
602 continue;
603 }
604 // Sample proportional to distance squared
605 let threshold = rng.gen::<f64>() * total;
606 let mut cum = 0.0;
607 let mut next = m - 1;
608 for (j, &d) in dists.iter().enumerate() {
609 cum += d;
610 if cum >= threshold {
611 next = j;
612 break;
613 }
614 }
615 centers.push(next);
616 }
617
618 // Assign each point to nearest center; run 10 assign-update iterations
619 let mut col_labels: Vec<usize> = (0..m)
620 .map(|j| {
621 centers
622 .iter()
623 .enumerate()
624 .map(|(ci, &c)| (ci, profile_l2sq(j, c)))
625 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
626 .map(|(ci, _)| ci)
627 .unwrap_or(0)
628 })
629 .collect();
630
631 for _ in 0..10 {
632 // Recompute centroids as mean of assigned profiles (in feature space R^n)
633 // We track centroid as column-major buffer of size n*l_blocks
634 let mut cent = vec![0.0_f64; n * l_blocks];
635 let mut cnt = vec![0u64; l_blocks];
636 for j in 0..m {
637 let l = col_labels[j];
638 cnt[l] += 1;
639 let col = data.column(j);
640 for i in 0..n {
641 cent[l * n + i] += col[i];
642 }
643 }
644 for l in 0..l_blocks {
645 if cnt[l] > 0 {
646 let c = cnt[l] as f64;
647 for i in 0..n {
648 cent[l * n + i] /= c;
649 }
650 }
651 }
652
653 // Reassign
654 let mut changed = false;
655 for j in 0..m {
656 let col = data.column(j);
657 let mut best_l = 0usize;
658 let mut best_d = f64::INFINITY;
659 for l in 0..l_blocks {
660 let d: f64 = (0..n).map(|i| (col[i] - cent[l * n + i]).powi(2)).sum();
661 if d < best_d {
662 best_d = d;
663 best_l = l;
664 }
665 }
666 if col_labels[j] != best_l {
667 changed = true;
668 col_labels[j] = best_l;
669 }
670 }
671
672 if !changed {
673 break;
674 }
675 }
676
677 col_labels
678}
679
680/// Run a single CEM fit from given initial row/col labels. Returns (result, per_iter_ll).
681#[allow(clippy::too_many_arguments)]
682fn cem_single_fit(
683 data: &FdMatrix,
684 rotation: &FdMatrix,
685 mean: &[f64],
686 weights: &[f64],
687 init_row_labels: Vec<usize>,
688 init_col_labels: Vec<usize>,
689 n: usize,
690 m: usize,
691 k_blocks: usize,
692 l_blocks: usize,
693 eff_ncomp: usize,
694 max_iter: usize,
695 tol: f64,
696) -> (CoClusterResult, Vec<f64>) {
697 let mut row_labels = init_row_labels;
698 let mut col_labels = init_col_labels;
699
700 // Initial block scores
701 let mut block_scores = build_block_scores(
702 data,
703 rotation,
704 mean,
705 weights,
706 &col_labels,
707 n,
708 m,
709 l_blocks,
710 eff_ncomp,
711 );
712
713 let reg = block_score_reg(&block_scores, n, l_blocks, eff_ncomp);
714
715 // Initial M-step
716 let (mut row_props, mut col_props, mut block_params) = m_step(
717 &block_scores,
718 &row_labels,
719 &col_labels,
720 n,
721 m,
722 k_blocks,
723 l_blocks,
724 eff_ncomp,
725 reg,
726 );
727
728 let mut prev_ll = f64::NEG_INFINITY;
729 let mut per_iter_ll: Vec<f64> = Vec::with_capacity(max_iter);
730 let mut iterations = 0usize;
731 let mut converged = false;
732
733 for iter in 0..max_iter {
734 // E-row: reassign curves
735 row_labels = e_row_step(
736 &block_scores,
737 &row_props,
738 &col_props,
739 &block_params,
740 n,
741 k_blocks,
742 l_blocks,
743 eff_ncomp,
744 );
745
746 // E-col: reassign argument points (uses current block_scores and params)
747 col_labels = e_col_step(
748 data,
749 rotation,
750 mean,
751 weights,
752 &col_labels,
753 &row_labels,
754 &row_props,
755 &col_props,
756 &block_params,
757 n,
758 m,
759 k_blocks,
760 l_blocks,
761 eff_ncomp,
762 );
763
764 // Rebuild block scores after col reassignment
765 block_scores = build_block_scores(
766 data,
767 rotation,
768 mean,
769 weights,
770 &col_labels,
771 n,
772 m,
773 l_blocks,
774 eff_ncomp,
775 );
776
777 // M-step
778 let (rp, cp, bp) = m_step(
779 &block_scores,
780 &row_labels,
781 &col_labels,
782 n,
783 m,
784 k_blocks,
785 l_blocks,
786 eff_ncomp,
787 reg,
788 );
789 row_props = rp;
790 col_props = cp;
791 block_params = bp;
792
793 // Classification log-likelihood
794 let ll = classification_log_likelihood(
795 &block_scores,
796 &row_labels,
797 &col_labels,
798 &row_props,
799 &col_props,
800 &block_params,
801 n,
802 m,
803 k_blocks,
804 l_blocks,
805 eff_ncomp,
806 );
807
808 per_iter_ll.push(ll);
809 iterations = iter + 1;
810
811 // Convergence check (skip iter 0 to allow at least one update)
812 if iter > 0 && (ll - prev_ll).abs() < tol {
813 converged = true;
814 break;
815 }
816 prev_ll = ll;
817 }
818
819 let log_likelihood = per_iter_ll.last().copied().unwrap_or(f64::NEG_INFINITY);
820
821 // ICL: p_KL = (K-1) + (L-1) + 2*K*L*eff_ncomp
822 let p_kl = (k_blocks.saturating_sub(1))
823 + (l_blocks.saturating_sub(1))
824 + 2 * k_blocks * l_blocks * eff_ncomp;
825 let icl = log_likelihood - 0.5 * (p_kl as f64) * ((n as f64).ln() + (m as f64).ln());
826
827 let result = CoClusterResult {
828 row_labels,
829 col_labels,
830 n_row_blocks: k_blocks,
831 n_col_blocks: l_blocks,
832 block_params,
833 row_props,
834 col_props,
835 log_likelihood,
836 icl,
837 iterations,
838 converged,
839 };
840
841 (result, per_iter_ll)
842}
843
844// ---------------------------------------------------------------------------
845// Public entry point
846// ---------------------------------------------------------------------------
847
848/// Fit a funLBM functional co-clustering model via Classification EM (CEM).
849///
850/// Simultaneously partitions the n curves into K row-clusters and the m argument
851/// points into L column-clusters. Returns hard assignments and per-block Gaussian
852/// parameters.
853///
854/// # Arguments
855/// * `data` — Functional data matrix (n × m), column-major.
856/// * `argvals` — Evaluation/argument points, length m. Must be sorted ascending.
857/// * `config` — Tuning parameters (K, L, ncomp, restarts, seed, …).
858///
859/// # Errors
860/// - [`FdarError::InvalidParameter`] if `config.ncomp < 1`, `n_row_blocks > n`, or `n_col_blocks > m`.
861/// - [`FdarError::InvalidDimension`] if `data` or `argvals` dimensions are inconsistent
862/// (propagated from [`fdata_to_pc_1d`]).
863/// - [`FdarError::ComputationFailed`] if all initializations fail (propagated from FPCA).
864///
865/// # Example
866/// ```no_run
867/// use fdars_core::coclustering::{co_cluster, CoClusterConfig};
868/// use fdars_core::matrix::FdMatrix;
869///
870/// let data = FdMatrix::zeros(10, 8);
871/// let argvals: Vec<f64> = (0..8).map(|i| i as f64 / 7.0).collect();
872/// let mut config = CoClusterConfig::default();
873/// config.n_row_blocks = 2;
874/// config.n_col_blocks = 2;
875/// config.ncomp = 3;
876/// let result = co_cluster(&data, &argvals, &config)?;
877/// assert_eq!(result.row_labels.len(), 10);
878/// assert_eq!(result.col_labels.len(), 8);
879/// # Ok::<(), fdars_core::error::FdarError>(())
880/// ```
881#[must_use = "expensive computation whose result should not be discarded"]
882pub fn co_cluster(
883 data: &FdMatrix,
884 argvals: &[f64],
885 config: &CoClusterConfig,
886) -> Result<CoClusterResult, FdarError> {
887 let (n, m) = data.shape();
888
889 // --- Input validation ---
890 if config.ncomp < 1 {
891 return Err(FdarError::InvalidParameter {
892 parameter: "ncomp",
893 message: format!("ncomp must be >= 1, got {}", config.ncomp),
894 });
895 }
896 if config.n_row_blocks > n {
897 return Err(FdarError::InvalidParameter {
898 parameter: "n_row_blocks",
899 message: format!(
900 "n_row_blocks={} exceeds number of observations n={}",
901 config.n_row_blocks, n
902 ),
903 });
904 }
905 if config.n_row_blocks == 0 {
906 return Err(FdarError::InvalidParameter {
907 parameter: "n_row_blocks",
908 message: "n_row_blocks must be >= 1".to_string(),
909 });
910 }
911 if config.n_col_blocks > m {
912 return Err(FdarError::InvalidParameter {
913 parameter: "n_col_blocks",
914 message: format!(
915 "n_col_blocks={} exceeds number of argument points m={}",
916 config.n_col_blocks, m
917 ),
918 });
919 }
920 if config.n_col_blocks == 0 {
921 return Err(FdarError::InvalidParameter {
922 parameter: "n_col_blocks",
923 message: "n_col_blocks must be >= 1".to_string(),
924 });
925 }
926
927 let k_blocks = config.n_row_blocks;
928 let l_blocks = config.n_col_blocks;
929
930 // --- Global FPCA ---
931 // fdata_to_pc_1d validates data/argvals dimensions and propagates its errors.
932 let fpca = fdata_to_pc_1d(data, config.ncomp, argvals)?;
933 // Read effective ncomp — may be < requested (clipped to min(n, m))
934 let eff_ncomp = fpca.scores.ncols();
935 let rotation = &fpca.rotation; // m × eff_ncomp
936 let mean = &fpca.mean; // len m
937 let weights = &fpca.weights; // len m
938
939 // --- Multi-restart CEM ---
940 let n_init = config.n_init.max(1);
941
942 // One initialization+fit, fully determined by `init` (per-init seeding is order-independent).
943 // Only `kmeans_fd` is fallible; its error is the one the old sequential `?` short-circuited on.
944 let run_init = |init: usize| -> Result<CoClusterResult, FdarError> {
945 let seed = config.seed.wrapping_add(init as u64 * 1000);
946
947 // Row initialization via kmeans_fd
948 use crate::clustering::kmeans_fd;
949 let km = kmeans_fd(data, argvals, k_blocks, 100, 1e-4, seed)?;
950 let init_row_labels = km.cluster;
951
952 // Column initialization via k-means++ on argument-point profiles
953 let init_col_labels = col_kmeans_init(data, n, m, l_blocks, seed.wrapping_add(1));
954
955 let (result, _per_iter_ll) = cem_single_fit(
956 data,
957 rotation,
958 mean,
959 weights,
960 init_row_labels,
961 init_col_labels,
962 n,
963 m,
964 k_blocks,
965 l_blocks,
966 eff_ncomp,
967 config.max_iter,
968 config.tol,
969 );
970 Ok(result)
971 };
972
973 // Build the per-init results in index order. Above the payback threshold the map runs in
974 // parallel (when the `parallel` feature is on). `collect::<Result<Vec,_>>()` short-circuits on
975 // an Err and preserves index order for the Ok values (rayon's IndexedParallelIterator). Error
976 // equivalence here does NOT rely on *which* Err wins a race: `run_init`'s only fallible call
977 // (`kmeans_fd`) fails solely on init-independent input validation, so every init would yield the
978 // identical error value anyway. Both branches thus produce the identical index-ordered Vec (or
979 // the identical Err); only the dispatch differs. (If an init-DEPENDENT fallible call is ever
980 // added inside `run_init`, revisit this: rayon does not guarantee the *lowest-index* Err wins.)
981 let results: Vec<CoClusterResult> = if n_init >= CO_CLUSTER_INIT_PARALLEL_THRESHOLD {
982 iter_maybe_parallel!(0..n_init)
983 .map(run_init)
984 .collect::<Result<Vec<_>, _>>()?
985 } else {
986 (0..n_init).map(run_init).collect::<Result<Vec<_>, _>>()?
987 };
988
989 // Reduce SEQUENTIALLY with strict `>` so the lowest init index wins ties — `reduce` keeps `acc`
990 // (the earlier element) unless a strictly-greater log_likelihood appears, exactly matching the
991 // old loop's "only replace when strictly greater" tie-break.
992 let best = results.into_iter().reduce(|acc, r| {
993 if r.log_likelihood > acc.log_likelihood {
994 r
995 } else {
996 acc
997 }
998 });
999
1000 best.ok_or_else(|| FdarError::ComputationFailed {
1001 operation: "co_cluster",
1002 detail: "all initializations failed".to_string(),
1003 })
1004}
1005
1006// ---------------------------------------------------------------------------
1007// Slope-heuristic model selection
1008// ---------------------------------------------------------------------------
1009
1010/// Result of [`co_cluster_select`]: the slope-heuristic-selected (K, L) fit
1011/// together with full grid diagnostics.
1012///
1013/// ## Grid diagnostics
1014///
1015/// `grid_scores` contains one entry per (K, L) pair in the sweep:
1016/// `(K, L, log_likelihood, model_dim, penalised_score)`.
1017///
1018/// `penalised_score = log_likelihood − penalty_rate × model_dim`.
1019/// In fallback branches (single cell, flat slope, small grid) `penalised_score = log_likelihood`.
1020///
1021/// ## Slope heuristic calibration
1022///
1023/// The Birgé–Massart penalty is estimated by OLS over the large-model (top-50% by dimension)
1024/// region of the fitted grid. This is a data-driven heuristic: it works best when the grid
1025/// spans a range of model dimensions and the data is well-separated enough for the
1026/// log-likelihood to grow linearly with dimension in the overparameterised region.
1027/// On poorly separated data the slope may be noisy and the selection may land at a boundary.
1028/// Inspect `grid_scores` to audit the selection.
1029///
1030/// ## Divergence from R funHDDC
1031///
1032/// The slope calibration here uses OLS over the top-50% by model dimension (the "linear region"
1033/// heuristic of Baudry, Maugis & Michel 2012). R's funHDDC uses a slightly different calibration
1034/// based on the full grid. The selected model may differ on small grids.
1035#[derive(Debug, Clone)]
1036#[non_exhaustive]
1037#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1038pub struct CoClusterSelectResult {
1039 /// The selected (K*, L*) co-clustering result.
1040 pub best: CoClusterResult,
1041 /// Selected number of row clusters K*.
1042 pub best_k: usize,
1043 /// Selected number of column clusters L*.
1044 pub best_l: usize,
1045 /// All grid fits: `(K, L, log_likelihood, model_dim, penalised_score)`.
1046 ///
1047 /// `penalised_score = log_likelihood − penalty_rate * model_dim`.
1048 /// In fallback branches (< 4 grid points, flat slope, etc.) `penalised_score = log_likelihood`.
1049 pub grid_scores: Vec<(usize, usize, f64, usize, f64)>,
1050 /// OLS slope estimated from the top-50% of fits by model dimension.
1051 /// Zero when the grid is too small or the slope heuristic fell back to max-LL.
1052 pub slope_estimate: f64,
1053 /// Penalty rate applied per model dimension: `2 × |slope_estimate|`.
1054 /// Zero in fallback branches.
1055 pub penalty_rate: f64,
1056}
1057
1058/// Fit funLBM over a (K, L) grid and select the best block count via the
1059/// Birgé–Massart slope heuristic.
1060///
1061/// For every combination of K in `k_range` and L in `l_range` the function
1062/// calls [`co_cluster`] (with `config` cloned and `n_row_blocks`/`n_col_blocks`
1063/// overridden), collects the (model_dimension, log_likelihood) pair, estimates
1064/// the slope of the LL-vs-dim curve in the large-model region by OLS, and
1065/// selects `argmax (LL − 2 × |slope| × dim)`.
1066///
1067/// ## Model dimension formula
1068///
1069/// `dim(K, L) = (K−1) + (L−1) + 2·K·L·eff_ncomp`
1070///
1071/// where `eff_ncomp` is the effective FPC count used by the fitted model
1072/// (read from `block_params[0].mean.len()`; may be less than `config.ncomp`
1073/// when clipped to `min(n, m)`).
1074///
1075/// ## Slope estimation
1076///
1077/// OLS over the top-50% of fits by model dimension (the region assumed to be
1078/// linear in the LL-vs-dim curve). Fallback to `argmax LL` when:
1079/// - the grid has fewer than 4 distinct-dimension points (or fewer than 4 total),
1080/// - the OLS denominator is near zero (all dims equal in the large-model subset),
1081/// - or the estimated penalty rate is ≤ 0 (flat/increasing LL with dimension).
1082///
1083/// In every fallback branch `slope_estimate = 0.0` and `penalty_rate = 0.0`;
1084/// `grid_scores` is always fully populated.
1085///
1086/// ## Determinism
1087///
1088/// Each grid fit uses the seed from `config.seed`. Fits that would fail
1089/// `co_cluster`'s own validation (e.g. K > n or L > m) propagate their
1090/// `FdarError` immediately.
1091///
1092/// # Arguments
1093/// * `data` — Functional data matrix (n × m), column-major.
1094/// * `argvals` — Evaluation points, length m. Must be sorted ascending.
1095/// * `k_range` — Candidate K values (number of row clusters). Must be non-empty.
1096/// * `l_range` — Candidate L values (number of column clusters). Must be non-empty.
1097/// * `config` — Base tuning parameters. `n_row_blocks` and `n_col_blocks` are
1098/// overridden per grid cell; all other fields are reused as-is.
1099///
1100/// # Errors
1101/// - [`FdarError::InvalidParameter`] if `k_range` or `l_range` is empty.
1102/// - Any error propagated from [`co_cluster`] for an invalid (K, L) combination.
1103///
1104/// # Example
1105/// ```no_run
1106/// use fdars_core::coclustering::{co_cluster_select, CoClusterConfig};
1107/// use fdars_core::matrix::FdMatrix;
1108///
1109/// let data = FdMatrix::zeros(20, 10);
1110/// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
1111/// let mut config = CoClusterConfig::default();
1112/// config.ncomp = 3;
1113/// config.n_init = 2;
1114/// let result = co_cluster_select(&data, &argvals, &[2, 3, 4], &[2, 3], &config)?;
1115/// println!("Selected K={}, L={}", result.best_k, result.best_l);
1116/// # Ok::<(), fdars_core::error::FdarError>(())
1117/// ```
1118#[must_use = "expensive grid sweep whose result should not be discarded"]
1119pub fn co_cluster_select(
1120 data: &FdMatrix,
1121 argvals: &[f64],
1122 k_range: &[usize],
1123 l_range: &[usize],
1124 config: &CoClusterConfig,
1125) -> Result<CoClusterSelectResult, FdarError> {
1126 // --- Validate inputs ---
1127 if k_range.is_empty() {
1128 return Err(FdarError::InvalidParameter {
1129 parameter: "k_range",
1130 message: "k_range must be non-empty".to_string(),
1131 });
1132 }
1133 if l_range.is_empty() {
1134 return Err(FdarError::InvalidParameter {
1135 parameter: "l_range",
1136 message: "l_range must be non-empty".to_string(),
1137 });
1138 }
1139
1140 // --- Build the (K, L) grid ---
1141 let grid: Vec<(usize, usize)> = k_range
1142 .iter()
1143 .flat_map(|&k| l_range.iter().map(move |&l| (k, l)))
1144 .collect();
1145
1146 // --- Sweep the grid sequentially (co_cluster is internally parallelised) ---
1147 // We use sequential iteration to keep grid results in deterministic order.
1148 // Each co_cluster call may itself use rayon via its internal helpers.
1149 let mut cell_results: Vec<(usize, usize, CoClusterResult)> = Vec::with_capacity(grid.len());
1150 for &(k, l) in &grid {
1151 let mut cell_cfg = config.clone();
1152 cell_cfg.n_row_blocks = k;
1153 cell_cfg.n_col_blocks = l;
1154 let result = co_cluster(data, argvals, &cell_cfg)?;
1155 cell_results.push((k, l, result));
1156 }
1157
1158 // --- Compute (dim, ll) for each cell ---
1159 // eff_ncomp = block_params[0].mean.len() (may be < config.ncomp when clipped)
1160 // model_dim = (K-1) + (L-1) + 2*K*L*eff_ncomp
1161 struct CellInfo {
1162 k: usize,
1163 l: usize,
1164 ll: f64,
1165 dim: usize,
1166 result_idx: usize,
1167 }
1168
1169 let infos: Vec<CellInfo> = cell_results
1170 .iter()
1171 .enumerate()
1172 .map(|(idx, (k, l, res))| {
1173 let eff_ncomp = if res.block_params.is_empty() {
1174 0
1175 } else {
1176 res.block_params[0].mean.len()
1177 };
1178 let dim = k.saturating_sub(1) + l.saturating_sub(1) + 2 * k * l * eff_ncomp;
1179 CellInfo {
1180 k: *k,
1181 l: *l,
1182 ll: res.log_likelihood,
1183 dim,
1184 result_idx: idx,
1185 }
1186 })
1187 .collect();
1188
1189 // --- Birgé–Massart slope estimation ---
1190 // Sort by dim descending to identify large-model region
1191 let n_grid = infos.len();
1192
1193 let (slope_estimate, penalty_rate) = if n_grid < 4 {
1194 // Too few points for reliable slope estimation; fall back to max-LL
1195 (0.0_f64, 0.0_f64)
1196 } else {
1197 // Take the top 50% (at least 4 points) by model dimension
1198 let mut sorted_by_dim: Vec<usize> = (0..n_grid).collect();
1199 sorted_by_dim.sort_by(|&a, &b| infos[b].dim.cmp(&infos[a].dim));
1200
1201 let n_top = (n_grid / 2).max(4).min(n_grid);
1202 let top_idxs = &sorted_by_dim[..n_top];
1203
1204 // OLS: slope = Σ(dim_i − d̄)(ll_i − l̄) / Σ(dim_i − d̄)²
1205 let d_mean: f64 = top_idxs.iter().map(|&i| infos[i].dim as f64).sum::<f64>() / n_top as f64;
1206 let l_mean: f64 = top_idxs.iter().map(|&i| infos[i].ll).sum::<f64>() / n_top as f64;
1207
1208 let numerator: f64 = top_idxs
1209 .iter()
1210 .map(|&i| (infos[i].dim as f64 - d_mean) * (infos[i].ll - l_mean))
1211 .sum();
1212 let denominator: f64 = top_idxs
1213 .iter()
1214 .map(|&i| (infos[i].dim as f64 - d_mean).powi(2))
1215 .sum();
1216
1217 if denominator.abs() < 1e-10 {
1218 // All dims equal in the large-model subset; fall back to max-LL
1219 (0.0_f64, 0.0_f64)
1220 } else {
1221 let slope = numerator / denominator;
1222 let pen = 2.0 * slope.abs();
1223 if pen <= 0.0 {
1224 (slope, 0.0_f64)
1225 } else {
1226 (slope, pen)
1227 }
1228 }
1229 };
1230
1231 // --- Compute penalised scores and select the best ---
1232 // penalty_rate == 0 means we fall back to argmax LL
1233 let penalised: Vec<f64> = infos
1234 .iter()
1235 .map(|ci| {
1236 if penalty_rate > 0.0 {
1237 ci.ll - penalty_rate * ci.dim as f64
1238 } else {
1239 ci.ll
1240 }
1241 })
1242 .collect();
1243
1244 // argmax of penalised scores
1245 let best_pos = penalised
1246 .iter()
1247 .enumerate()
1248 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Less))
1249 .map(|(i, _)| i)
1250 .unwrap_or(0);
1251
1252 let best_k = infos[best_pos].k;
1253 let best_l = infos[best_pos].l;
1254 let best_result_idx = infos[best_pos].result_idx;
1255
1256 // --- Build grid_scores (fully populated) ---
1257 let grid_scores: Vec<(usize, usize, f64, usize, f64)> = infos
1258 .iter()
1259 .enumerate()
1260 .map(|(pos, ci)| (ci.k, ci.l, ci.ll, ci.dim, penalised[pos]))
1261 .collect();
1262
1263 let best = cell_results.remove(best_result_idx).2;
1264
1265 Ok(CoClusterSelectResult {
1266 best,
1267 best_k,
1268 best_l,
1269 grid_scores,
1270 slope_estimate,
1271 penalty_rate,
1272 })
1273}
1274
1275// ---------------------------------------------------------------------------
1276// Tests
1277// ---------------------------------------------------------------------------
1278
1279#[cfg(test)]
1280mod tests {
1281 use super::*;
1282 use crate::test_helpers::{adjusted_rand_index, uniform_grid};
1283
1284 /// Build a synthetic (K=2, L=2) block-structured dataset.
1285 ///
1286 /// Returns (data, argvals, true_row_labels, true_col_labels).
1287 /// - First n/2 curves have a large positive offset on the first m/2 argument points.
1288 /// - Second n/2 curves have a large negative offset there.
1289 /// - Small Normal noise is added everywhere.
1290 fn make_block_data(
1291 n: usize,
1292 m: usize,
1293 seed: u64,
1294 ) -> (FdMatrix, Vec<f64>, Vec<usize>, Vec<usize>) {
1295 use rand::prelude::*;
1296 use rand_distr::Normal;
1297
1298 let argvals = uniform_grid(m);
1299 let mut rng = StdRng::seed_from_u64(seed);
1300 let noise_dist = Normal::new(0.0_f64, 0.1).unwrap();
1301
1302 let m_half = m / 2;
1303
1304 let mut data = FdMatrix::zeros(n, m);
1305 let mut true_row_labels = vec![0usize; n];
1306 let mut true_col_labels = vec![0usize; m];
1307
1308 // Column labels: first half → 0, second half → 1
1309 for j in m_half..m {
1310 true_col_labels[j] = 1;
1311 }
1312
1313 // Row labels and curve values
1314 for i in 0..n {
1315 let row_group = if i < n / 2 { 0 } else { 1 };
1316 true_row_labels[i] = row_group;
1317
1318 let signal = if row_group == 0 { 5.0_f64 } else { -5.0_f64 };
1319
1320 for j in 0..m {
1321 let noise: f64 = rng.sample(noise_dist);
1322 // Large signal only on the first m/2 columns (col-cluster 0)
1323 let base = if j < m_half { signal } else { 0.0 };
1324 data[(i, j)] = base + noise;
1325 }
1326 }
1327
1328 (data, argvals, true_row_labels, true_col_labels)
1329 }
1330
1331 /// Internal helper: run a single CEM fit and return the per-iteration LL vector.
1332 fn run_single_cem_with_ll(
1333 data: &FdMatrix,
1334 argvals: &[f64],
1335 k: usize,
1336 l: usize,
1337 ncomp: usize,
1338 seed: u64,
1339 ) -> (CoClusterResult, Vec<f64>) {
1340 let (n, m) = data.shape();
1341 let fpca = fdata_to_pc_1d(data, ncomp, argvals).unwrap();
1342 let eff_ncomp = fpca.scores.ncols();
1343
1344 use crate::clustering::kmeans_fd;
1345 let km = kmeans_fd(data, argvals, k, 100, 1e-4, seed).unwrap();
1346 let init_row = km.cluster;
1347 let init_col = col_kmeans_init(data, n, m, l, seed.wrapping_add(1));
1348
1349 cem_single_fit(
1350 data,
1351 &fpca.rotation,
1352 &fpca.mean,
1353 &fpca.weights,
1354 init_row,
1355 init_col,
1356 n,
1357 m,
1358 k,
1359 l,
1360 eff_ncomp,
1361 200,
1362 1e-6,
1363 )
1364 }
1365
1366 // -----------------------------------------------------------------------
1367 // Task 1 smoke test
1368 // -----------------------------------------------------------------------
1369
1370 #[test]
1371 fn test_co_cluster_smoke() {
1372 let n = 8;
1373 let m = 6;
1374 let argvals = uniform_grid(m);
1375 let data = FdMatrix::zeros(n, m);
1376 let config = CoClusterConfig {
1377 n_row_blocks: 2,
1378 n_col_blocks: 2,
1379 ncomp: 3,
1380 n_init: 1,
1381 ..Default::default()
1382 };
1383 let result = co_cluster(&data, &argvals, &config).unwrap();
1384 assert_eq!(result.row_labels.len(), n);
1385 assert_eq!(result.col_labels.len(), m);
1386 assert_eq!(result.block_params.len(), 4);
1387 // log-likelihood should be finite (may be -inf only if all zeros; accept either)
1388 // In practice, zeros → all equal block means → finite LL from the Gaussian
1389 // (variance will be reg-floored)
1390 assert!(result.log_likelihood.is_finite() || result.log_likelihood == f64::NEG_INFINITY);
1391 }
1392
1393 // -----------------------------------------------------------------------
1394 // Task 2 correctness tests
1395 // -----------------------------------------------------------------------
1396
1397 #[test]
1398 fn test_classification_ll_nondecreasing() {
1399 let (data, argvals, _, _) = make_block_data(16, 10, 7777);
1400 let (_result, per_iter_ll) = run_single_cem_with_ll(&data, &argvals, 2, 2, 3, 42);
1401
1402 // Classification LL must be non-decreasing across iterations
1403 // (allow tiny floating-point slack of 1e-6)
1404 for w in per_iter_ll.windows(2) {
1405 assert!(
1406 w[1] >= w[0] - 1e-6,
1407 "LL decreased: iter[i]={:.6} -> iter[i+1]={:.6}",
1408 w[0],
1409 w[1]
1410 );
1411 }
1412 }
1413
1414 #[test]
1415 fn test_coclustering_recovers_block_structure() {
1416 let (data, argvals, true_row, true_col) = make_block_data(20, 12, 1234);
1417 let config = CoClusterConfig {
1418 n_row_blocks: 2,
1419 n_col_blocks: 2,
1420 ncomp: 3,
1421 n_init: 3,
1422 seed: 42,
1423 ..Default::default()
1424 };
1425 let result = co_cluster(&data, &argvals, &config).unwrap();
1426
1427 let ari_row = adjusted_rand_index(&true_row, &result.row_labels);
1428 let ari_col = adjusted_rand_index(&true_col, &result.col_labels);
1429
1430 assert!(
1431 ari_row > 0.8,
1432 "Row ARI too low: {ari_row:.3} (expected > 0.8)"
1433 );
1434 assert!(
1435 ari_col > 0.8,
1436 "Col ARI too low: {ari_col:.3} (expected > 0.8)"
1437 );
1438 }
1439
1440 #[test]
1441 fn test_determinism_under_seed() {
1442 let (data, argvals, _, _) = make_block_data(16, 10, 999);
1443 let config = CoClusterConfig {
1444 n_row_blocks: 2,
1445 n_col_blocks: 2,
1446 ncomp: 3,
1447 n_init: 2,
1448 seed: 77,
1449 ..Default::default()
1450 };
1451
1452 let r1 = co_cluster(&data, &argvals, &config).unwrap();
1453 let r2 = co_cluster(&data, &argvals, &config).unwrap();
1454
1455 assert_eq!(
1456 r1.row_labels, r2.row_labels,
1457 "row_labels differ across runs"
1458 );
1459 assert_eq!(
1460 r1.col_labels, r2.col_labels,
1461 "col_labels differ across runs"
1462 );
1463 assert_eq!(
1464 r1.log_likelihood, r2.log_likelihood,
1465 "log_likelihood differs"
1466 );
1467 assert_eq!(r1.icl, r2.icl, "ICL differs");
1468 }
1469
1470 #[test]
1471 fn test_icl_is_finite() {
1472 let (data, argvals, _, _) = make_block_data(16, 10, 42);
1473 let config = CoClusterConfig {
1474 n_row_blocks: 2,
1475 n_col_blocks: 2,
1476 ncomp: 3,
1477 n_init: 1,
1478 ..Default::default()
1479 };
1480 let result = co_cluster(&data, &argvals, &config).unwrap();
1481 assert!(result.icl.is_finite(), "ICL is not finite: {}", result.icl);
1482 }
1483
1484 // -----------------------------------------------------------------------
1485 // Task 3 error-path tests
1486 // -----------------------------------------------------------------------
1487
1488 #[test]
1489 fn test_error_k_exceeds_n() {
1490 let n = 8;
1491 let m = 6;
1492 let data = FdMatrix::zeros(n, m);
1493 let argvals = uniform_grid(m);
1494 let config = CoClusterConfig {
1495 n_row_blocks: 99,
1496 n_col_blocks: 2,
1497 ncomp: 3,
1498 ..Default::default()
1499 };
1500 let err = co_cluster(&data, &argvals, &config).unwrap_err();
1501 assert!(
1502 matches!(
1503 err,
1504 FdarError::InvalidParameter {
1505 parameter: "n_row_blocks",
1506 ..
1507 }
1508 ),
1509 "Expected InvalidParameter(n_row_blocks), got: {err:?}"
1510 );
1511 }
1512
1513 #[test]
1514 fn test_error_l_exceeds_m() {
1515 let n = 8;
1516 let m = 6;
1517 let data = FdMatrix::zeros(n, m);
1518 let argvals = uniform_grid(m);
1519 let config = CoClusterConfig {
1520 n_row_blocks: 2,
1521 n_col_blocks: 99,
1522 ncomp: 3,
1523 ..Default::default()
1524 };
1525 let err = co_cluster(&data, &argvals, &config).unwrap_err();
1526 assert!(
1527 matches!(
1528 err,
1529 FdarError::InvalidParameter {
1530 parameter: "n_col_blocks",
1531 ..
1532 }
1533 ),
1534 "Expected InvalidParameter(n_col_blocks), got: {err:?}"
1535 );
1536 }
1537
1538 #[test]
1539 fn test_error_zero_ncomp() {
1540 let n = 8;
1541 let m = 6;
1542 let data = FdMatrix::zeros(n, m);
1543 let argvals = uniform_grid(m);
1544 let config = CoClusterConfig {
1545 n_row_blocks: 2,
1546 n_col_blocks: 2,
1547 ncomp: 0,
1548 ..Default::default()
1549 };
1550 let err = co_cluster(&data, &argvals, &config).unwrap_err();
1551 assert!(
1552 matches!(
1553 err,
1554 FdarError::InvalidParameter {
1555 parameter: "ncomp",
1556 ..
1557 }
1558 ),
1559 "Expected InvalidParameter(ncomp), got: {err:?}"
1560 );
1561 }
1562
1563 #[test]
1564 fn test_error_argvals_mismatch() {
1565 let n = 8;
1566 let m = 6;
1567 let data = FdMatrix::zeros(n, m);
1568 let argvals = uniform_grid(m + 3); // wrong length
1569 let config = CoClusterConfig {
1570 n_row_blocks: 2,
1571 n_col_blocks: 2,
1572 ncomp: 3,
1573 ..Default::default()
1574 };
1575 let err = co_cluster(&data, &argvals, &config).unwrap_err();
1576 assert!(
1577 matches!(err, FdarError::InvalidDimension { .. }),
1578 "Expected InvalidDimension, got: {err:?}"
1579 );
1580 }
1581
1582 // -----------------------------------------------------------------------
1583 // Task 1 (tracer) + Task 2 (slope heuristic) tests
1584 // -----------------------------------------------------------------------
1585
1586 #[test]
1587 fn test_co_cluster_select_smoke() {
1588 // Small grid: k_range=[2,3], l_range=[2] → 2 grid cells
1589 let n = 8;
1590 let m = 6;
1591 let argvals = uniform_grid(m);
1592 let data = FdMatrix::zeros(n, m);
1593 let config = CoClusterConfig {
1594 ncomp: 2,
1595 n_init: 1,
1596 ..Default::default()
1597 };
1598 let result = co_cluster_select(&data, &argvals, &[2, 3], &[2], &config).unwrap();
1599 assert_eq!(
1600 result.grid_scores.len(),
1601 2,
1602 "Expected 2 grid cells (K in {{2,3}}, L=2)"
1603 );
1604 assert_eq!(
1605 result.best.row_labels.len(),
1606 n,
1607 "best.row_labels.len() should equal n"
1608 );
1609 assert_eq!(
1610 result.best.col_labels.len(),
1611 m,
1612 "best.col_labels.len() should equal m"
1613 );
1614 }
1615
1616 #[test]
1617 fn test_slope_heuristic_selects_correct_kl() {
1618 // Use well-separated (K=2, L=2) block data; sweep [2,3,4] × [2,3].
1619 // The slope heuristic should select the true (K=2, L=2) or at least a
1620 // model with ARI > 0.8 on row assignments.
1621 let (data, argvals, true_row, _) = make_block_data(24, 12, 2024);
1622 let config = CoClusterConfig {
1623 ncomp: 3,
1624 n_init: 3,
1625 seed: 42,
1626 ..Default::default()
1627 };
1628 let result = co_cluster_select(&data, &argvals, &[2, 3, 4], &[2, 3], &config).unwrap();
1629
1630 // grid_scores should have 6 entries (3 K × 2 L)
1631 assert_eq!(result.grid_scores.len(), 6, "Expected 6 grid cells");
1632
1633 // All grid_scores entries should have finite (or NEG_INFINITY) log-likelihoods
1634 for &(k, l, ll, dim, pen) in &result.grid_scores {
1635 assert!(
1636 ll.is_finite() || ll == f64::NEG_INFINITY,
1637 "grid entry (K={k}, L={l}) has non-finite ll={ll}"
1638 );
1639 let _ = (dim, pen); // used
1640 }
1641
1642 // The best result should assign n curves
1643 assert_eq!(result.best.row_labels.len(), 24);
1644
1645 // ARI tolerance: best row assignment should have ARI > 0.6 with true labels
1646 // (relaxed because slope heuristic may pick K=3 on some runs, which is near-true)
1647 let ari = adjusted_rand_index(&true_row, &result.best.row_labels);
1648 assert!(
1649 ari > 0.6,
1650 "Row ARI too low: {ari:.3}. best_k={}, best_l={}",
1651 result.best_k,
1652 result.best_l
1653 );
1654 }
1655
1656 #[test]
1657 fn test_select_single_cell() {
1658 // Single-cell grid (k_range=[2], l_range=[2]) → 1 grid entry, no slope estimation
1659 let n = 10;
1660 let m = 8;
1661 let (data, argvals, _, _) = make_block_data(n, m, 42);
1662 let config = CoClusterConfig {
1663 ncomp: 2,
1664 n_init: 1,
1665 seed: 1,
1666 ..Default::default()
1667 };
1668 let result = co_cluster_select(&data, &argvals, &[2], &[2], &config).unwrap();
1669
1670 assert_eq!(
1671 result.grid_scores.len(),
1672 1,
1673 "Single-cell grid should have 1 entry"
1674 );
1675 assert_eq!(result.best_k, 2);
1676 assert_eq!(result.best_l, 2);
1677 // Slope fallback: < 4 points → slope_estimate = 0, penalty_rate = 0
1678 assert_eq!(
1679 result.slope_estimate, 0.0,
1680 "slope_estimate should be 0 for single-cell"
1681 );
1682 assert_eq!(
1683 result.penalty_rate, 0.0,
1684 "penalty_rate should be 0 for single-cell"
1685 );
1686 }
1687
1688 #[test]
1689 fn test_select_empty_range_errors() {
1690 let n = 8;
1691 let m = 6;
1692 let data = FdMatrix::zeros(n, m);
1693 let argvals = uniform_grid(m);
1694 let config = CoClusterConfig::default();
1695
1696 // Empty k_range
1697 let err = co_cluster_select(&data, &argvals, &[], &[2], &config).unwrap_err();
1698 assert!(
1699 matches!(
1700 err,
1701 FdarError::InvalidParameter {
1702 parameter: "k_range",
1703 ..
1704 }
1705 ),
1706 "Expected InvalidParameter(k_range), got: {err:?}"
1707 );
1708
1709 // Empty l_range
1710 let err = co_cluster_select(&data, &argvals, &[2], &[], &config).unwrap_err();
1711 assert!(
1712 matches!(
1713 err,
1714 FdarError::InvalidParameter {
1715 parameter: "l_range",
1716 ..
1717 }
1718 ),
1719 "Expected InvalidParameter(l_range), got: {err:?}"
1720 );
1721 }
1722
1723 #[test]
1724 fn test_select_determinism() {
1725 let (data, argvals, _, _) = make_block_data(16, 10, 12345);
1726 let config = CoClusterConfig {
1727 ncomp: 3,
1728 n_init: 2,
1729 seed: 99,
1730 ..Default::default()
1731 };
1732
1733 let r1 = co_cluster_select(&data, &argvals, &[2, 3], &[2, 3], &config).unwrap();
1734 let r2 = co_cluster_select(&data, &argvals, &[2, 3], &[2, 3], &config).unwrap();
1735
1736 assert_eq!(r1.best_k, r2.best_k, "best_k differs across runs");
1737 assert_eq!(r1.best_l, r2.best_l, "best_l differs across runs");
1738 assert_eq!(
1739 r1.grid_scores.len(),
1740 r2.grid_scores.len(),
1741 "grid_scores.len() differs"
1742 );
1743 for (a, b) in r1.grid_scores.iter().zip(r2.grid_scores.iter()) {
1744 assert_eq!(a.0, b.0, "K differs in grid_scores");
1745 assert_eq!(a.1, b.1, "L differs in grid_scores");
1746 assert_eq!(a.2, b.2, "log_lik differs in grid_scores");
1747 assert_eq!(a.3, b.3, "model_dim differs in grid_scores");
1748 assert_eq!(a.4, b.4, "penalised_score differs in grid_scores");
1749 }
1750 }
1751
1752 #[test]
1753 fn test_result_surface_populated() {
1754 let n = 10;
1755 let m = 8;
1756 let (data, argvals, _, _) = make_block_data(n, m, 555);
1757 let config = CoClusterConfig {
1758 n_row_blocks: 2,
1759 n_col_blocks: 2,
1760 ncomp: 3,
1761 n_init: 1,
1762 ..Default::default()
1763 };
1764 let result = co_cluster(&data, &argvals, &config).unwrap();
1765
1766 assert_eq!(result.row_labels.len(), n, "row_labels.len() != n");
1767 assert_eq!(result.col_labels.len(), m, "col_labels.len() != m");
1768 assert_eq!(
1769 result.block_params.len(),
1770 result.n_row_blocks * result.n_col_blocks,
1771 "block_params.len() != K*L"
1772 );
1773 assert_eq!(result.row_props.len(), result.n_row_blocks);
1774 assert_eq!(result.col_props.len(), result.n_col_blocks);
1775
1776 // All block_params have consistent lengths
1777 for bp in &result.block_params {
1778 assert!(!bp.mean.is_empty(), "block_param.mean is empty");
1779 assert_eq!(
1780 bp.mean.len(),
1781 bp.variance.len(),
1782 "mean/variance length mismatch"
1783 );
1784 }
1785 }
1786}