1use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2};
8use scirs2_core::numeric::{Float, FromPrimitive};
9use scirs2_core::random::seq::SliceRandom;
10use scirs2_core::random::{Rng, RngExt, SeedableRng};
11use std::collections::HashSet;
12use std::fmt::Debug;
13
14use crate::error::{ClusteringError, Result};
15use crate::metrics::adjusted_rand_index;
16use crate::vq::kmeans2;
17
18#[derive(Debug, Clone)]
20pub struct StabilityConfig {
21 pub n_bootstrap: usize,
23 pub subsample_ratio: f64,
25 pub random_seed: Option<u64>,
27 pub n_runs_per_bootstrap: usize,
29 pub k_range: Option<(usize, usize)>,
31}
32
33impl Default for StabilityConfig {
34 fn default() -> Self {
35 Self {
36 n_bootstrap: 100,
37 subsample_ratio: 0.8,
38 random_seed: None,
39 n_runs_per_bootstrap: 10,
40 k_range: None,
41 }
42 }
43}
44
45#[derive(Debug, Clone)]
47pub struct StabilityResult<F: Float> {
48 pub stability_scores: Vec<F>,
50 pub consensus_labels: Option<Array1<usize>>,
52 pub optimal_k: Option<usize>,
54 pub mean_stability: F,
56 pub std_stability: F,
58 pub bootstrap_matrix: Array2<F>,
60}
61
62pub struct BootstrapValidator<F: Float> {
68 config: StabilityConfig,
69 phantom: std::marker::PhantomData<F>,
70}
71
72impl<F: Float + FromPrimitive + Debug + 'static + std::iter::Sum + std::fmt::Display>
73 BootstrapValidator<F>
74{
75 pub fn new(config: StabilityConfig) -> Self {
77 Self {
78 config,
79 phantom: std::marker::PhantomData,
80 }
81 }
82
83 pub fn assess_kmeans_stability(
85 &self,
86 data: ArrayView2<F>,
87 k: usize,
88 ) -> Result<StabilityResult<F>> {
89 let n_samples = data.shape()[0];
90 let n_features = data.shape()[1];
91
92 if n_samples < 2 {
93 return Err(ClusteringError::InvalidInput(
94 "Need at least 2 samples for stability assessment".into(),
95 ));
96 }
97
98 let subsample_size = ((n_samples as f64) * self.config.subsample_ratio) as usize;
99 if subsample_size < k {
100 return Err(ClusteringError::InvalidInput(
101 "Subsample size must be at least k".into(),
102 ));
103 }
104
105 let mut rng = match self.config.random_seed {
106 Some(seed) => scirs2_core::random::rngs::StdRng::seed_from_u64(seed),
107 None => {
108 scirs2_core::random::rngs::StdRng::seed_from_u64(42)
110 }
111 };
112
113 let mut bootstrap_results = Vec::new();
114
115 for _iteration in 0..self.config.n_bootstrap {
117 let mut indices: Vec<usize> = (0..n_samples).collect();
119 indices.shuffle(&mut rng);
120 indices.truncate(subsample_size);
121
122 let mut bootstrap_data = Array2::zeros((subsample_size, n_features));
123 for (new_idx, &old_idx) in indices.iter().enumerate() {
124 bootstrap_data.row_mut(new_idx).assign(&data.row(old_idx));
125 }
126
127 let mut run_labels = Vec::new();
129 for _run in 0..self.config.n_runs_per_bootstrap {
130 let seed = rng.random::<u64>();
131
132 match kmeans2(
133 bootstrap_data.view(),
134 k,
135 Some(100), None, None, None, Some(false), Some(seed),
141 ) {
142 Ok((_, labels)) => {
143 let labels_usize: Array1<usize> = labels.mapv(|x| x);
144 run_labels.push(labels_usize);
145 }
146 Err(_) => {
147 let dummy_labels = Array1::zeros(subsample_size);
149 run_labels.push(dummy_labels);
150 }
151 }
152 }
153
154 bootstrap_results.push((indices, run_labels));
155 }
156
157 let stability_scores = self.calculate_stability_scores(&bootstrap_results)?;
159 let mean_stability = stability_scores
160 .iter()
161 .copied()
162 .fold(F::zero(), |acc, x| acc + x)
163 / F::from(stability_scores.len()).expect("Operation failed");
164
165 let variance = stability_scores
166 .iter()
167 .map(|&x| {
168 let diff = x - mean_stability;
169 diff * diff
170 })
171 .fold(F::zero(), |acc, x| acc + x)
172 / F::from(stability_scores.len()).expect("Operation failed");
173 let std_stability = variance.sqrt();
174
175 let bootstrap_matrix = self.create_bootstrap_matrix(&bootstrap_results, n_samples)?;
177
178 Ok(StabilityResult {
179 stability_scores,
180 consensus_labels: None, optimal_k: None,
182 mean_stability,
183 std_stability,
184 bootstrap_matrix,
185 })
186 }
187
188 fn calculate_stability_scores(
190 &self,
191 bootstrap_results: &[(Vec<usize>, Vec<Array1<usize>>)],
192 ) -> Result<Vec<F>> {
193 let mut scores = Vec::new();
194
195 for (_, run_labels) in bootstrap_results {
196 if run_labels.len() < 2 {
197 continue;
198 }
199
200 let mut pairwise_aris = Vec::new();
202 for i in 0..run_labels.len() {
203 for j in (i + 1)..run_labels.len() {
204 let labels1 = run_labels[i].mapv(|x| x as i32);
205 let labels2 = run_labels[j].mapv(|x| x as i32);
206
207 match adjusted_rand_index::<F>(labels1.view(), labels2.view()) {
208 Ok(ari) => pairwise_aris.push(ari),
209 Err(_) => pairwise_aris.push(F::zero()),
210 }
211 }
212 }
213
214 if !pairwise_aris.is_empty() {
215 let mean_ari = pairwise_aris
216 .iter()
217 .copied()
218 .fold(F::zero(), |acc, x| acc + x)
219 / F::from(pairwise_aris.len()).expect("Operation failed");
220 scores.push(mean_ari);
221 }
222 }
223
224 Ok(scores)
225 }
226
227 fn create_bootstrap_matrix(
229 &self,
230 bootstrap_results: &[(Vec<usize>, Vec<Array1<usize>>)],
231 n_samples: usize,
232 ) -> Result<Array2<F>> {
233 let mut co_occurrence_matrix: Array2<F> = Array2::zeros((n_samples, n_samples));
234 let mut count_matrix: Array2<F> = Array2::zeros((n_samples, n_samples));
235
236 for (indices, run_labels) in bootstrap_results {
237 if run_labels.is_empty() {
238 continue;
239 }
240
241 let labels = &run_labels[0];
243
244 for (i, &idx_i) in indices.iter().enumerate() {
246 for (j, &idx_j) in indices.iter().enumerate() {
247 if i != j {
248 count_matrix[[idx_i, idx_j]] = count_matrix[[idx_i, idx_j]] + F::one();
249
250 if labels[i] == labels[j] {
251 co_occurrence_matrix[[idx_i, idx_j]] =
252 co_occurrence_matrix[[idx_i, idx_j]] + F::one();
253 }
254 }
255 }
256 }
257 }
258
259 let mut stability_matrix = Array2::zeros((n_samples, n_samples));
261 for i in 0..n_samples {
262 for j in 0..n_samples {
263 if count_matrix[[i, j]] > F::zero() {
264 stability_matrix[[i, j]] = co_occurrence_matrix[[i, j]] / count_matrix[[i, j]];
265 }
266 }
267 }
268
269 Ok(stability_matrix)
270 }
271}
272
273pub struct ConsensusClusterer<F: Float> {
278 config: StabilityConfig,
279 phantom: std::marker::PhantomData<F>,
280}
281
282impl<F: Float + FromPrimitive + Debug + std::iter::Sum + std::fmt::Display> ConsensusClusterer<F> {
283 pub fn new(config: StabilityConfig) -> Self {
285 Self {
286 config,
287 phantom: std::marker::PhantomData,
288 }
289 }
290
291 pub fn find_consensus_clusters(&self, data: ArrayView2<F>, k: usize) -> Result<Array1<usize>> {
293 let n_samples = data.shape()[0];
294
295 if n_samples < 2 {
296 return Err(ClusteringError::InvalidInput(
297 "Need at least 2 samples for consensus clustering".into(),
298 ));
299 }
300
301 let mut rng = match self.config.random_seed {
302 Some(seed) => scirs2_core::random::rngs::StdRng::seed_from_u64(seed),
303 None => {
304 scirs2_core::random::rngs::StdRng::seed_from_u64(42)
306 }
307 };
308
309 let mut all_labels = Vec::new();
310
311 for _run in 0..self.config.n_bootstrap {
313 let seed = rng.random::<u64>();
314
315 match kmeans2(
316 data,
317 k,
318 Some(100), None, None, None, Some(false), Some(seed),
324 ) {
325 Ok((_, labels)) => {
326 let labels_usize: Array1<usize> = labels.mapv(|x| x);
327 all_labels.push(labels_usize);
328 }
329 Err(_) => {
330 continue;
332 }
333 }
334 }
335
336 if all_labels.is_empty() {
337 return Err(ClusteringError::ComputationError(
338 "All clustering runs failed".into(),
339 ));
340 }
341
342 let mut consensus_matrix = Array2::zeros((n_samples, n_samples));
344
345 for labels in &all_labels {
346 for i in 0..n_samples {
347 for j in 0..n_samples {
348 if labels[i] == labels[j] {
349 consensus_matrix[[i, j]] = consensus_matrix[[i, j]] + F::one();
350 }
351 }
352 }
353 }
354
355 let n_runs = F::from(all_labels.len()).expect("Operation failed");
357 consensus_matrix.mapv_inplace(|x| x / n_runs);
358
359 let threshold = F::from(0.5).expect("Failed to convert constant to float");
361 self.extract_consensus_clusters(&consensus_matrix, threshold, k)
362 }
363
364 fn extract_consensus_clusters(
366 &self,
367 consensus_matrix: &Array2<F>,
368 threshold: F,
369 k: usize,
370 ) -> Result<Array1<usize>> {
371 let n_samples = consensus_matrix.shape()[0];
372 let mut labels = Array1::from_elem(n_samples, usize::MAX); let mut current_cluster = 0;
374
375 let mut unassigned: HashSet<usize> = (0..n_samples).collect();
377
378 while current_cluster < k && !unassigned.is_empty() {
379 let mut best_consensus = F::zero();
381 let mut best_seed = None;
382
383 for &i in &unassigned {
384 for &j in &unassigned {
385 if i != j && consensus_matrix[[i, j]] > best_consensus {
386 best_consensus = consensus_matrix[[i, j]];
387 best_seed = Some(i);
388 }
389 }
390 }
391
392 if let Some(seed) = best_seed {
393 let mut cluster_members = Vec::new();
395 cluster_members.push(seed);
396
397 for &candidate in &unassigned {
399 if candidate != seed && consensus_matrix[[seed, candidate]] >= threshold {
400 cluster_members.push(candidate);
401 }
402 }
403
404 for &member in &cluster_members {
406 labels[member] = current_cluster;
407 unassigned.remove(&member);
408 }
409
410 current_cluster += 1;
411 } else {
412 break;
414 }
415 }
416
417 for &unassigned_point in &unassigned {
419 let mut best_cluster = 0;
420 let mut best_avg_consensus = F::zero();
421
422 for cluster_id in 0..current_cluster {
423 let mut total_consensus = F::zero();
424 let mut count = 0;
425
426 for i in 0..n_samples {
427 if labels[i] == cluster_id {
428 total_consensus = total_consensus + consensus_matrix[[unassigned_point, i]];
429 count += 1;
430 }
431 }
432
433 if count > 0 {
434 let avg_consensus =
435 total_consensus / F::from(count).expect("Failed to convert to float");
436 if avg_consensus > best_avg_consensus {
437 best_avg_consensus = avg_consensus;
438 best_cluster = cluster_id;
439 }
440 }
441 }
442
443 labels[unassigned_point] = best_cluster;
444 }
445
446 Ok(labels)
447 }
448}
449
450pub struct OptimalKSelector<F: Float> {
452 config: StabilityConfig,
453 phantom: std::marker::PhantomData<F>,
454}
455
456impl<F: Float + FromPrimitive + Debug + 'static + std::iter::Sum + std::fmt::Display>
457 OptimalKSelector<F>
458{
459 pub fn new(config: StabilityConfig) -> Self {
461 Self {
462 config,
463 phantom: std::marker::PhantomData,
464 }
465 }
466
467 pub fn find_optimal_k(&self, data: ArrayView2<F>) -> Result<(usize, Vec<F>)> {
469 let (k_min, k_max) = self.config.k_range.unwrap_or((2, 10));
470 let mut stability_scores = Vec::new();
471
472 for k in k_min..=k_max {
473 let validator = BootstrapValidator::new(self.config.clone());
474 match validator.assess_kmeans_stability(data, k) {
475 Ok(result) => stability_scores.push(result.mean_stability),
476 Err(_) => stability_scores.push(F::zero()),
477 }
478 }
479
480 let mut best_k = k_min;
482 let mut best_score = F::neg_infinity();
483
484 for (i, &score) in stability_scores.iter().enumerate() {
485 if score > best_score {
486 best_score = score;
487 best_k = k_min + i;
488 }
489 }
490
491 Ok((best_k, stability_scores))
492 }
493
494 pub fn gap_statistic(&self, data: ArrayView2<F>) -> Result<(usize, Vec<F>)> {
496 let (k_min, k_max) = self.config.k_range.unwrap_or((2, 10));
497 let n_samples = data.shape()[0];
498 let n_features = data.shape()[1];
499
500 let mut gap_scores = Vec::new();
501
502 let mut min_vals = Array1::from_elem(n_features, F::infinity());
504 let mut max_vals = Array1::from_elem(n_features, F::neg_infinity());
505
506 for i in 0..n_samples {
507 for j in 0..n_features {
508 let val = data[[i, j]];
509 if val < min_vals[j] {
510 min_vals[j] = val;
511 }
512 if val > max_vals[j] {
513 max_vals[j] = val;
514 }
515 }
516 }
517
518 for k in k_min..=k_max {
519 let original_wk = self.calculate_within_cluster_dispersion(data, k)?;
521 let log_wk = original_wk.ln();
522
523 let mut reference_log_wks = Vec::new();
525 let mut rng = match self.config.random_seed {
526 Some(seed) => scirs2_core::random::rngs::StdRng::seed_from_u64(seed),
527 None => {
528 scirs2_core::random::rngs::StdRng::seed_from_u64(42)
530 }
531 };
532
533 for _b in 0..self.config.n_bootstrap {
534 let mut reference_data = Array2::zeros((n_samples, n_features));
536 for i in 0..n_samples {
537 for j in 0..n_features {
538 let range = max_vals[j] - min_vals[j];
539 let random_val = min_vals[j]
540 + range * F::from(rng.random::<f64>()).expect("Operation failed");
541 reference_data[[i, j]] = random_val;
542 }
543 }
544
545 let reference_wk =
546 self.calculate_within_cluster_dispersion(reference_data.view(), k)?;
547 reference_log_wks.push(reference_wk.ln());
548 }
549
550 let expected_log_wk = reference_log_wks
552 .iter()
553 .copied()
554 .fold(F::zero(), |acc, x| acc + x)
555 / F::from(reference_log_wks.len()).expect("Operation failed");
556 let gap = expected_log_wk - log_wk;
557 gap_scores.push(gap);
558 }
559
560 let mut optimal_k = k_min;
562 for i in 0..(gap_scores.len() - 1) {
563 if gap_scores[i] >= gap_scores[i + 1] {
564 optimal_k = k_min + i;
565 break;
566 }
567 }
568
569 Ok((optimal_k, gap_scores))
570 }
571
572 fn calculate_within_cluster_dispersion(&self, data: ArrayView2<F>, k: usize) -> Result<F> {
574 match kmeans2(
576 data,
577 k,
578 Some(100), None, None, None, Some(false), self.config.random_seed,
584 ) {
585 Ok((centroids, labels)) => {
586 let mut total_dispersion = F::zero();
587
588 for cluster_id in 0..k {
589 let mut cluster_dispersion = F::zero();
590 let mut cluster_size = 0;
591
592 for i in 0..data.shape()[0] {
594 if labels[i] == cluster_id {
595 let mut sq_dist = F::zero();
596 for j in 0..data.shape()[1] {
597 let diff = data[[i, j]] - centroids[[cluster_id, j]];
598 sq_dist = sq_dist + diff * diff;
599 }
600 cluster_dispersion = cluster_dispersion + sq_dist;
601 cluster_size += 1;
602 }
603 }
604
605 if cluster_size > 1 {
607 total_dispersion = total_dispersion
608 + cluster_dispersion
609 / F::from(cluster_size).expect("Failed to convert to float");
610 }
611 }
612
613 Ok(total_dispersion)
614 }
615 Err(e) => Err(e),
616 }
617 }
618}
619
620pub mod advanced {
622 use super::*;
623 use crate::ensemble::{EnsembleClusterer, EnsembleConfig};
624 use crate::metrics::{mutual_info_score, silhouette_score};
625
626 pub struct CrossValidationStability<F: Float> {
631 config: StabilityConfig,
632 n_folds: usize,
633 _phantom: std::marker::PhantomData<F>,
634 }
635
636 impl<F: Float + FromPrimitive + Debug + 'static + std::iter::Sum + std::fmt::Display>
637 CrossValidationStability<F>
638 {
639 pub fn new(config: StabilityConfig, n_folds: usize) -> Self {
641 Self {
642 config,
643 n_folds,
644 _phantom: std::marker::PhantomData,
645 }
646 }
647
648 pub fn assess_stability(
650 &self,
651 data: ArrayView2<F>,
652 k: usize,
653 ) -> Result<StabilityResult<F>> {
654 let n_samples = data.shape()[0];
655 let fold_size = n_samples / self.n_folds;
656 let mut stability_scores = Vec::new();
657 let mut bootstrap_matrix = Array2::zeros((self.n_folds, self.n_folds));
658
659 for fold in 0..self.n_folds {
661 let start_idx = fold * fold_size;
662 let end_idx = if fold == self.n_folds - 1 {
663 n_samples
664 } else {
665 (fold + 1) * fold_size
666 };
667
668 let mut train_indices = Vec::new();
670 for i in 0..n_samples {
671 if i < start_idx || i >= end_idx {
672 train_indices.push(i);
673 }
674 }
675
676 let train_data =
678 Array2::from_shape_fn((train_indices.len(), data.shape()[1]), |(i, j)| {
679 data[[train_indices[i], j]]
680 });
681
682 let (train_centroids, train_labels) = kmeans2(
684 train_data.view(),
685 k,
686 Some(100), Some(F::from(1e-6).expect("Failed to convert constant to float")), None, None, None, Some(42), )?;
693
694 let test_labels = Array1::from_shape_fn(end_idx - start_idx, |i| {
696 let test_point = data.row(start_idx + i);
697 let mut min_dist = F::infinity();
698 let mut closest_cluster = 0;
699
700 for (cluster_id, centroid) in train_centroids.outer_iter().enumerate() {
701 let dist = test_point
702 .iter()
703 .zip(centroid.iter())
704 .map(|(a, b)| (*a - *b) * (*a - *b))
705 .sum::<F>()
706 .sqrt();
707
708 if dist < min_dist {
709 min_dist = dist;
710 closest_cluster = cluster_id;
711 }
712 }
713 closest_cluster
714 });
715
716 let stability = self.calculate_fold_stability(&test_labels, k)?;
718 stability_scores.push(stability);
719 }
720
721 let mean_stability = stability_scores.iter().fold(F::zero(), |acc, x| acc + *x)
723 / F::from(stability_scores.len()).expect("Operation failed");
724 let variance = stability_scores
725 .iter()
726 .map(|&s| (s - mean_stability) * (s - mean_stability))
727 .fold(F::zero(), |acc, x| acc + x)
728 / F::from(stability_scores.len()).expect("Operation failed");
729 let std_stability = variance.sqrt();
730
731 Ok(StabilityResult {
732 stability_scores,
733 consensus_labels: None,
734 optimal_k: None,
735 mean_stability,
736 std_stability,
737 bootstrap_matrix,
738 })
739 }
740
741 fn calculate_fold_stability(&self, labels: &Array1<usize>, k: usize) -> Result<F> {
742 let mut cluster_cohesion = F::zero();
744 let mut total_pairs = 0;
745
746 for cluster_id in 0..k {
747 let cluster_members: Vec<_> = labels
748 .iter()
749 .enumerate()
750 .filter(|(_, &label)| label == cluster_id)
751 .map(|(idx_, _)| idx_)
752 .collect();
753
754 let cluster_size = cluster_members.len();
755 if cluster_size > 1 {
756 let pairs = cluster_size * (cluster_size - 1) / 2;
757 cluster_cohesion =
758 cluster_cohesion + F::from(pairs).expect("Failed to convert to float");
759 total_pairs += pairs;
760 }
761 }
762
763 if total_pairs == 0 {
764 Ok(F::zero())
765 } else {
766 Ok(cluster_cohesion / F::from(total_pairs).expect("Failed to convert to float"))
767 }
768 }
769 }
770
771 pub struct PerturbationStability<F: Float> {
776 config: StabilityConfig,
777 perturbation_types: Vec<PerturbationType>,
778 _phantom: std::marker::PhantomData<F>,
779 }
780
781 #[derive(Debug, Clone)]
783 pub enum PerturbationType {
784 GaussianNoise { std_dev: f64 },
786 SampleRemoval { removal_rate: f64 },
788 FeatureNoise { noise_level: f64 },
790 OutlierInjection {
792 outlier_rate: f64,
793 outlier_magnitude: f64,
794 },
795 }
796
797 impl<F: Float + FromPrimitive + Debug + 'static + std::iter::Sum + std::fmt::Display>
798 PerturbationStability<F>
799 {
800 pub fn new(config: StabilityConfig, perturbation_types: Vec<PerturbationType>) -> Self {
802 Self {
803 config,
804 perturbation_types,
805 _phantom: std::marker::PhantomData,
806 }
807 }
808
809 pub fn assess_stability(
811 &self,
812 data: ArrayView2<F>,
813 k: usize,
814 ) -> Result<StabilityResult<F>> {
815 let mut all_stability_scores = Vec::new();
816 let mut rng = scirs2_core::random::rng();
817
818 let (baseline_centroids, baseline_labels) = kmeans2(
820 data,
821 k,
822 Some(100), Some(F::from(1e-6).expect("Failed to convert constant to float")), None, None, None, Some(42), )?;
829
830 for perturbation in &self.perturbation_types {
832 let mut perturbation_scores = Vec::new();
833
834 for _ in 0..self.config.n_bootstrap {
835 let perturbed_data = self.apply_perturbation(data, perturbation, &mut rng)?;
837
838 let (_, perturbed_labels) = kmeans2(
840 perturbed_data.view(),
841 k,
842 Some(100), Some(F::from(1e-6).expect("Failed to convert constant to float")), None, None, None, None, )?;
849
850 let similarity =
852 self.calculate_label_similarity(&baseline_labels, &perturbed_labels)?;
853 perturbation_scores.push(similarity);
854 }
855
856 all_stability_scores.extend(perturbation_scores);
857 }
858
859 let mean_stability = all_stability_scores
861 .iter()
862 .fold(F::zero(), |acc, x| acc + *x)
863 / F::from(all_stability_scores.len()).expect("Operation failed");
864 let variance = all_stability_scores
865 .iter()
866 .map(|&s| (s - mean_stability) * (s - mean_stability))
867 .sum::<F>()
868 / F::from(all_stability_scores.len()).expect("Operation failed");
869 let std_stability = variance.sqrt();
870
871 let bootstrap_matrix =
872 Array2::zeros((self.config.n_bootstrap, self.perturbation_types.len()));
873
874 Ok(StabilityResult {
875 stability_scores: all_stability_scores,
876 consensus_labels: None,
877 optimal_k: None,
878 mean_stability,
879 std_stability,
880 bootstrap_matrix,
881 })
882 }
883
884 fn apply_perturbation(
885 &self,
886 data: ArrayView2<F>,
887 perturbation: &PerturbationType,
888 rng: &mut impl Rng,
889 ) -> Result<Array2<F>> {
890 let mut perturbed = data.to_owned();
891
892 match perturbation {
893 PerturbationType::GaussianNoise { std_dev } => {
894 for elem in perturbed.iter_mut() {
895 let noise = rng.random::<f64>() * std_dev;
896 *elem = *elem + F::from(noise).expect("Failed to convert to float");
897 }
898 }
899 PerturbationType::SampleRemoval { removal_rate } => {
900 let n_samples = data.shape()[0];
901 let n_remove = (n_samples as f64 * removal_rate) as usize;
902 let mut indices: Vec<_> = (0..n_samples).collect();
903 indices.shuffle(rng);
904 indices.truncate(n_samples - n_remove);
905 indices.sort();
906
907 let mut new_data = Array2::zeros((indices.len(), data.shape()[1]));
908 for (new_i, &old_i) in indices.iter().enumerate() {
909 new_data.row_mut(new_i).assign(&data.row(old_i));
910 }
911 perturbed = new_data;
912 }
913 PerturbationType::FeatureNoise { noise_level } => {
914 for elem in perturbed.iter_mut() {
915 let noise = (rng.random::<f64>() - 0.5) * 2.0 * noise_level;
916 *elem = *elem + F::from(noise).expect("Failed to convert to float");
917 }
918 }
919 PerturbationType::OutlierInjection {
920 outlier_rate,
921 outlier_magnitude,
922 } => {
923 let n_samples = data.shape()[0];
924 let n_outliers = (n_samples as f64 * outlier_rate) as usize;
925
926 for _ in 0..n_outliers {
927 let sample_idx = rng.random_range(0..n_samples);
928 let feature_idx = rng.random_range(0..data.shape()[1]);
929 let outlier_value = rng.random::<f64>() * outlier_magnitude;
930 perturbed[[sample_idx, feature_idx]] =
931 F::from(outlier_value).expect("Failed to convert to float");
932 }
933 }
934 }
935
936 Ok(perturbed)
937 }
938
939 fn calculate_label_similarity(
940 &self,
941 labels1: &Array1<usize>,
942 labels2: &Array1<usize>,
943 ) -> Result<F> {
944 if labels1.len() != labels2.len() {
945 return Ok(F::zero());
946 }
947
948 let labels1_i32: Array1<i32> = labels1.mapv(|x| x as i32);
950 let labels2_i32: Array1<i32> = labels2.mapv(|x| x as i32);
951
952 let ari: f64 = adjusted_rand_index(labels1_i32.view(), labels2_i32.view())?;
954 Ok(F::from(ari).expect("Failed to convert to float"))
955 }
956 }
957
958 pub struct MultiScaleStability<F: Float> {
963 config: StabilityConfig,
964 scale_factors: Vec<f64>,
965 _phantom: std::marker::PhantomData<F>,
966 }
967
968 impl<F: Float + FromPrimitive + Debug + 'static + std::iter::Sum + std::fmt::Display>
969 MultiScaleStability<F>
970 {
971 pub fn new(config: StabilityConfig, scale_factors: Vec<f64>) -> Self {
973 Self {
974 config,
975 scale_factors,
976 _phantom: std::marker::PhantomData,
977 }
978 }
979
980 pub fn assess_stability(
982 &self,
983 data: ArrayView2<F>,
984 k_range: (usize, usize),
985 ) -> Result<Vec<StabilityResult<F>>> {
986 let mut results = Vec::new();
987
988 for &scale_factor in &self.scale_factors {
989 let scaled_data =
991 data.mapv(|x| x * F::from(scale_factor).expect("Failed to convert to float"));
992
993 for k in k_range.0..=k_range.1 {
995 let validator = BootstrapValidator::new(self.config.clone());
996 let stability_result =
997 validator.assess_kmeans_stability(scaled_data.view(), k)?;
998 results.push(stability_result);
999 }
1000 }
1001
1002 Ok(results)
1003 }
1004
1005 pub fn find_optimal_scale_and_k(
1007 &self,
1008 data: ArrayView2<F>,
1009 k_range: (usize, usize),
1010 ) -> Result<(f64, usize, F)> {
1011 let results = self.assess_stability(data, k_range)?;
1012
1013 let mut best_scale = self.scale_factors[0];
1014 let mut best_k = k_range.0;
1015 let mut best_stability = F::neg_infinity();
1016
1017 let mut result_idx = 0;
1018 for &scale_factor in &self.scale_factors {
1019 for k in k_range.0..=k_range.1 {
1020 if result_idx < results.len() {
1021 let stability = results[result_idx].mean_stability;
1022 if stability > best_stability {
1023 best_stability = stability;
1024 best_scale = scale_factor;
1025 best_k = k;
1026 }
1027 result_idx += 1;
1028 }
1029 }
1030 }
1031
1032 Ok((best_scale, best_k, best_stability))
1033 }
1034 }
1035
1036 pub struct PredictionStrength<F: Float> {
1042 pub config: PredictionStrengthConfig,
1044 phantom: std::marker::PhantomData<F>,
1045 }
1046
1047 #[derive(Debug, Clone)]
1049 pub struct PredictionStrengthConfig {
1050 pub n_bootstrap: usize,
1052 pub train_ratio: f64,
1054 pub strength_threshold: f64,
1056 pub random_seed: Option<u64>,
1058 }
1059
1060 impl Default for PredictionStrengthConfig {
1061 fn default() -> Self {
1062 Self {
1063 n_bootstrap: 50,
1064 train_ratio: 0.5,
1065 strength_threshold: 0.8,
1066 random_seed: None,
1067 }
1068 }
1069 }
1070
1071 impl<F: Float + FromPrimitive + Debug + 'static + std::iter::Sum + std::fmt::Display>
1072 PredictionStrength<F>
1073 {
1074 pub fn new(config: PredictionStrengthConfig) -> Self {
1076 Self {
1077 config,
1078 phantom: std::marker::PhantomData,
1079 }
1080 }
1081
1082 pub fn assess_k_range(
1084 &self,
1085 data: ArrayView2<F>,
1086 k_range: (usize, usize),
1087 ) -> Result<Vec<F>> {
1088 let mut prediction_strengths = Vec::new();
1089
1090 for k in k_range.0..=k_range.1 {
1091 let strength = self.compute_prediction_strength(data, k)?;
1092 prediction_strengths.push(strength);
1093 }
1094
1095 Ok(prediction_strengths)
1096 }
1097
1098 pub fn compute_prediction_strength(&self, data: ArrayView2<F>, k: usize) -> Result<F> {
1100 let mut rng = match self.config.random_seed {
1101 Some(seed) => scirs2_core::random::rngs::StdRng::seed_from_u64(seed),
1102 None => scirs2_core::random::rngs::StdRng::seed_from_u64(
1103 scirs2_core::random::rng().random(),
1104 ),
1105 };
1106
1107 let n_samples = data.nrows();
1108 let train_size = ((n_samples as f64) * self.config.train_ratio) as usize;
1109
1110 let mut prediction_scores = Vec::new();
1111
1112 for _ in 0..self.config.n_bootstrap {
1113 let mut indices: Vec<usize> = (0..n_samples).collect();
1115 indices.shuffle(&mut rng);
1116
1117 let train_indices = &indices[..train_size];
1118 let test_indices = &indices[train_size..];
1119
1120 if test_indices.is_empty() {
1121 continue;
1122 }
1123
1124 let train_data = data.select(scirs2_core::ndarray::Axis(0), train_indices);
1126 let test_data = data.select(scirs2_core::ndarray::Axis(0), test_indices);
1127
1128 match kmeans2(train_data.view(), k, None, None, None, None, None, None) {
1130 Ok((_, train_labels)) => {
1131 match kmeans2(test_data.view(), k, None, None, None, None, None, None) {
1133 Ok((_, test_labels)) => {
1134 let strength = self.compute_pairwise_prediction_strength(
1136 &train_data,
1137 &test_data,
1138 &train_labels,
1139 &test_labels,
1140 )?;
1141 prediction_scores.push(strength);
1142 }
1143 Err(_) => continue,
1144 }
1145 }
1146 Err(_) => continue,
1147 }
1148 }
1149
1150 if prediction_scores.is_empty() {
1151 return Ok(F::zero());
1152 }
1153
1154 let sum: F = prediction_scores.iter().fold(F::zero(), |acc, &x| acc + x);
1156 Ok(sum / F::from(prediction_scores.len()).expect("Operation failed"))
1157 }
1158
1159 fn compute_pairwise_prediction_strength(
1161 &self,
1162 train_data: &Array2<F>,
1163 test_data: &Array2<F>,
1164 train_labels: &Array1<usize>,
1165 test_labels: &Array1<usize>,
1166 ) -> Result<F> {
1167 let test_size = test_data.nrows();
1168 let mut correct_predictions = 0;
1169 let mut total_predictions = 0;
1170
1171 for i in 0..test_size {
1173 for j in (i + 1)..test_size {
1174 let closest_train_i = self.find_closest_point(&test_data.row(i), train_data)?;
1176 let closest_train_j = self.find_closest_point(&test_data.row(j), train_data)?;
1177
1178 let predicted_same =
1180 train_labels[closest_train_i] == train_labels[closest_train_j];
1181 let actual_same = test_labels[i] == test_labels[j];
1182
1183 if predicted_same == actual_same {
1184 correct_predictions += 1;
1185 }
1186 total_predictions += 1;
1187 }
1188 }
1189
1190 if total_predictions == 0 {
1191 return Ok(F::zero());
1192 }
1193
1194 Ok(
1195 F::from(correct_predictions as f64 / total_predictions as f64)
1196 .expect("Failed to convert to float"),
1197 )
1198 }
1199
1200 fn find_closest_point(
1202 &self,
1203 test_point: &scirs2_core::ndarray::ArrayView1<F>,
1204 train_data: &Array2<F>,
1205 ) -> Result<usize> {
1206 let mut min_distance = F::infinity();
1207 let mut closest_idx = 0;
1208
1209 for (idx, train_point) in train_data.rows().into_iter().enumerate() {
1210 let distance = test_point
1211 .iter()
1212 .zip(train_point.iter())
1213 .map(|(a, b)| (*a - *b) * (*a - *b))
1214 .fold(F::zero(), |acc, x| acc + x)
1215 .sqrt();
1216
1217 if distance < min_distance {
1218 min_distance = distance;
1219 closest_idx = idx;
1220 }
1221 }
1222
1223 Ok(closest_idx)
1224 }
1225
1226 pub fn find_optimal_k(
1228 &self,
1229 data: ArrayView2<F>,
1230 k_range: (usize, usize),
1231 ) -> Result<usize> {
1232 let strengths = self.assess_k_range(data, k_range)?;
1233
1234 for (idx, &strength) in strengths.iter().enumerate().rev() {
1236 if strength
1237 >= F::from(self.config.strength_threshold).expect("Failed to convert to float")
1238 {
1239 return Ok(k_range.0 + idx);
1240 }
1241 }
1242
1243 let best_idx = strengths
1245 .iter()
1246 .enumerate()
1247 .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
1248 .map(|(idx_, _)| idx_)
1249 .unwrap_or(0);
1250
1251 Ok(k_range.0 + best_idx)
1252 }
1253 }
1254
1255 pub struct JaccardStability<F: Float> {
1260 pub n_bootstrap: usize,
1262 pub subsample_ratio: f64,
1264 pub random_seed: Option<u64>,
1266 _phantom: std::marker::PhantomData<F>,
1267 }
1268
1269 impl<F: Float + FromPrimitive + Debug + 'static + std::iter::Sum + std::fmt::Display>
1270 JaccardStability<F>
1271 {
1272 pub fn new(n_bootstrap: usize, subsample_ratio: f64, random_seed: Option<u64>) -> Self {
1274 Self {
1275 n_bootstrap,
1276 subsample_ratio,
1277 random_seed,
1278 _phantom: std::marker::PhantomData,
1279 }
1280 }
1281
1282 pub fn compute_stability(&self, data: ArrayView2<F>, k: usize) -> Result<F> {
1284 let mut rng = match self.random_seed {
1285 Some(seed) => scirs2_core::random::rngs::StdRng::seed_from_u64(seed),
1286 None => scirs2_core::random::rngs::StdRng::seed_from_u64(
1287 scirs2_core::random::rng().random(),
1288 ),
1289 };
1290
1291 let n_samples = data.nrows();
1292 let subsample_size = ((n_samples as f64) * self.subsample_ratio) as usize;
1293
1294 let mut jaccard_scores = Vec::new();
1295
1296 for _ in 0..self.n_bootstrap {
1298 let mut indices1: Vec<usize> = (0..n_samples).collect();
1300 indices1.shuffle(&mut rng);
1301 let sample_indices1 = &indices1[..subsample_size];
1302 let sample_data1 = data.select(scirs2_core::ndarray::Axis(0), sample_indices1);
1303
1304 let mut indices2: Vec<usize> = (0..n_samples).collect();
1306 indices2.shuffle(&mut rng);
1307 let sample_indices2 = &indices2[..subsample_size];
1308 let sample_data2 = data.select(scirs2_core::ndarray::Axis(0), sample_indices2);
1309
1310 match (
1312 kmeans2(sample_data1.view(), k, None, None, None, None, None, None),
1313 kmeans2(sample_data2.view(), k, None, None, None, None, None, None),
1314 ) {
1315 (Ok((_, labels1)), Ok((_, labels2))) => {
1316 let overlap_indices: Vec<(usize, usize)> = sample_indices1
1318 .iter()
1319 .enumerate()
1320 .filter_map(|(i1, &idx1)| {
1321 sample_indices2
1322 .iter()
1323 .enumerate()
1324 .find(|(_, &idx2)| idx1 == idx2)
1325 .map(|(i2_, _)| (i1, i2_))
1326 })
1327 .collect();
1328
1329 if overlap_indices.len() >= 2 {
1330 let jaccard = self.compute_jaccard_similarity(
1331 &labels1,
1332 &labels2,
1333 &overlap_indices,
1334 )?;
1335 jaccard_scores.push(jaccard);
1336 }
1337 }
1338 _ => continue,
1339 }
1340 }
1341
1342 if jaccard_scores.is_empty() {
1343 return Ok(F::zero());
1344 }
1345
1346 let sum: F = jaccard_scores.iter().fold(F::zero(), |acc, &x| acc + x);
1348 Ok(sum / F::from(jaccard_scores.len()).expect("Operation failed"))
1349 }
1350
1351 fn compute_jaccard_similarity(
1353 &self,
1354 labels1: &Array1<usize>,
1355 labels2: &Array1<usize>,
1356 overlap_indices: &[(usize, usize)],
1357 ) -> Result<F> {
1358 let mut same_cluster_both = 0;
1359 let mut same_cluster_either = 0;
1360
1361 let n_overlap = overlap_indices.len();
1362
1363 for i in 0..n_overlap {
1364 for j in (i + 1)..n_overlap {
1365 let (idx1_i, idx2_i) = overlap_indices[i];
1366 let (idx1_j, idx2_j) = overlap_indices[j];
1367
1368 let same_in_clustering1 = labels1[idx1_i] == labels1[idx1_j];
1369 let same_in_clustering2 = labels2[idx2_i] == labels2[idx2_j];
1370
1371 if same_in_clustering1 && same_in_clustering2 {
1372 same_cluster_both += 1;
1373 }
1374 if same_in_clustering1 || same_in_clustering2 {
1375 same_cluster_either += 1;
1376 }
1377 }
1378 }
1379
1380 if same_cluster_either == 0 {
1381 return Ok(F::one()); }
1383
1384 Ok(
1385 F::from(same_cluster_both as f64 / same_cluster_either as f64)
1386 .expect("Failed to convert to float"),
1387 )
1388 }
1389
1390 pub fn assess_k_range(
1392 &self,
1393 data: ArrayView2<F>,
1394 k_range: (usize, usize),
1395 ) -> Result<Vec<F>> {
1396 let mut stabilities = Vec::new();
1397
1398 for k in k_range.0..=k_range.1 {
1399 let stability = self.compute_stability(data, k)?;
1400 stabilities.push(stability);
1401 }
1402
1403 Ok(stabilities)
1404 }
1405 }
1406
1407 pub struct ClusterSpecificStability<F: Float> {
1412 pub config: StabilityConfig,
1414 phantom: std::marker::PhantomData<F>,
1415 }
1416
1417 #[derive(Debug, Clone)]
1419 pub struct ClusterStabilityResult<F: Float> {
1420 pub cluster_stabilities: Vec<F>,
1422 pub mean_stability: F,
1424 pub std_stability: F,
1426 pub size_consistency: Vec<F>,
1428 }
1429
1430 impl<F: Float + FromPrimitive + Debug + 'static + std::iter::Sum + std::fmt::Display>
1431 ClusterSpecificStability<F>
1432 {
1433 pub fn new(config: StabilityConfig) -> Self {
1435 Self {
1436 config,
1437 phantom: std::marker::PhantomData,
1438 }
1439 }
1440
1441 pub fn assess_cluster_stability(
1443 &self,
1444 data: ArrayView2<F>,
1445 k: usize,
1446 ) -> Result<ClusterStabilityResult<F>> {
1447 let mut rng = match self.config.random_seed {
1448 Some(seed) => scirs2_core::random::rngs::StdRng::seed_from_u64(seed),
1449 None => scirs2_core::random::rngs::StdRng::seed_from_u64(
1450 scirs2_core::random::rng().random(),
1451 ),
1452 };
1453
1454 let n_samples = data.nrows();
1455 let subsample_size = ((n_samples as f64) * self.config.subsample_ratio) as usize;
1456
1457 let mut cluster_memberships: Vec<Vec<HashSet<usize>>> = vec![Vec::new(); k];
1458 let mut cluster_sizes: Vec<Vec<usize>> = vec![Vec::new(); k];
1459
1460 for _ in 0..self.config.n_bootstrap {
1462 let mut indices: Vec<usize> = (0..n_samples).collect();
1463 indices.shuffle(&mut rng);
1464 let sample_indices = &indices[..subsample_size];
1465 let sample_data = data.select(scirs2_core::ndarray::Axis(0), sample_indices);
1466
1467 match kmeans2(sample_data.view(), k, None, None, None, None, None, None) {
1468 Ok((_, labels)) => {
1469 for cluster_id in 0..k {
1471 let mut cluster_members = HashSet::new();
1472 for (local_idx, &label) in labels.iter().enumerate() {
1473 if label == cluster_id {
1474 cluster_members.insert(sample_indices[local_idx]);
1475 }
1476 }
1477 cluster_memberships[cluster_id].push(cluster_members.clone());
1478 cluster_sizes[cluster_id].push(cluster_members.len());
1479 }
1480 }
1481 Err(_) => continue,
1482 }
1483 }
1484
1485 let mut cluster_stabilities = Vec::new();
1487 let mut size_consistency = Vec::new();
1488
1489 for cluster_id in 0..k {
1490 let stability = self.compute_cluster_stability(&cluster_memberships[cluster_id])?;
1491 cluster_stabilities.push(stability);
1492
1493 let consistency = self.compute_size_consistency(&cluster_sizes[cluster_id])?;
1494 size_consistency.push(consistency);
1495 }
1496
1497 let mean_stability = cluster_stabilities
1499 .iter()
1500 .fold(F::zero(), |acc, &x| acc + x)
1501 / F::from(cluster_stabilities.len()).expect("Operation failed");
1502
1503 let variance = cluster_stabilities
1504 .iter()
1505 .map(|&x| (x - mean_stability) * (x - mean_stability))
1506 .fold(F::zero(), |acc, x| acc + x)
1507 / F::from(cluster_stabilities.len()).expect("Operation failed");
1508 let std_stability = variance.sqrt();
1509
1510 Ok(ClusterStabilityResult {
1511 cluster_stabilities,
1512 mean_stability,
1513 std_stability,
1514 size_consistency,
1515 })
1516 }
1517
1518 fn compute_cluster_stability(&self, cluster_samples: &[HashSet<usize>]) -> Result<F> {
1520 if cluster_samples.len() < 2 {
1521 return Ok(F::zero());
1522 }
1523
1524 let mut jaccard_scores = Vec::new();
1525
1526 for i in 0..cluster_samples.len() {
1528 for j in (i + 1)..cluster_samples.len() {
1529 let intersection_size =
1530 cluster_samples[i].intersection(&cluster_samples[j]).count();
1531 let union_size = cluster_samples[i].union(&cluster_samples[j]).count();
1532
1533 if union_size > 0 {
1534 let jaccard = intersection_size as f64 / union_size as f64;
1535 jaccard_scores.push(F::from(jaccard).expect("Failed to convert to float"));
1536 }
1537 }
1538 }
1539
1540 if jaccard_scores.is_empty() {
1541 return Ok(F::zero());
1542 }
1543
1544 let sum: F = jaccard_scores.iter().fold(F::zero(), |acc, &x| acc + x);
1546 Ok(sum / F::from(jaccard_scores.len()).expect("Operation failed"))
1547 }
1548
1549 fn compute_size_consistency(&self, sizes: &[usize]) -> Result<F> {
1551 if sizes.is_empty() {
1552 return Ok(F::zero());
1553 }
1554
1555 let mean_size = sizes.iter().sum::<usize>() as f64 / sizes.len() as f64;
1556 let variance = sizes
1557 .iter()
1558 .map(|&size| (size as f64 - mean_size).powi(2))
1559 .sum::<f64>()
1560 / sizes.len() as f64;
1561
1562 let cv = if mean_size > 0.0 {
1563 variance.sqrt() / mean_size
1564 } else {
1565 0.0
1566 };
1567 Ok(F::one() - F::from(cv).expect("Failed to convert to float")) }
1569 }
1570
1571 pub struct ParameterStabilityAnalyzer<F: Float> {
1576 pub base_k: usize,
1578 pub perturbation_ranges: Vec<f64>,
1580 pub n_samples_per_range: usize,
1582 pub random_seed: Option<u64>,
1584 _phantom: std::marker::PhantomData<F>,
1585 }
1586
1587 #[derive(Debug, Clone)]
1589 pub struct ParameterStabilityResult<F: Float> {
1590 pub stability_by_perturbation: Vec<F>,
1592 pub sensitivity_profile: Vec<F>,
1594 pub robust_range: (f64, f64),
1596 }
1597
1598 impl<F: Float + FromPrimitive + Debug + 'static + std::iter::Sum + std::fmt::Display>
1599 ParameterStabilityAnalyzer<F>
1600 {
1601 pub fn new(
1603 base_k: usize,
1604 perturbation_ranges: Vec<f64>,
1605 n_samples_per_range: usize,
1606 random_seed: Option<u64>,
1607 ) -> Self {
1608 Self {
1609 base_k,
1610 perturbation_ranges,
1611 n_samples_per_range,
1612 random_seed,
1613 _phantom: std::marker::PhantomData,
1614 }
1615 }
1616
1617 pub fn analyze_stability(
1619 &self,
1620 data: ArrayView2<F>,
1621 ) -> Result<ParameterStabilityResult<F>> {
1622 let mut rng = match self.random_seed {
1623 Some(seed) => scirs2_core::random::rngs::StdRng::seed_from_u64(seed),
1624 None => scirs2_core::random::rngs::StdRng::seed_from_u64(
1625 scirs2_core::random::rng().random(),
1626 ),
1627 };
1628
1629 let mut stability_by_perturbation = Vec::new();
1630 let mut sensitivity_profile = Vec::new();
1631
1632 let baseline_result = kmeans2(data, self.base_k, None, None, None, None, None, None)?;
1634
1635 for &perturbation_level in &self.perturbation_ranges {
1636 let mut stability_scores = Vec::new();
1637
1638 for _ in 0..self.n_samples_per_range {
1639 let k_perturbation = (F::from(rng.random::<f64>()).expect("Operation failed")
1641 - F::from(0.5).expect("Failed to convert constant to float"))
1642 * F::from(2.0).expect("Failed to convert constant to float")
1643 * F::from(perturbation_level).expect("Failed to convert to float");
1644 let perturbed_k = (self.base_k as f64
1645 * (1.0 + k_perturbation.to_f64().expect("Operation failed")))
1646 .round()
1647 .max(1.0) as usize;
1648
1649 match kmeans2(data, perturbed_k, None, None, None, None, None, None) {
1650 Ok((_, perturbed_labels)) => {
1651 let baseline_i32 = baseline_result.1.mapv(|x| x as i32);
1654 let perturbed_i32 = perturbed_labels.mapv(|x| x as i32);
1655 match adjusted_rand_index(baseline_i32.view(), perturbed_i32.view()) {
1656 Ok(stability) => stability_scores.push(stability),
1657 Err(_) => continue,
1658 }
1659 }
1660 Err(_) => continue,
1661 }
1662 }
1663
1664 if !stability_scores.is_empty() {
1665 let mean_stability = stability_scores.iter().fold(F::zero(), |acc, &x| acc + x)
1666 / F::from(stability_scores.len()).expect("Operation failed");
1667 stability_by_perturbation.push(mean_stability);
1668
1669 sensitivity_profile.push(F::one() - mean_stability);
1671 }
1672 }
1673
1674 let robust_range = self.find_robust_range(&sensitivity_profile);
1676
1677 Ok(ParameterStabilityResult {
1678 stability_by_perturbation,
1679 sensitivity_profile,
1680 robust_range,
1681 })
1682 }
1683
1684 fn find_robust_range(&self, sensitivity_profile: &[F]) -> (f64, f64) {
1686 if sensitivity_profile.is_empty() {
1687 return (0.0, 0.0);
1688 }
1689
1690 let min_sensitivity = sensitivity_profile
1692 .iter()
1693 .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
1694 .expect("Operation failed");
1695
1696 let max_sensitivity = sensitivity_profile
1698 .iter()
1699 .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
1700 .expect("Operation failed");
1701 let threshold = *min_sensitivity
1702 + (*max_sensitivity - *min_sensitivity)
1703 * F::from(0.1).expect("Failed to convert constant to float");
1704
1705 let mut start_idx = None;
1707 let mut end_idx = None;
1708
1709 for (idx, &sensitivity) in sensitivity_profile.iter().enumerate() {
1710 if sensitivity <= threshold {
1711 if start_idx.is_none() {
1712 start_idx = Some(idx);
1713 }
1714 end_idx = Some(idx);
1715 }
1716 }
1717
1718 let start_range = start_idx
1719 .map(|idx| self.perturbation_ranges[idx])
1720 .unwrap_or(0.0);
1721 let end_range = end_idx
1722 .map(|idx| self.perturbation_ranges[idx])
1723 .unwrap_or(0.0);
1724
1725 (start_range, end_range)
1726 }
1727 }
1728}
1729
1730#[cfg(test)]
1731mod tests {
1732 use super::*;
1733 use scirs2_core::ndarray::Array2;
1734
1735 #[test]
1736 fn test_stability_config_default() {
1737 let config = StabilityConfig::default();
1738 assert_eq!(config.n_bootstrap, 100);
1739 assert_eq!(config.subsample_ratio, 0.8);
1740 assert_eq!(config.n_runs_per_bootstrap, 10);
1741 assert!(config.random_seed.is_none());
1742 }
1743
1744 #[test]
1745 fn test_bootstrap_validator() {
1746 let data = Array2::from_shape_vec((20, 2), (0..40).map(|i| i as f64 / 10.0).collect())
1747 .expect("Operation failed");
1748
1749 let config = StabilityConfig {
1750 n_bootstrap: 5,
1751 subsample_ratio: 0.8,
1752 n_runs_per_bootstrap: 3,
1753 random_seed: Some(42),
1754 k_range: None,
1755 };
1756
1757 let validator = BootstrapValidator::new(config);
1758 let result = validator.assess_kmeans_stability(data.view(), 2);
1759
1760 assert!(result.is_ok());
1761 let stability_result = result.expect("Operation failed");
1762 assert!(stability_result.mean_stability >= 0.0);
1763 assert!(stability_result.mean_stability <= 1.0);
1764 assert_eq!(stability_result.bootstrap_matrix.shape(), &[20, 20]);
1765 }
1766
1767 #[test]
1768 fn test_consensus_clusterer() {
1769 let data = Array2::from_shape_vec(
1770 (6, 2),
1771 vec![0.0, 0.0, 0.1, 0.1, 0.2, 0.2, 5.0, 5.0, 5.1, 5.1, 5.2, 5.2],
1772 )
1773 .expect("Operation failed");
1774
1775 let config = StabilityConfig {
1776 n_bootstrap: 10,
1777 random_seed: Some(42),
1778 ..Default::default()
1779 };
1780
1781 let consensus = ConsensusClusterer::new(config);
1782 let result = consensus.find_consensus_clusters(data.view(), 2);
1783
1784 assert!(result.is_ok());
1785 let labels = result.expect("Operation failed");
1786 assert_eq!(labels.len(), 6);
1787
1788 let unique_labels: std::collections::HashSet<_> = labels.iter().copied().collect();
1790 assert_eq!(unique_labels.len(), 2);
1791 }
1792
1793 #[test]
1794 fn test_optimal_k_selector() {
1795 let data = Array2::from_shape_vec(
1796 (12, 2),
1797 vec![
1798 0.0, 0.0, 0.1, 0.1, 0.2, 0.2, 5.0, 5.0, 5.1, 5.1, 5.2, 5.2, 10.0, 10.0, 10.1, 10.1, 10.2, 10.2, 15.0, 15.0, 15.1, 15.1, 15.2, 15.2, ],
1803 )
1804 .expect("Operation failed");
1805
1806 let config = StabilityConfig {
1807 k_range: Some((2, 5)),
1808 n_bootstrap: 5,
1809 random_seed: Some(42),
1810 ..Default::default()
1811 };
1812
1813 let selector = OptimalKSelector::new(config);
1814 let result = selector.find_optimal_k(data.view());
1815
1816 assert!(result.is_ok());
1817 let (optimal_k, scores) = result.expect("Operation failed");
1818 assert!((2..=5).contains(&optimal_k));
1819 assert_eq!(scores.len(), 4); }
1821
1822 #[test]
1823 fn test_gap_statistic() {
1824 let data = Array2::from_shape_vec(
1825 (8, 2),
1826 vec![
1827 0.0, 0.0, 0.1, 0.1, 0.2, 0.2, 0.3, 0.3, 5.0, 5.0, 5.1, 5.1, 5.2, 5.2, 5.3, 5.3,
1828 ],
1829 )
1830 .expect("Operation failed");
1831
1832 let config = StabilityConfig {
1833 k_range: Some((2, 4)),
1834 n_bootstrap: 5,
1835 random_seed: Some(42),
1836 ..Default::default()
1837 };
1838
1839 let selector = OptimalKSelector::new(config);
1840 let result = selector.gap_statistic(data.view());
1841
1842 assert!(result.is_ok());
1843 let (optimal_k, gap_scores) = result.expect("Operation failed");
1844 assert!((2..=4).contains(&optimal_k));
1845 assert_eq!(gap_scores.len(), 3); }
1847}