1use std::f64::consts::PI;
42
43use rand::prelude::*;
44
45use crate::error::FdarError;
46use crate::matrix::FdMatrix;
47use crate::regression::fdata_to_pc_1d;
48
49#[derive(Debug, Clone, PartialEq)]
56#[non_exhaustive]
57#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
58pub struct BlockParams {
59 pub mean: Vec<f64>,
61 pub variance: Vec<f64>,
63}
64
65#[derive(Debug, Clone, PartialEq)]
81#[non_exhaustive]
82#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
83pub struct CoClusterResult {
84 pub row_labels: Vec<usize>,
87
88 pub col_labels: Vec<usize>,
93
94 pub n_row_blocks: usize,
96
97 pub n_col_blocks: usize,
99
100 pub block_params: Vec<BlockParams>,
104
105 pub row_props: Vec<f64>,
107
108 pub col_props: Vec<f64>,
110
111 pub log_likelihood: f64,
113
114 pub icl: f64,
119
120 pub iterations: usize,
122
123 pub converged: bool,
125}
126
127#[derive(Debug, Clone, PartialEq)]
143#[non_exhaustive]
144#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
145pub struct CoClusterConfig {
146 pub n_row_blocks: usize,
148 pub n_col_blocks: usize,
150 pub ncomp: usize,
153 pub max_iter: usize,
155 pub tol: f64,
157 pub n_init: usize,
159 pub seed: u64,
161}
162
163impl Default for CoClusterConfig {
164 fn default() -> Self {
165 Self {
166 n_row_blocks: 2,
167 n_col_blocks: 2,
168 ncomp: 5,
169 max_iter: 200,
170 tol: 1e-6,
171 n_init: 3,
172 seed: 42,
173 }
174 }
175}
176
177#[inline]
185fn log_gaussian_1d(x: f64, mu: f64, var: f64) -> f64 {
186 if var <= 0.0 {
187 return f64::NEG_INFINITY;
188 }
189 -0.5 * ((x - mu).powi(2) / var + var.ln() + (2.0 * PI).ln())
190}
191
192fn build_block_scores(
197 data: &FdMatrix,
198 rotation: &FdMatrix,
199 mean: &[f64],
200 weights: &[f64],
201 col_labels: &[usize],
202 n: usize,
203 m: usize,
204 l_blocks: usize,
205 eff_ncomp: usize,
206) -> Vec<f64> {
207 let total = n * l_blocks * eff_ncomp;
208 let mut buf = vec![0.0_f64; total];
209
210 for j in 0..m {
212 let l = col_labels[j];
213 let w = weights[j];
214 let mean_j = mean[j];
215 for i in 0..n {
217 let val = data[(i, j)] - mean_j;
218 let base = (i * l_blocks + l) * eff_ncomp;
219 for k in 0..eff_ncomp {
220 buf[base + k] += w * val * rotation[(j, k)];
222 }
223 }
224 }
225
226 buf
227}
228
229fn block_score_reg(block_scores: &[f64], n: usize, l_blocks: usize, eff_ncomp: usize) -> f64 {
231 const REG_REL: f64 = 1e-6;
232 if n == 0 || l_blocks == 0 || eff_ncomp == 0 {
233 return REG_REL;
234 }
235 let total_blocks = l_blocks * eff_ncomp;
236 let mut total_var = 0.0_f64;
237 let mut n_dims = 0u64;
238 for l in 0..l_blocks {
239 for comp in 0..eff_ncomp {
240 let mut sum = 0.0_f64;
242 let mut ss = 0.0_f64;
243 for i in 0..n {
244 let v = block_scores[(i * l_blocks + l) * eff_ncomp + comp];
245 sum += v;
246 ss += v * v;
247 }
248 let mean = sum / n as f64;
249 let var = ss / n as f64 - mean * mean;
250 total_var += var;
251 n_dims += 1;
252 }
253 }
254 let _ = total_blocks; let mean_var = if n_dims > 0 {
256 total_var / n_dims as f64
257 } else {
258 0.0
259 };
260 if mean_var > 0.0 {
261 REG_REL * mean_var
262 } else {
263 REG_REL
264 }
265}
266
267fn m_step(
269 block_scores: &[f64],
270 row_labels: &[usize],
271 col_labels: &[usize],
272 n: usize,
273 m: usize,
274 k_blocks: usize,
275 l_blocks: usize,
276 eff_ncomp: usize,
277 reg: f64,
278) -> (Vec<f64>, Vec<f64>, Vec<BlockParams>) {
279 let mut row_counts = vec![0usize; k_blocks];
281 for &r in row_labels {
282 row_counts[r] += 1;
283 }
284 let row_props: Vec<f64> = row_counts.iter().map(|&c| c as f64 / n as f64).collect();
285
286 let mut col_counts = vec![0usize; l_blocks];
288 for &c in col_labels {
289 col_counts[c] += 1;
290 }
291 let col_props: Vec<f64> = col_counts.iter().map(|&c| c as f64 / m as f64).collect();
292
293 let mut block_params = Vec::with_capacity(k_blocks * l_blocks);
295 for k in 0..k_blocks {
296 for l in 0..l_blocks {
297 let mut mean = vec![0.0_f64; eff_ncomp];
298 let mut var = vec![0.0_f64; eff_ncomp];
299 let mut cnt = 0u64;
300
301 for i in 0..n {
302 if row_labels[i] != k {
303 continue;
304 }
305 cnt += 1;
306 let base = (i * l_blocks + l) * eff_ncomp;
307 for comp in 0..eff_ncomp {
308 mean[comp] += block_scores[base + comp];
309 }
310 }
311
312 if cnt > 0 {
313 let nf = cnt as f64;
314 for comp in 0..eff_ncomp {
315 mean[comp] /= nf;
316 }
317 for i in 0..n {
319 if row_labels[i] != k {
320 continue;
321 }
322 let base = (i * l_blocks + l) * eff_ncomp;
323 for comp in 0..eff_ncomp {
324 let d = block_scores[base + comp] - mean[comp];
325 var[comp] += d * d;
326 }
327 }
328 for comp in 0..eff_ncomp {
329 var[comp] = var[comp] / nf + reg;
330 }
331 } else {
332 for comp in 0..eff_ncomp {
334 var[comp] = reg;
335 }
336 }
337
338 block_params.push(BlockParams {
339 mean,
340 variance: var,
341 });
342 }
343 }
344
345 (row_props, col_props, block_params)
346}
347
348fn classification_log_likelihood(
350 block_scores: &[f64],
351 row_labels: &[usize],
352 _col_labels: &[usize],
353 row_props: &[f64],
354 col_props: &[f64],
355 block_params: &[BlockParams],
356 n: usize,
357 _m: usize,
358 _k_blocks: usize,
359 l_blocks: usize,
360 eff_ncomp: usize,
361) -> f64 {
362 let mut ll = 0.0_f64;
363
364 for i in 0..n {
365 let k = row_labels[i];
366 let rp = row_props[k];
367 if rp < 1e-15 {
368 continue;
369 }
370 ll += rp.ln();
371
372 for l in 0..l_blocks {
374 let cp = col_props[l];
375 if cp < 1e-15 {
376 continue;
377 }
378 let bp = &block_params[k * l_blocks + l];
379 let base = (i * l_blocks + l) * eff_ncomp;
380 let mut block_ld = 0.0_f64;
381 for comp in 0..eff_ncomp {
382 block_ld +=
383 log_gaussian_1d(block_scores[base + comp], bp.mean[comp], bp.variance[comp]);
384 }
385 ll += cp.ln() + block_ld;
386 }
387 }
388
389 ll
390}
391
392fn e_row_step(
394 block_scores: &[f64],
395 row_props: &[f64],
396 col_props: &[f64],
397 block_params: &[BlockParams],
398 n: usize,
399 k_blocks: usize,
400 l_blocks: usize,
401 eff_ncomp: usize,
402) -> Vec<usize> {
403 let mut row_labels = vec![0usize; n];
404 for i in 0..n {
405 let mut best_k = 0usize;
406 let mut best_score = f64::NEG_INFINITY;
407 for k in 0..k_blocks {
408 let rp = row_props[k];
409 if rp < 1e-15 {
410 continue;
411 }
412 let mut score = rp.ln();
413 for l in 0..l_blocks {
414 let cp = col_props[l];
415 if cp < 1e-15 {
416 continue;
417 }
418 let bp = &block_params[k * l_blocks + l];
419 let base = (i * l_blocks + l) * eff_ncomp;
420 let mut block_ld = 0.0_f64;
421 for comp in 0..eff_ncomp {
422 block_ld += log_gaussian_1d(
423 block_scores[base + comp],
424 bp.mean[comp],
425 bp.variance[comp],
426 );
427 }
428 score += cp.ln() + block_ld;
429 }
430 if score > best_score {
431 best_score = score;
432 best_k = k;
433 }
434 }
435 row_labels[i] = best_k;
436 }
437 row_labels
438}
439
440fn e_col_step(
458 data: &FdMatrix,
459 rotation: &FdMatrix,
460 mean: &[f64],
461 weights: &[f64],
462 col_labels: &[usize],
463 row_labels: &[usize],
464 row_props: &[f64],
465 col_props: &[f64],
466 block_params: &[BlockParams],
467 n: usize,
468 m: usize,
469 _k_blocks: usize,
470 l_blocks: usize,
471 eff_ncomp: usize,
472) -> Vec<usize> {
473 let mut new_col_labels = col_labels.to_vec();
474
475 for j in 0..m {
479 let w_j = weights[j];
480 let mean_j = mean[j];
481
482 let mut s = vec![0.0_f64; n * eff_ncomp];
485 for i in 0..n {
486 let val = w_j * (data[(i, j)] - mean_j);
487 for comp in 0..eff_ncomp {
488 s[i * eff_ncomp + comp] = val * rotation[(j, comp)];
489 }
490 }
491
492 let mut best_l = 0usize;
493 let mut best_gain = f64::NEG_INFINITY;
494
495 for l_cand in 0..l_blocks {
496 let cp = col_props[l_cand];
497 if cp < 1e-15 {
498 continue;
499 }
500 let l_curr = col_labels[j];
504 let mut gain = 0.0_f64;
505
506 for i in 0..n {
507 let k = row_labels[i];
508 let rp = row_props[k];
509 if rp < 1e-15 {
510 continue;
511 }
512
513 let bp_cand = &block_params[k * l_blocks + l_cand];
514
515 let mut ld_cand_new = 0.0_f64;
520 for comp in 0..eff_ncomp {
521 ld_cand_new += log_gaussian_1d(
522 s[i * eff_ncomp + comp],
523 bp_cand.mean[comp],
524 bp_cand.variance[comp],
525 );
526 }
527 gain += cp.ln() + ld_cand_new;
528
529 if l_curr != l_cand {
531 let bp_curr = &block_params[k * l_blocks + l_curr];
532 let mut ld_curr = 0.0_f64;
533 for comp in 0..eff_ncomp {
534 ld_curr += log_gaussian_1d(
535 s[i * eff_ncomp + comp],
536 bp_curr.mean[comp],
537 bp_curr.variance[comp],
538 );
539 }
540 let cp_curr = col_props[l_curr];
541 if cp_curr >= 1e-15 {
542 gain -= cp_curr.ln() + ld_curr;
543 }
544 }
545 }
546
547 if gain > best_gain {
548 best_gain = gain;
549 best_l = l_cand;
550 }
551 }
552
553 new_col_labels[j] = best_l;
554 }
555
556 new_col_labels
557}
558
559fn col_kmeans_init(data: &FdMatrix, n: usize, m: usize, l_blocks: usize, seed: u64) -> Vec<usize> {
561 if l_blocks >= m {
562 return (0..m).map(|j| j % l_blocks).collect();
564 }
565
566 let mut rng = StdRng::seed_from_u64(seed);
567
568 let profile_l2sq = |j1: usize, j2: usize| -> f64 {
571 let c1 = data.column(j1);
572 let c2 = data.column(j2);
573 c1.iter().zip(c2.iter()).map(|(a, b)| (a - b).powi(2)).sum()
574 };
575
576 let first = rng.gen_range(0..m);
578 let mut centers: Vec<usize> = vec![first];
579
580 for _ in 1..l_blocks {
581 let dists: Vec<f64> = (0..m)
583 .map(|j| {
584 centers
585 .iter()
586 .map(|&c| profile_l2sq(j, c))
587 .fold(f64::INFINITY, f64::min)
588 })
589 .collect();
590 let total: f64 = dists.iter().sum();
591 if total < 1e-15 {
592 centers.push(centers.len() % m);
594 continue;
595 }
596 let threshold = rng.gen::<f64>() * total;
598 let mut cum = 0.0;
599 let mut next = m - 1;
600 for (j, &d) in dists.iter().enumerate() {
601 cum += d;
602 if cum >= threshold {
603 next = j;
604 break;
605 }
606 }
607 centers.push(next);
608 }
609
610 let mut col_labels: Vec<usize> = (0..m)
612 .map(|j| {
613 centers
614 .iter()
615 .enumerate()
616 .map(|(ci, &c)| (ci, profile_l2sq(j, c)))
617 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
618 .map(|(ci, _)| ci)
619 .unwrap_or(0)
620 })
621 .collect();
622
623 for _ in 0..10 {
624 let mut cent = vec![0.0_f64; n * l_blocks];
627 let mut cnt = vec![0u64; l_blocks];
628 for j in 0..m {
629 let l = col_labels[j];
630 cnt[l] += 1;
631 let col = data.column(j);
632 for i in 0..n {
633 cent[l * n + i] += col[i];
634 }
635 }
636 for l in 0..l_blocks {
637 if cnt[l] > 0 {
638 let c = cnt[l] as f64;
639 for i in 0..n {
640 cent[l * n + i] /= c;
641 }
642 }
643 }
644
645 let mut changed = false;
647 for j in 0..m {
648 let col = data.column(j);
649 let mut best_l = 0usize;
650 let mut best_d = f64::INFINITY;
651 for l in 0..l_blocks {
652 let d: f64 = (0..n).map(|i| (col[i] - cent[l * n + i]).powi(2)).sum();
653 if d < best_d {
654 best_d = d;
655 best_l = l;
656 }
657 }
658 if col_labels[j] != best_l {
659 changed = true;
660 col_labels[j] = best_l;
661 }
662 }
663
664 if !changed {
665 break;
666 }
667 }
668
669 col_labels
670}
671
672#[allow(clippy::too_many_arguments)]
674fn cem_single_fit(
675 data: &FdMatrix,
676 rotation: &FdMatrix,
677 mean: &[f64],
678 weights: &[f64],
679 init_row_labels: Vec<usize>,
680 init_col_labels: Vec<usize>,
681 n: usize,
682 m: usize,
683 k_blocks: usize,
684 l_blocks: usize,
685 eff_ncomp: usize,
686 max_iter: usize,
687 tol: f64,
688) -> (CoClusterResult, Vec<f64>) {
689 let mut row_labels = init_row_labels;
690 let mut col_labels = init_col_labels;
691
692 let mut block_scores = build_block_scores(
694 data,
695 rotation,
696 mean,
697 weights,
698 &col_labels,
699 n,
700 m,
701 l_blocks,
702 eff_ncomp,
703 );
704
705 let reg = block_score_reg(&block_scores, n, l_blocks, eff_ncomp);
706
707 let (mut row_props, mut col_props, mut block_params) = m_step(
709 &block_scores,
710 &row_labels,
711 &col_labels,
712 n,
713 m,
714 k_blocks,
715 l_blocks,
716 eff_ncomp,
717 reg,
718 );
719
720 let mut prev_ll = f64::NEG_INFINITY;
721 let mut per_iter_ll: Vec<f64> = Vec::with_capacity(max_iter);
722 let mut iterations = 0usize;
723 let mut converged = false;
724
725 for iter in 0..max_iter {
726 row_labels = e_row_step(
728 &block_scores,
729 &row_props,
730 &col_props,
731 &block_params,
732 n,
733 k_blocks,
734 l_blocks,
735 eff_ncomp,
736 );
737
738 col_labels = e_col_step(
740 data,
741 rotation,
742 mean,
743 weights,
744 &col_labels,
745 &row_labels,
746 &row_props,
747 &col_props,
748 &block_params,
749 n,
750 m,
751 k_blocks,
752 l_blocks,
753 eff_ncomp,
754 );
755
756 block_scores = build_block_scores(
758 data,
759 rotation,
760 mean,
761 weights,
762 &col_labels,
763 n,
764 m,
765 l_blocks,
766 eff_ncomp,
767 );
768
769 let (rp, cp, bp) = m_step(
771 &block_scores,
772 &row_labels,
773 &col_labels,
774 n,
775 m,
776 k_blocks,
777 l_blocks,
778 eff_ncomp,
779 reg,
780 );
781 row_props = rp;
782 col_props = cp;
783 block_params = bp;
784
785 let ll = classification_log_likelihood(
787 &block_scores,
788 &row_labels,
789 &col_labels,
790 &row_props,
791 &col_props,
792 &block_params,
793 n,
794 m,
795 k_blocks,
796 l_blocks,
797 eff_ncomp,
798 );
799
800 per_iter_ll.push(ll);
801 iterations = iter + 1;
802
803 if iter > 0 && (ll - prev_ll).abs() < tol {
805 converged = true;
806 break;
807 }
808 prev_ll = ll;
809 }
810
811 let log_likelihood = per_iter_ll.last().copied().unwrap_or(f64::NEG_INFINITY);
812
813 let p_kl = (k_blocks.saturating_sub(1))
815 + (l_blocks.saturating_sub(1))
816 + 2 * k_blocks * l_blocks * eff_ncomp;
817 let icl = log_likelihood - 0.5 * (p_kl as f64) * ((n as f64).ln() + (m as f64).ln());
818
819 let result = CoClusterResult {
820 row_labels,
821 col_labels,
822 n_row_blocks: k_blocks,
823 n_col_blocks: l_blocks,
824 block_params,
825 row_props,
826 col_props,
827 log_likelihood,
828 icl,
829 iterations,
830 converged,
831 };
832
833 (result, per_iter_ll)
834}
835
836#[must_use = "expensive computation whose result should not be discarded"]
874pub fn co_cluster(
875 data: &FdMatrix,
876 argvals: &[f64],
877 config: &CoClusterConfig,
878) -> Result<CoClusterResult, FdarError> {
879 let (n, m) = data.shape();
880
881 if config.ncomp < 1 {
883 return Err(FdarError::InvalidParameter {
884 parameter: "ncomp",
885 message: format!("ncomp must be >= 1, got {}", config.ncomp),
886 });
887 }
888 if config.n_row_blocks > n {
889 return Err(FdarError::InvalidParameter {
890 parameter: "n_row_blocks",
891 message: format!(
892 "n_row_blocks={} exceeds number of observations n={}",
893 config.n_row_blocks, n
894 ),
895 });
896 }
897 if config.n_row_blocks == 0 {
898 return Err(FdarError::InvalidParameter {
899 parameter: "n_row_blocks",
900 message: "n_row_blocks must be >= 1".to_string(),
901 });
902 }
903 if config.n_col_blocks > m {
904 return Err(FdarError::InvalidParameter {
905 parameter: "n_col_blocks",
906 message: format!(
907 "n_col_blocks={} exceeds number of argument points m={}",
908 config.n_col_blocks, m
909 ),
910 });
911 }
912 if config.n_col_blocks == 0 {
913 return Err(FdarError::InvalidParameter {
914 parameter: "n_col_blocks",
915 message: "n_col_blocks must be >= 1".to_string(),
916 });
917 }
918
919 let k_blocks = config.n_row_blocks;
920 let l_blocks = config.n_col_blocks;
921
922 let fpca = fdata_to_pc_1d(data, config.ncomp, argvals)?;
925 let eff_ncomp = fpca.scores.ncols();
927 let rotation = &fpca.rotation; let mean = &fpca.mean; let weights = &fpca.weights; let n_init = config.n_init.max(1);
933 let mut best: Option<CoClusterResult> = None;
934
935 for init in 0..n_init {
936 let seed = config.seed.wrapping_add(init as u64 * 1000);
937
938 use crate::clustering::kmeans_fd;
940 let km = kmeans_fd(data, argvals, k_blocks, 100, 1e-4, seed)?;
941 let init_row_labels = km.cluster;
942
943 let init_col_labels = col_kmeans_init(data, n, m, l_blocks, seed.wrapping_add(1));
945
946 let (result, _per_iter_ll) = cem_single_fit(
947 data,
948 rotation,
949 mean,
950 weights,
951 init_row_labels,
952 init_col_labels,
953 n,
954 m,
955 k_blocks,
956 l_blocks,
957 eff_ncomp,
958 config.max_iter,
959 config.tol,
960 );
961
962 let is_better = best
963 .as_ref()
964 .map_or(true, |b| result.log_likelihood > b.log_likelihood);
965 if is_better {
966 best = Some(result);
967 }
968 }
969
970 best.ok_or_else(|| FdarError::ComputationFailed {
971 operation: "co_cluster",
972 detail: "all initializations failed".to_string(),
973 })
974}
975
976#[derive(Debug, Clone)]
1006#[non_exhaustive]
1007#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1008pub struct CoClusterSelectResult {
1009 pub best: CoClusterResult,
1011 pub best_k: usize,
1013 pub best_l: usize,
1015 pub grid_scores: Vec<(usize, usize, f64, usize, f64)>,
1020 pub slope_estimate: f64,
1023 pub penalty_rate: f64,
1026}
1027
1028#[must_use = "expensive grid sweep whose result should not be discarded"]
1089pub fn co_cluster_select(
1090 data: &FdMatrix,
1091 argvals: &[f64],
1092 k_range: &[usize],
1093 l_range: &[usize],
1094 config: &CoClusterConfig,
1095) -> Result<CoClusterSelectResult, FdarError> {
1096 if k_range.is_empty() {
1098 return Err(FdarError::InvalidParameter {
1099 parameter: "k_range",
1100 message: "k_range must be non-empty".to_string(),
1101 });
1102 }
1103 if l_range.is_empty() {
1104 return Err(FdarError::InvalidParameter {
1105 parameter: "l_range",
1106 message: "l_range must be non-empty".to_string(),
1107 });
1108 }
1109
1110 let grid: Vec<(usize, usize)> = k_range
1112 .iter()
1113 .flat_map(|&k| l_range.iter().map(move |&l| (k, l)))
1114 .collect();
1115
1116 let mut cell_results: Vec<(usize, usize, CoClusterResult)> = Vec::with_capacity(grid.len());
1120 for &(k, l) in &grid {
1121 let mut cell_cfg = config.clone();
1122 cell_cfg.n_row_blocks = k;
1123 cell_cfg.n_col_blocks = l;
1124 let result = co_cluster(data, argvals, &cell_cfg)?;
1125 cell_results.push((k, l, result));
1126 }
1127
1128 struct CellInfo {
1132 k: usize,
1133 l: usize,
1134 ll: f64,
1135 dim: usize,
1136 result_idx: usize,
1137 }
1138
1139 let infos: Vec<CellInfo> = cell_results
1140 .iter()
1141 .enumerate()
1142 .map(|(idx, (k, l, res))| {
1143 let eff_ncomp = if res.block_params.is_empty() {
1144 0
1145 } else {
1146 res.block_params[0].mean.len()
1147 };
1148 let dim = k.saturating_sub(1) + l.saturating_sub(1) + 2 * k * l * eff_ncomp;
1149 CellInfo {
1150 k: *k,
1151 l: *l,
1152 ll: res.log_likelihood,
1153 dim,
1154 result_idx: idx,
1155 }
1156 })
1157 .collect();
1158
1159 let n_grid = infos.len();
1162
1163 let (slope_estimate, penalty_rate) = if n_grid < 4 {
1164 (0.0_f64, 0.0_f64)
1166 } else {
1167 let mut sorted_by_dim: Vec<usize> = (0..n_grid).collect();
1169 sorted_by_dim.sort_by(|&a, &b| infos[b].dim.cmp(&infos[a].dim));
1170
1171 let n_top = (n_grid / 2).max(4).min(n_grid);
1172 let top_idxs = &sorted_by_dim[..n_top];
1173
1174 let d_mean: f64 = top_idxs.iter().map(|&i| infos[i].dim as f64).sum::<f64>() / n_top as f64;
1176 let l_mean: f64 = top_idxs.iter().map(|&i| infos[i].ll).sum::<f64>() / n_top as f64;
1177
1178 let numerator: f64 = top_idxs
1179 .iter()
1180 .map(|&i| (infos[i].dim as f64 - d_mean) * (infos[i].ll - l_mean))
1181 .sum();
1182 let denominator: f64 = top_idxs
1183 .iter()
1184 .map(|&i| (infos[i].dim as f64 - d_mean).powi(2))
1185 .sum();
1186
1187 if denominator.abs() < 1e-10 {
1188 (0.0_f64, 0.0_f64)
1190 } else {
1191 let slope = numerator / denominator;
1192 let pen = 2.0 * slope.abs();
1193 if pen <= 0.0 {
1194 (slope, 0.0_f64)
1195 } else {
1196 (slope, pen)
1197 }
1198 }
1199 };
1200
1201 let penalised: Vec<f64> = infos
1204 .iter()
1205 .map(|ci| {
1206 if penalty_rate > 0.0 {
1207 ci.ll - penalty_rate * ci.dim as f64
1208 } else {
1209 ci.ll
1210 }
1211 })
1212 .collect();
1213
1214 let best_pos = penalised
1216 .iter()
1217 .enumerate()
1218 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Less))
1219 .map(|(i, _)| i)
1220 .unwrap_or(0);
1221
1222 let best_k = infos[best_pos].k;
1223 let best_l = infos[best_pos].l;
1224 let best_result_idx = infos[best_pos].result_idx;
1225
1226 let grid_scores: Vec<(usize, usize, f64, usize, f64)> = infos
1228 .iter()
1229 .enumerate()
1230 .map(|(pos, ci)| (ci.k, ci.l, ci.ll, ci.dim, penalised[pos]))
1231 .collect();
1232
1233 let best = cell_results.remove(best_result_idx).2;
1234
1235 Ok(CoClusterSelectResult {
1236 best,
1237 best_k,
1238 best_l,
1239 grid_scores,
1240 slope_estimate,
1241 penalty_rate,
1242 })
1243}
1244
1245#[cfg(test)]
1250mod tests {
1251 use super::*;
1252 use crate::test_helpers::{adjusted_rand_index, uniform_grid};
1253
1254 fn make_block_data(
1261 n: usize,
1262 m: usize,
1263 seed: u64,
1264 ) -> (FdMatrix, Vec<f64>, Vec<usize>, Vec<usize>) {
1265 use rand::prelude::*;
1266 use rand_distr::Normal;
1267
1268 let argvals = uniform_grid(m);
1269 let mut rng = StdRng::seed_from_u64(seed);
1270 let noise_dist = Normal::new(0.0_f64, 0.1).unwrap();
1271
1272 let m_half = m / 2;
1273
1274 let mut data = FdMatrix::zeros(n, m);
1275 let mut true_row_labels = vec![0usize; n];
1276 let mut true_col_labels = vec![0usize; m];
1277
1278 for j in m_half..m {
1280 true_col_labels[j] = 1;
1281 }
1282
1283 for i in 0..n {
1285 let row_group = if i < n / 2 { 0 } else { 1 };
1286 true_row_labels[i] = row_group;
1287
1288 let signal = if row_group == 0 { 5.0_f64 } else { -5.0_f64 };
1289
1290 for j in 0..m {
1291 let noise: f64 = rng.sample(noise_dist);
1292 let base = if j < m_half { signal } else { 0.0 };
1294 data[(i, j)] = base + noise;
1295 }
1296 }
1297
1298 (data, argvals, true_row_labels, true_col_labels)
1299 }
1300
1301 fn run_single_cem_with_ll(
1303 data: &FdMatrix,
1304 argvals: &[f64],
1305 k: usize,
1306 l: usize,
1307 ncomp: usize,
1308 seed: u64,
1309 ) -> (CoClusterResult, Vec<f64>) {
1310 let (n, m) = data.shape();
1311 let fpca = fdata_to_pc_1d(data, ncomp, argvals).unwrap();
1312 let eff_ncomp = fpca.scores.ncols();
1313
1314 use crate::clustering::kmeans_fd;
1315 let km = kmeans_fd(data, argvals, k, 100, 1e-4, seed).unwrap();
1316 let init_row = km.cluster;
1317 let init_col = col_kmeans_init(data, n, m, l, seed.wrapping_add(1));
1318
1319 cem_single_fit(
1320 data,
1321 &fpca.rotation,
1322 &fpca.mean,
1323 &fpca.weights,
1324 init_row,
1325 init_col,
1326 n,
1327 m,
1328 k,
1329 l,
1330 eff_ncomp,
1331 200,
1332 1e-6,
1333 )
1334 }
1335
1336 #[test]
1341 fn test_co_cluster_smoke() {
1342 let n = 8;
1343 let m = 6;
1344 let argvals = uniform_grid(m);
1345 let data = FdMatrix::zeros(n, m);
1346 let config = CoClusterConfig {
1347 n_row_blocks: 2,
1348 n_col_blocks: 2,
1349 ncomp: 3,
1350 n_init: 1,
1351 ..Default::default()
1352 };
1353 let result = co_cluster(&data, &argvals, &config).unwrap();
1354 assert_eq!(result.row_labels.len(), n);
1355 assert_eq!(result.col_labels.len(), m);
1356 assert_eq!(result.block_params.len(), 4);
1357 assert!(result.log_likelihood.is_finite() || result.log_likelihood == f64::NEG_INFINITY);
1361 }
1362
1363 #[test]
1368 fn test_classification_ll_nondecreasing() {
1369 let (data, argvals, _, _) = make_block_data(16, 10, 7777);
1370 let (_result, per_iter_ll) = run_single_cem_with_ll(&data, &argvals, 2, 2, 3, 42);
1371
1372 for w in per_iter_ll.windows(2) {
1375 assert!(
1376 w[1] >= w[0] - 1e-6,
1377 "LL decreased: iter[i]={:.6} -> iter[i+1]={:.6}",
1378 w[0],
1379 w[1]
1380 );
1381 }
1382 }
1383
1384 #[test]
1385 fn test_coclustering_recovers_block_structure() {
1386 let (data, argvals, true_row, true_col) = make_block_data(20, 12, 1234);
1387 let config = CoClusterConfig {
1388 n_row_blocks: 2,
1389 n_col_blocks: 2,
1390 ncomp: 3,
1391 n_init: 3,
1392 seed: 42,
1393 ..Default::default()
1394 };
1395 let result = co_cluster(&data, &argvals, &config).unwrap();
1396
1397 let ari_row = adjusted_rand_index(&true_row, &result.row_labels);
1398 let ari_col = adjusted_rand_index(&true_col, &result.col_labels);
1399
1400 assert!(
1401 ari_row > 0.8,
1402 "Row ARI too low: {ari_row:.3} (expected > 0.8)"
1403 );
1404 assert!(
1405 ari_col > 0.8,
1406 "Col ARI too low: {ari_col:.3} (expected > 0.8)"
1407 );
1408 }
1409
1410 #[test]
1411 fn test_determinism_under_seed() {
1412 let (data, argvals, _, _) = make_block_data(16, 10, 999);
1413 let config = CoClusterConfig {
1414 n_row_blocks: 2,
1415 n_col_blocks: 2,
1416 ncomp: 3,
1417 n_init: 2,
1418 seed: 77,
1419 ..Default::default()
1420 };
1421
1422 let r1 = co_cluster(&data, &argvals, &config).unwrap();
1423 let r2 = co_cluster(&data, &argvals, &config).unwrap();
1424
1425 assert_eq!(
1426 r1.row_labels, r2.row_labels,
1427 "row_labels differ across runs"
1428 );
1429 assert_eq!(
1430 r1.col_labels, r2.col_labels,
1431 "col_labels differ across runs"
1432 );
1433 assert_eq!(
1434 r1.log_likelihood, r2.log_likelihood,
1435 "log_likelihood differs"
1436 );
1437 assert_eq!(r1.icl, r2.icl, "ICL differs");
1438 }
1439
1440 #[test]
1441 fn test_icl_is_finite() {
1442 let (data, argvals, _, _) = make_block_data(16, 10, 42);
1443 let config = CoClusterConfig {
1444 n_row_blocks: 2,
1445 n_col_blocks: 2,
1446 ncomp: 3,
1447 n_init: 1,
1448 ..Default::default()
1449 };
1450 let result = co_cluster(&data, &argvals, &config).unwrap();
1451 assert!(result.icl.is_finite(), "ICL is not finite: {}", result.icl);
1452 }
1453
1454 #[test]
1459 fn test_error_k_exceeds_n() {
1460 let n = 8;
1461 let m = 6;
1462 let data = FdMatrix::zeros(n, m);
1463 let argvals = uniform_grid(m);
1464 let config = CoClusterConfig {
1465 n_row_blocks: 99,
1466 n_col_blocks: 2,
1467 ncomp: 3,
1468 ..Default::default()
1469 };
1470 let err = co_cluster(&data, &argvals, &config).unwrap_err();
1471 assert!(
1472 matches!(
1473 err,
1474 FdarError::InvalidParameter {
1475 parameter: "n_row_blocks",
1476 ..
1477 }
1478 ),
1479 "Expected InvalidParameter(n_row_blocks), got: {err:?}"
1480 );
1481 }
1482
1483 #[test]
1484 fn test_error_l_exceeds_m() {
1485 let n = 8;
1486 let m = 6;
1487 let data = FdMatrix::zeros(n, m);
1488 let argvals = uniform_grid(m);
1489 let config = CoClusterConfig {
1490 n_row_blocks: 2,
1491 n_col_blocks: 99,
1492 ncomp: 3,
1493 ..Default::default()
1494 };
1495 let err = co_cluster(&data, &argvals, &config).unwrap_err();
1496 assert!(
1497 matches!(
1498 err,
1499 FdarError::InvalidParameter {
1500 parameter: "n_col_blocks",
1501 ..
1502 }
1503 ),
1504 "Expected InvalidParameter(n_col_blocks), got: {err:?}"
1505 );
1506 }
1507
1508 #[test]
1509 fn test_error_zero_ncomp() {
1510 let n = 8;
1511 let m = 6;
1512 let data = FdMatrix::zeros(n, m);
1513 let argvals = uniform_grid(m);
1514 let config = CoClusterConfig {
1515 n_row_blocks: 2,
1516 n_col_blocks: 2,
1517 ncomp: 0,
1518 ..Default::default()
1519 };
1520 let err = co_cluster(&data, &argvals, &config).unwrap_err();
1521 assert!(
1522 matches!(
1523 err,
1524 FdarError::InvalidParameter {
1525 parameter: "ncomp",
1526 ..
1527 }
1528 ),
1529 "Expected InvalidParameter(ncomp), got: {err:?}"
1530 );
1531 }
1532
1533 #[test]
1534 fn test_error_argvals_mismatch() {
1535 let n = 8;
1536 let m = 6;
1537 let data = FdMatrix::zeros(n, m);
1538 let argvals = uniform_grid(m + 3); let config = CoClusterConfig {
1540 n_row_blocks: 2,
1541 n_col_blocks: 2,
1542 ncomp: 3,
1543 ..Default::default()
1544 };
1545 let err = co_cluster(&data, &argvals, &config).unwrap_err();
1546 assert!(
1547 matches!(err, FdarError::InvalidDimension { .. }),
1548 "Expected InvalidDimension, got: {err:?}"
1549 );
1550 }
1551
1552 #[test]
1557 fn test_co_cluster_select_smoke() {
1558 let n = 8;
1560 let m = 6;
1561 let argvals = uniform_grid(m);
1562 let data = FdMatrix::zeros(n, m);
1563 let config = CoClusterConfig {
1564 ncomp: 2,
1565 n_init: 1,
1566 ..Default::default()
1567 };
1568 let result = co_cluster_select(&data, &argvals, &[2, 3], &[2], &config).unwrap();
1569 assert_eq!(
1570 result.grid_scores.len(),
1571 2,
1572 "Expected 2 grid cells (K in {{2,3}}, L=2)"
1573 );
1574 assert_eq!(
1575 result.best.row_labels.len(),
1576 n,
1577 "best.row_labels.len() should equal n"
1578 );
1579 assert_eq!(
1580 result.best.col_labels.len(),
1581 m,
1582 "best.col_labels.len() should equal m"
1583 );
1584 }
1585
1586 #[test]
1587 fn test_slope_heuristic_selects_correct_kl() {
1588 let (data, argvals, true_row, _) = make_block_data(24, 12, 2024);
1592 let config = CoClusterConfig {
1593 ncomp: 3,
1594 n_init: 3,
1595 seed: 42,
1596 ..Default::default()
1597 };
1598 let result = co_cluster_select(&data, &argvals, &[2, 3, 4], &[2, 3], &config).unwrap();
1599
1600 assert_eq!(result.grid_scores.len(), 6, "Expected 6 grid cells");
1602
1603 for &(k, l, ll, dim, pen) in &result.grid_scores {
1605 assert!(
1606 ll.is_finite() || ll == f64::NEG_INFINITY,
1607 "grid entry (K={k}, L={l}) has non-finite ll={ll}"
1608 );
1609 let _ = (dim, pen); }
1611
1612 assert_eq!(result.best.row_labels.len(), 24);
1614
1615 let ari = adjusted_rand_index(&true_row, &result.best.row_labels);
1618 assert!(
1619 ari > 0.6,
1620 "Row ARI too low: {ari:.3}. best_k={}, best_l={}",
1621 result.best_k,
1622 result.best_l
1623 );
1624 }
1625
1626 #[test]
1627 fn test_select_single_cell() {
1628 let n = 10;
1630 let m = 8;
1631 let (data, argvals, _, _) = make_block_data(n, m, 42);
1632 let config = CoClusterConfig {
1633 ncomp: 2,
1634 n_init: 1,
1635 seed: 1,
1636 ..Default::default()
1637 };
1638 let result = co_cluster_select(&data, &argvals, &[2], &[2], &config).unwrap();
1639
1640 assert_eq!(
1641 result.grid_scores.len(),
1642 1,
1643 "Single-cell grid should have 1 entry"
1644 );
1645 assert_eq!(result.best_k, 2);
1646 assert_eq!(result.best_l, 2);
1647 assert_eq!(
1649 result.slope_estimate, 0.0,
1650 "slope_estimate should be 0 for single-cell"
1651 );
1652 assert_eq!(
1653 result.penalty_rate, 0.0,
1654 "penalty_rate should be 0 for single-cell"
1655 );
1656 }
1657
1658 #[test]
1659 fn test_select_empty_range_errors() {
1660 let n = 8;
1661 let m = 6;
1662 let data = FdMatrix::zeros(n, m);
1663 let argvals = uniform_grid(m);
1664 let config = CoClusterConfig::default();
1665
1666 let err = co_cluster_select(&data, &argvals, &[], &[2], &config).unwrap_err();
1668 assert!(
1669 matches!(
1670 err,
1671 FdarError::InvalidParameter {
1672 parameter: "k_range",
1673 ..
1674 }
1675 ),
1676 "Expected InvalidParameter(k_range), got: {err:?}"
1677 );
1678
1679 let err = co_cluster_select(&data, &argvals, &[2], &[], &config).unwrap_err();
1681 assert!(
1682 matches!(
1683 err,
1684 FdarError::InvalidParameter {
1685 parameter: "l_range",
1686 ..
1687 }
1688 ),
1689 "Expected InvalidParameter(l_range), got: {err:?}"
1690 );
1691 }
1692
1693 #[test]
1694 fn test_select_determinism() {
1695 let (data, argvals, _, _) = make_block_data(16, 10, 12345);
1696 let config = CoClusterConfig {
1697 ncomp: 3,
1698 n_init: 2,
1699 seed: 99,
1700 ..Default::default()
1701 };
1702
1703 let r1 = co_cluster_select(&data, &argvals, &[2, 3], &[2, 3], &config).unwrap();
1704 let r2 = co_cluster_select(&data, &argvals, &[2, 3], &[2, 3], &config).unwrap();
1705
1706 assert_eq!(r1.best_k, r2.best_k, "best_k differs across runs");
1707 assert_eq!(r1.best_l, r2.best_l, "best_l differs across runs");
1708 assert_eq!(
1709 r1.grid_scores.len(),
1710 r2.grid_scores.len(),
1711 "grid_scores.len() differs"
1712 );
1713 for (a, b) in r1.grid_scores.iter().zip(r2.grid_scores.iter()) {
1714 assert_eq!(a.0, b.0, "K differs in grid_scores");
1715 assert_eq!(a.1, b.1, "L differs in grid_scores");
1716 assert_eq!(a.2, b.2, "log_lik differs in grid_scores");
1717 assert_eq!(a.3, b.3, "model_dim differs in grid_scores");
1718 assert_eq!(a.4, b.4, "penalised_score differs in grid_scores");
1719 }
1720 }
1721
1722 #[test]
1723 fn test_result_surface_populated() {
1724 let n = 10;
1725 let m = 8;
1726 let (data, argvals, _, _) = make_block_data(n, m, 555);
1727 let config = CoClusterConfig {
1728 n_row_blocks: 2,
1729 n_col_blocks: 2,
1730 ncomp: 3,
1731 n_init: 1,
1732 ..Default::default()
1733 };
1734 let result = co_cluster(&data, &argvals, &config).unwrap();
1735
1736 assert_eq!(result.row_labels.len(), n, "row_labels.len() != n");
1737 assert_eq!(result.col_labels.len(), m, "col_labels.len() != m");
1738 assert_eq!(
1739 result.block_params.len(),
1740 result.n_row_blocks * result.n_col_blocks,
1741 "block_params.len() != K*L"
1742 );
1743 assert_eq!(result.row_props.len(), result.n_row_blocks);
1744 assert_eq!(result.col_props.len(), result.n_col_blocks);
1745
1746 for bp in &result.block_params {
1748 assert!(!bp.mean.is_empty(), "block_param.mean is empty");
1749 assert_eq!(
1750 bp.mean.len(),
1751 bp.variance.len(),
1752 "mean/variance length mismatch"
1753 );
1754 }
1755 }
1756}