1use crate::fdata::mean_1d;
15use crate::iter_maybe_parallel;
16use crate::matrix::FdMatrix;
17use crate::regression::fdata_to_pc_1d;
18use crate::smoothing::local_polynomial;
19use rand::prelude::*;
20use rand_distr::StandardNormal;
21#[cfg(feature = "parallel")]
22use rayon::iter::ParallelIterator;
23
24#[derive(Debug, Clone)]
28pub struct ToleranceBand {
29 pub lower: Vec<f64>,
31 pub upper: Vec<f64>,
33 pub center: Vec<f64>,
35 pub half_width: Vec<f64>,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum BandType {
42 Pointwise,
44 Simultaneous,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum NonConformityScore {
51 SupNorm,
53 L2,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum MultiplierDistribution {
60 Gaussian,
62 Rademacher,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum ExponentialFamily {
69 Gaussian,
71 Binomial,
73 Poisson,
75}
76
77fn pointwise_mean_std(data: &FdMatrix) -> (Vec<f64>, Vec<f64>) {
81 let (n, m) = data.shape();
82 let nf = n as f64;
83 let mut means = vec![0.0; m];
84 let mut stds = vec![0.0; m];
85
86 for j in 0..m {
87 let col = data.column(j);
88 let mean = col.iter().sum::<f64>() / nf;
89 means[j] = mean;
90 let var = col.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / (nf - 1.0);
91 stds[j] = var.sqrt();
92 }
93 (means, stds)
94}
95
96fn normal_quantile(p: f64) -> f64 {
98 if p <= 0.0 || p >= 1.0 {
99 return f64::NAN;
100 }
101 if (p - 0.5).abs() < 1e-15 {
102 return 0.0;
103 }
104
105 let (sign, q) = if p < 0.5 { (-1.0, 1.0 - p) } else { (1.0, p) };
107
108 let t = (-2.0 * (1.0 - q).ln()).sqrt();
109
110 const C0: f64 = 2.515517;
112 const C1: f64 = 0.802853;
113 const C2: f64 = 0.010328;
114 const D1: f64 = 1.432788;
115 const D2: f64 = 0.189269;
116 const D3: f64 = 0.001308;
117
118 let numerator = C0 + C1 * t + C2 * t * t;
119 let denominator = 1.0 + D1 * t + D2 * t * t + D3 * t * t * t;
120
121 sign * (t - numerator / denominator)
122}
123
124fn build_band(center: Vec<f64>, half_width: Vec<f64>) -> ToleranceBand {
126 let lower: Vec<f64> = center
127 .iter()
128 .zip(half_width.iter())
129 .map(|(&c, &h)| c - h)
130 .collect();
131 let upper: Vec<f64> = center
132 .iter()
133 .zip(half_width.iter())
134 .map(|(&c, &h)| c + h)
135 .collect();
136 ToleranceBand {
137 lower,
138 upper,
139 center,
140 half_width,
141 }
142}
143
144fn percentile_sorted(sorted: &mut [f64], p: f64) -> f64 {
146 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
147 let idx = ((sorted.len() as f64 * p).ceil() as usize).min(sorted.len()) - 1;
148 sorted[idx]
149}
150
151fn valid_band_params(n: usize, m: usize, ncomp: usize, nb: usize, coverage: f64) -> bool {
153 n >= 3 && m > 0 && ncomp > 0 && nb > 0 && coverage > 0.0 && coverage < 1.0
154}
155
156struct ScoreStats {
160 means: Vec<f64>,
161 stds: Vec<f64>,
162}
163
164fn compute_score_stats(scores: &FdMatrix, n: usize) -> ScoreStats {
166 let ncomp = scores.ncols();
167 let mut means = vec![0.0; ncomp];
168 let mut stds = vec![0.0; ncomp];
169 for k in 0..ncomp {
170 let col = scores.column(k);
171 let mean = col.iter().sum::<f64>() / n as f64;
172 means[k] = mean;
173 let var = col.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / (n as f64 - 1.0);
174 stds[k] = var.sqrt().max(1e-15);
175 }
176 ScoreStats { means, stds }
177}
178
179fn sample_bootstrap_curve(
181 rng: &mut StdRng,
182 mean: &[f64],
183 rotation: &FdMatrix,
184 stats: &ScoreStats,
185 curve: &mut [f64],
186) {
187 let m = mean.len();
188 let ncomp = stats.means.len();
189 curve[..m].copy_from_slice(&mean[..m]);
190 for k in 0..ncomp {
191 let z: f64 = rng.sample(StandardNormal);
192 let score = stats.means[k] + stats.stds[k] * z;
193 for j in 0..m {
194 curve[j] += score * rotation[(j, k)];
195 }
196 }
197}
198
199fn fpca_pointwise_boot(
201 fpca: &crate::regression::FpcaResult,
202 stats: &ScoreStats,
203 n: usize,
204 m: usize,
205 nb: usize,
206 coverage: f64,
207 seed: u64,
208) -> ToleranceBand {
209 let boot_stds: Vec<Vec<f64>> = iter_maybe_parallel!(0..nb)
210 .map(|b| {
211 let mut rng = StdRng::seed_from_u64(seed + b as u64);
212 let mut curve = vec![0.0; m];
213 let mut sum = vec![0.0; m];
214 let mut sum_sq = vec![0.0; m];
215 for _ in 0..n {
216 sample_bootstrap_curve(&mut rng, &fpca.mean, &fpca.rotation, stats, &mut curve);
217 for j in 0..m {
218 sum[j] += curve[j];
219 sum_sq[j] += curve[j] * curve[j];
220 }
221 }
222 let nf = n as f64;
223 (0..m)
224 .map(|j| (sum_sq[j] / nf - (sum[j] / nf).powi(2)).max(0.0).sqrt())
225 .collect::<Vec<f64>>()
226 })
227 .collect();
228
229 let z = normal_quantile((1.0 + coverage) / 2.0);
230 let center = fpca.mean.clone();
231 let mut half_width = vec![0.0; m];
232 for j in 0..m {
233 let mut stds_j: Vec<f64> = boot_stds.iter().map(|s| s[j]).collect();
234 let pct = percentile_sorted(&mut stds_j, coverage);
235 half_width[j] = z * pct;
236 }
237 build_band(center, half_width)
238}
239
240fn fpca_simultaneous_boot(
242 data: &FdMatrix,
243 fpca: &crate::regression::FpcaResult,
244 stats: &ScoreStats,
245 n: usize,
246 m: usize,
247 nb: usize,
248 coverage: f64,
249 seed: u64,
250) -> ToleranceBand {
251 let (center, data_std) = pointwise_mean_std(data);
252
253 let mut sup_norms: Vec<f64> = iter_maybe_parallel!(0..nb)
254 .map(|b| {
255 let mut rng = StdRng::seed_from_u64(seed + b as u64);
256 let mut max_dev = 0.0_f64;
257 let mut curve = vec![0.0; m];
258 for _ in 0..n {
259 sample_bootstrap_curve(&mut rng, &fpca.mean, &fpca.rotation, stats, &mut curve);
260 let dev = (0..m)
261 .map(|j| (curve[j] - center[j]).abs() / data_std[j].max(1e-15))
262 .fold(0.0_f64, f64::max);
263 max_dev = max_dev.max(dev);
264 }
265 max_dev
266 })
267 .collect();
268
269 let k_factor = percentile_sorted(&mut sup_norms, coverage);
270 let half_width: Vec<f64> = data_std.iter().map(|&s| k_factor * s).collect();
271 build_band(center, half_width)
272}
273
274pub fn fpca_tolerance_band(
304 data: &FdMatrix,
305 ncomp: usize,
306 nb: usize,
307 coverage: f64,
308 band_type: BandType,
309 seed: u64,
310) -> Option<ToleranceBand> {
311 let (n, m) = data.shape();
312 if !valid_band_params(n, m, ncomp, nb, coverage) {
313 return None;
314 }
315
316 let fpca = fdata_to_pc_1d(data, ncomp)?;
317 let stats = compute_score_stats(&fpca.scores, n);
318
319 Some(match band_type {
320 BandType::Pointwise => fpca_pointwise_boot(&fpca, &stats, n, m, nb, coverage, seed),
321 BandType::Simultaneous => {
322 fpca_simultaneous_boot(data, &fpca, &stats, n, m, nb, coverage, seed)
323 }
324 })
325}
326
327fn nonconformity_score(
331 data: &FdMatrix,
332 i: usize,
333 center: &[f64],
334 m: usize,
335 score_type: NonConformityScore,
336) -> f64 {
337 match score_type {
338 NonConformityScore::SupNorm => (0..m)
339 .map(|j| (data[(i, j)] - center[j]).abs())
340 .fold(0.0_f64, f64::max),
341 NonConformityScore::L2 => {
342 let ss: f64 = (0..m).map(|j| (data[(i, j)] - center[j]).powi(2)).sum();
343 ss.sqrt()
344 }
345 }
346}
347
348fn subset_rows(data: &FdMatrix, indices: &[usize], m: usize) -> FdMatrix {
350 let n_sub = indices.len();
351 let mut sub = FdMatrix::zeros(n_sub, m);
352 for (new_i, &old_i) in indices.iter().enumerate() {
353 for j in 0..m {
354 sub[(new_i, j)] = data[(old_i, j)];
355 }
356 }
357 sub
358}
359
360pub fn conformal_prediction_band(
391 data: &FdMatrix,
392 cal_fraction: f64,
393 coverage: f64,
394 score_type: NonConformityScore,
395 seed: u64,
396) -> Option<ToleranceBand> {
397 let (n, m) = data.shape();
398 if n < 4
399 || m == 0
400 || cal_fraction <= 0.0
401 || cal_fraction >= 1.0
402 || coverage <= 0.0
403 || coverage >= 1.0
404 {
405 return None;
406 }
407
408 let n_cal = ((n as f64) * cal_fraction).max(1.0).min((n - 2) as f64) as usize;
409 let n_train = n - n_cal;
410
411 let mut indices: Vec<usize> = (0..n).collect();
413 let mut rng = StdRng::seed_from_u64(seed);
414 indices.shuffle(&mut rng);
415
416 let train_data = subset_rows(data, &indices[..n_train], m);
417 let center = mean_1d(&train_data);
418
419 let cal_idx = &indices[n_train..];
421 let mut scores: Vec<f64> = cal_idx
422 .iter()
423 .map(|&i| nonconformity_score(data, i, ¢er, m, score_type))
424 .collect();
425
426 let level = (((n_cal + 1) as f64 * coverage).ceil() / n_cal as f64).min(1.0);
428 let q = percentile_sorted(&mut scores, level);
429
430 let half_width = match score_type {
432 NonConformityScore::SupNorm => vec![q; m],
433 NonConformityScore::L2 => vec![q / (m as f64).sqrt(); m],
434 };
435
436 Some(build_band(center, half_width))
437}
438
439fn residual_sigma(data: &FdMatrix, center: &[f64], n: usize, m: usize) -> Vec<f64> {
443 let nf = n as f64;
444 (0..m)
445 .map(|j| {
446 let var: f64 = (0..n).map(|i| (data[(i, j)] - center[j]).powi(2)).sum();
447 (var / nf).sqrt().max(1e-15)
448 })
449 .collect()
450}
451
452fn generate_multiplier_weights(
454 rng: &mut StdRng,
455 n: usize,
456 multiplier: MultiplierDistribution,
457) -> Vec<f64> {
458 (0..n)
459 .map(|_| match multiplier {
460 MultiplierDistribution::Gaussian => rng.sample::<f64, _>(StandardNormal),
461 MultiplierDistribution::Rademacher => {
462 if rng.gen_bool(0.5) {
463 1.0
464 } else {
465 -1.0
466 }
467 }
468 })
469 .collect()
470}
471
472pub fn scb_mean_degras(
502 data: &FdMatrix,
503 argvals: &[f64],
504 bandwidth: f64,
505 nb: usize,
506 confidence: f64,
507 multiplier: MultiplierDistribution,
508) -> Option<ToleranceBand> {
509 let (n, m) = data.shape();
510 if n < 3
511 || m == 0
512 || argvals.len() != m
513 || bandwidth <= 0.0
514 || nb == 0
515 || confidence <= 0.0
516 || confidence >= 1.0
517 {
518 return None;
519 }
520
521 let raw_mean = mean_1d(data);
522 let center = local_polynomial(argvals, &raw_mean, argvals, bandwidth, 1, "epanechnikov");
523 let sigma_hat = residual_sigma(data, ¢er, n, m);
524
525 let sqrt_n = (n as f64).sqrt();
526 let mut sup_stats: Vec<f64> = iter_maybe_parallel!(0..nb)
527 .map(|b| {
528 let mut rng = StdRng::seed_from_u64(42 + b as u64);
529 let weights = generate_multiplier_weights(&mut rng, n, multiplier);
530 (0..m)
531 .map(|j| {
532 let z: f64 = (0..n)
533 .map(|i| weights[i] * (data[(i, j)] - center[j]))
534 .sum::<f64>()
535 / (sqrt_n * sigma_hat[j]);
536 z.abs()
537 })
538 .fold(0.0_f64, f64::max)
539 })
540 .collect();
541
542 let c = percentile_sorted(&mut sup_stats, confidence);
543 let half_width: Vec<f64> = sigma_hat.iter().map(|&s| c * s / sqrt_n).collect();
544
545 Some(build_band(center, half_width))
546}
547
548fn apply_link(value: f64, family: ExponentialFamily) -> f64 {
552 match family {
553 ExponentialFamily::Gaussian => value,
554 ExponentialFamily::Binomial => {
555 let p = value.clamp(1e-10, 1.0 - 1e-10);
557 (p / (1.0 - p)).ln()
558 }
559 ExponentialFamily::Poisson => {
560 value.max(1e-10).ln()
562 }
563 }
564}
565
566fn apply_inverse_link(value: f64, family: ExponentialFamily) -> f64 {
568 match family {
569 ExponentialFamily::Gaussian => value,
570 ExponentialFamily::Binomial => {
571 1.0 / (1.0 + (-value).exp())
573 }
574 ExponentialFamily::Poisson => {
575 value.exp()
577 }
578 }
579}
580
581fn transform_data(data: &FdMatrix, family: ExponentialFamily) -> FdMatrix {
583 let (n, m) = data.shape();
584 let mut out = FdMatrix::zeros(n, m);
585 for j in 0..m {
586 for i in 0..n {
587 out[(i, j)] = apply_link(data[(i, j)], family);
588 }
589 }
590 out
591}
592
593fn inverse_link_band(band: ToleranceBand, family: ExponentialFamily) -> ToleranceBand {
595 let lower: Vec<f64> = band
596 .lower
597 .iter()
598 .map(|&v| apply_inverse_link(v, family))
599 .collect();
600 let upper: Vec<f64> = band
601 .upper
602 .iter()
603 .map(|&v| apply_inverse_link(v, family))
604 .collect();
605 let center: Vec<f64> = band
606 .center
607 .iter()
608 .map(|&v| apply_inverse_link(v, family))
609 .collect();
610 let half_width: Vec<f64> = upper
611 .iter()
612 .zip(lower.iter())
613 .map(|(&u, &l)| (u - l) / 2.0)
614 .collect();
615 ToleranceBand {
616 lower,
617 upper,
618 center,
619 half_width,
620 }
621}
622
623pub fn exponential_family_tolerance_band(
663 data: &FdMatrix,
664 family: ExponentialFamily,
665 ncomp: usize,
666 nb: usize,
667 coverage: f64,
668 seed: u64,
669) -> Option<ToleranceBand> {
670 let (n, m) = data.shape();
671 if !valid_band_params(n, m, ncomp, nb, coverage) {
672 return None;
673 }
674
675 let transformed = transform_data(data, family);
676 let band = fpca_tolerance_band(
677 &transformed,
678 ncomp,
679 nb,
680 coverage,
681 BandType::Simultaneous,
682 seed,
683 )?;
684 Some(inverse_link_band(band, family))
685}
686
687pub fn elastic_tolerance_band(
722 data: &FdMatrix,
723 argvals: &[f64],
724 ncomp: usize,
725 nb: usize,
726 coverage: f64,
727 band_type: BandType,
728 max_iter: usize,
729 seed: u64,
730) -> Option<ToleranceBand> {
731 let (n, m) = data.shape();
732 if !valid_band_params(n, m, ncomp, nb, coverage) || argvals.len() != m || max_iter == 0 {
733 return None;
734 }
735
736 let karcher = crate::alignment::karcher_mean(data, argvals, max_iter, 1e-4);
738
739 fpca_tolerance_band(&karcher.aligned_data, ncomp, nb, coverage, band_type, seed)
741}
742
743#[cfg(test)]
746mod tests {
747 use super::*;
748 use crate::simulation::{sim_fundata, EFunType, EValType};
749
750 fn uniform_grid(m: usize) -> Vec<f64> {
751 (0..m).map(|i| i as f64 / (m - 1) as f64).collect()
752 }
753
754 fn make_test_data() -> FdMatrix {
755 let m = 50;
756 let t = uniform_grid(m);
757 sim_fundata(
758 50,
759 &t,
760 5,
761 EFunType::Fourier,
762 EValType::Exponential,
763 Some(42),
764 )
765 }
766
767 #[test]
770 fn test_normal_quantile_symmetry() {
771 for &p in &[0.1, 0.2, 0.3, 0.4] {
772 let q_low = normal_quantile(p);
773 let q_high = normal_quantile(1.0 - p);
774 assert!(
775 (q_low + q_high).abs() < 1e-6,
776 "q({p}) + q({}) = {} (expected ~0)",
777 1.0 - p,
778 q_low + q_high
779 );
780 }
781 }
782
783 #[test]
784 fn test_normal_quantile_known_values() {
785 let q975 = normal_quantile(0.975);
786 assert!(
787 (q975 - 1.96).abs() < 0.01,
788 "q(0.975) = {q975}, expected ~1.96"
789 );
790
791 let q50 = normal_quantile(0.5);
792 assert!(q50.abs() < 1e-10, "q(0.5) = {q50}, expected 0.0");
793
794 let q_invalid = normal_quantile(0.0);
795 assert!(q_invalid.is_nan());
796 let q_invalid2 = normal_quantile(1.0);
797 assert!(q_invalid2.is_nan());
798 }
799
800 #[test]
803 fn test_fpca_band_valid_output() {
804 let data = make_test_data();
805 let m = data.ncols();
806
807 let band = fpca_tolerance_band(&data, 3, 100, 0.95, BandType::Pointwise, 42);
808 let band = band.expect("FPCA band should succeed");
809
810 assert_eq!(band.lower.len(), m);
811 assert_eq!(band.upper.len(), m);
812 assert_eq!(band.center.len(), m);
813 assert_eq!(band.half_width.len(), m);
814 }
815
816 #[test]
817 fn test_fpca_band_lower_less_than_upper() {
818 let data = make_test_data();
819 let band = fpca_tolerance_band(&data, 3, 100, 0.95, BandType::Pointwise, 42).unwrap();
820
821 for j in 0..band.lower.len() {
822 assert!(
823 band.lower[j] < band.upper[j],
824 "lower[{j}] = {} >= upper[{j}] = {}",
825 band.lower[j],
826 band.upper[j]
827 );
828 }
829 }
830
831 #[test]
832 fn test_fpca_band_deterministic() {
833 let data = make_test_data();
834 let b1 = fpca_tolerance_band(&data, 3, 50, 0.95, BandType::Pointwise, 123).unwrap();
835 let b2 = fpca_tolerance_band(&data, 3, 50, 0.95, BandType::Pointwise, 123).unwrap();
836
837 for j in 0..b1.lower.len() {
838 assert_eq!(b1.lower[j], b2.lower[j]);
839 assert_eq!(b1.upper[j], b2.upper[j]);
840 }
841 }
842
843 #[test]
844 fn test_fpca_simultaneous_wider_than_pointwise() {
845 let data = make_test_data();
846 let pw = fpca_tolerance_band(&data, 3, 200, 0.95, BandType::Pointwise, 42).unwrap();
847 let sim = fpca_tolerance_band(&data, 3, 200, 0.95, BandType::Simultaneous, 42).unwrap();
848
849 let pw_mean_hw: f64 = pw.half_width.iter().sum::<f64>() / pw.half_width.len() as f64;
850 let sim_mean_hw: f64 = sim.half_width.iter().sum::<f64>() / sim.half_width.len() as f64;
851
852 assert!(
853 sim_mean_hw > pw_mean_hw,
854 "Simultaneous mean half-width ({sim_mean_hw}) should exceed pointwise ({pw_mean_hw})"
855 );
856 }
857
858 #[test]
859 fn test_fpca_higher_coverage_wider() {
860 let data = make_test_data();
861 let b90 = fpca_tolerance_band(&data, 3, 200, 0.90, BandType::Pointwise, 42).unwrap();
862 let b99 = fpca_tolerance_band(&data, 3, 200, 0.99, BandType::Pointwise, 42).unwrap();
863
864 let hw90: f64 = b90.half_width.iter().sum::<f64>();
865 let hw99: f64 = b99.half_width.iter().sum::<f64>();
866
867 assert!(
868 hw99 > hw90,
869 "99% band total half-width ({hw99}) should exceed 90% ({hw90})"
870 );
871 }
872
873 #[test]
874 fn test_fpca_band_invalid_input() {
875 let data = make_test_data();
876 assert!(fpca_tolerance_band(&data, 0, 100, 0.95, BandType::Pointwise, 42).is_none());
877 assert!(fpca_tolerance_band(&data, 3, 0, 0.95, BandType::Pointwise, 42).is_none());
878 assert!(fpca_tolerance_band(&data, 3, 100, 0.0, BandType::Pointwise, 42).is_none());
879 assert!(fpca_tolerance_band(&data, 3, 100, 1.0, BandType::Pointwise, 42).is_none());
880
881 let tiny = FdMatrix::zeros(2, 5);
882 assert!(fpca_tolerance_band(&tiny, 1, 10, 0.95, BandType::Pointwise, 42).is_none());
883 }
884
885 #[test]
888 fn test_conformal_band_valid_output() {
889 let data = make_test_data();
890 let m = data.ncols();
891
892 let band = conformal_prediction_band(&data, 0.2, 0.95, NonConformityScore::SupNorm, 42);
893 let band = band.expect("Conformal band should succeed");
894
895 assert_eq!(band.lower.len(), m);
896 assert_eq!(band.upper.len(), m);
897 }
898
899 #[test]
900 fn test_conformal_supnorm_constant_width() {
901 let data = make_test_data();
902 let band =
903 conformal_prediction_band(&data, 0.3, 0.95, NonConformityScore::SupNorm, 42).unwrap();
904
905 let first = band.half_width[0];
906 for &hw in &band.half_width {
907 assert!(
908 (hw - first).abs() < 1e-12,
909 "SupNorm band should have constant width"
910 );
911 }
912 }
913
914 #[test]
915 fn test_conformal_l2_constant_width() {
916 let data = make_test_data();
917 let band = conformal_prediction_band(&data, 0.3, 0.95, NonConformityScore::L2, 42).unwrap();
918
919 let first = band.half_width[0];
920 for &hw in &band.half_width {
921 assert!(
922 (hw - first).abs() < 1e-12,
923 "L2 band should have constant width"
924 );
925 }
926 }
927
928 #[test]
929 fn test_conformal_coverage_monotonicity() {
930 let data = make_test_data();
931 let b80 =
932 conformal_prediction_band(&data, 0.3, 0.80, NonConformityScore::SupNorm, 42).unwrap();
933 let b95 =
934 conformal_prediction_band(&data, 0.3, 0.95, NonConformityScore::SupNorm, 42).unwrap();
935
936 assert!(
937 b95.half_width[0] >= b80.half_width[0],
938 "Higher coverage should give wider band"
939 );
940 }
941
942 #[test]
943 fn test_conformal_invalid_input() {
944 let data = make_test_data();
945 assert!(
946 conformal_prediction_band(&data, 0.0, 0.95, NonConformityScore::SupNorm, 42).is_none()
947 );
948 assert!(
949 conformal_prediction_band(&data, 1.0, 0.95, NonConformityScore::SupNorm, 42).is_none()
950 );
951 assert!(
952 conformal_prediction_band(&data, 0.2, 0.0, NonConformityScore::SupNorm, 42).is_none()
953 );
954
955 let tiny = FdMatrix::zeros(3, 5);
956 assert!(
957 conformal_prediction_band(&tiny, 0.2, 0.95, NonConformityScore::SupNorm, 42).is_none()
958 );
959 }
960
961 #[test]
964 fn test_scb_mean_valid_output() {
965 let data = make_test_data();
966 let m = data.ncols();
967 let t = uniform_grid(m);
968
969 let band = scb_mean_degras(&data, &t, 0.2, 100, 0.95, MultiplierDistribution::Gaussian);
970 let band = band.expect("SCB mean should succeed");
971
972 assert_eq!(band.lower.len(), m);
973 assert_eq!(band.upper.len(), m);
974 for j in 0..m {
975 assert!(band.lower[j] < band.upper[j]);
976 }
977 }
978
979 #[test]
980 fn test_scb_gaussian_vs_rademacher() {
981 let data = make_test_data();
982 let m = data.ncols();
983 let t = uniform_grid(m);
984
985 let gauss =
986 scb_mean_degras(&data, &t, 0.2, 200, 0.95, MultiplierDistribution::Gaussian).unwrap();
987 let radem = scb_mean_degras(
988 &data,
989 &t,
990 0.2,
991 200,
992 0.95,
993 MultiplierDistribution::Rademacher,
994 )
995 .unwrap();
996
997 let gauss_mean_hw: f64 = gauss.half_width.iter().sum::<f64>() / m as f64;
999 let radem_mean_hw: f64 = radem.half_width.iter().sum::<f64>() / m as f64;
1000 assert!(gauss_mean_hw > 0.0);
1001 assert!(radem_mean_hw > 0.0);
1002 }
1003
1004 #[test]
1005 fn test_scb_narrows_with_more_data() {
1006 let m = 50;
1007 let t = uniform_grid(m);
1008
1009 let data_small = sim_fundata(
1010 20,
1011 &t,
1012 5,
1013 EFunType::Fourier,
1014 EValType::Exponential,
1015 Some(42),
1016 );
1017 let data_large = sim_fundata(
1018 200,
1019 &t,
1020 5,
1021 EFunType::Fourier,
1022 EValType::Exponential,
1023 Some(42),
1024 );
1025
1026 let band_small = scb_mean_degras(
1027 &data_small,
1028 &t,
1029 0.2,
1030 100,
1031 0.95,
1032 MultiplierDistribution::Gaussian,
1033 )
1034 .unwrap();
1035 let band_large = scb_mean_degras(
1036 &data_large,
1037 &t,
1038 0.2,
1039 100,
1040 0.95,
1041 MultiplierDistribution::Gaussian,
1042 )
1043 .unwrap();
1044
1045 let hw_small: f64 = band_small.half_width.iter().sum::<f64>() / m as f64;
1046 let hw_large: f64 = band_large.half_width.iter().sum::<f64>() / m as f64;
1047
1048 assert!(
1049 hw_large < hw_small,
1050 "SCB should narrow with more data: hw_small={hw_small}, hw_large={hw_large}"
1051 );
1052 }
1053
1054 #[test]
1055 fn test_scb_invalid_input() {
1056 let data = make_test_data();
1057 let t = uniform_grid(data.ncols());
1058
1059 assert!(
1060 scb_mean_degras(&data, &t, 0.0, 100, 0.95, MultiplierDistribution::Gaussian).is_none()
1061 );
1062 assert!(
1063 scb_mean_degras(&data, &t, 0.2, 0, 0.95, MultiplierDistribution::Gaussian).is_none()
1064 );
1065 assert!(
1066 scb_mean_degras(&data, &t, 0.2, 100, 0.0, MultiplierDistribution::Gaussian).is_none()
1067 );
1068 let wrong_t = uniform_grid(data.ncols() + 1);
1070 assert!(scb_mean_degras(
1071 &data,
1072 &wrong_t,
1073 0.2,
1074 100,
1075 0.95,
1076 MultiplierDistribution::Gaussian
1077 )
1078 .is_none());
1079 }
1080
1081 #[test]
1084 fn test_exp_family_gaussian_matches_fpca() {
1085 let data = make_test_data();
1086
1087 let exp_band =
1088 exponential_family_tolerance_band(&data, ExponentialFamily::Gaussian, 3, 100, 0.95, 42)
1089 .unwrap();
1090
1091 let fpca_band =
1092 fpca_tolerance_band(&data, 3, 100, 0.95, BandType::Simultaneous, 42).unwrap();
1093
1094 for j in 0..data.ncols() {
1096 assert!(
1097 (exp_band.lower[j] - fpca_band.lower[j]).abs() < 1e-10,
1098 "Gaussian family should match FPCA at point {j}"
1099 );
1100 assert!(
1101 (exp_band.upper[j] - fpca_band.upper[j]).abs() < 1e-10,
1102 "Gaussian family should match FPCA at point {j}"
1103 );
1104 }
1105 }
1106
1107 #[test]
1108 fn test_exp_family_poisson() {
1109 let m = 30;
1111 let t = uniform_grid(m);
1112 let raw = sim_fundata(
1113 40,
1114 &t,
1115 3,
1116 EFunType::Fourier,
1117 EValType::Exponential,
1118 Some(99),
1119 );
1120
1121 let mut data = FdMatrix::zeros(40, m);
1123 for j in 0..m {
1124 for i in 0..40 {
1125 data[(i, j)] = (raw[(i, j)] + 5.0).max(0.1); }
1127 }
1128
1129 let band =
1130 exponential_family_tolerance_band(&data, ExponentialFamily::Poisson, 3, 50, 0.95, 42);
1131 let band = band.expect("Poisson band should succeed");
1132
1133 for j in 0..m {
1135 assert!(
1136 band.lower[j] > 0.0,
1137 "Poisson lower bound should be positive"
1138 );
1139 assert!(
1140 band.upper[j] > 0.0,
1141 "Poisson upper bound should be positive"
1142 );
1143 }
1144 }
1145
1146 #[test]
1147 fn test_exp_family_binomial() {
1148 let m = 30;
1150 let t = uniform_grid(m);
1151 let raw = sim_fundata(
1152 40,
1153 &t,
1154 3,
1155 EFunType::Fourier,
1156 EValType::Exponential,
1157 Some(77),
1158 );
1159
1160 let mut data = FdMatrix::zeros(40, m);
1161 for j in 0..m {
1162 for i in 0..40 {
1163 data[(i, j)] = 1.0 / (1.0 + (-raw[(i, j)]).exp());
1165 }
1166 }
1167
1168 let band =
1169 exponential_family_tolerance_band(&data, ExponentialFamily::Binomial, 3, 50, 0.95, 42);
1170 let band = band.expect("Binomial band should succeed");
1171
1172 for j in 0..m {
1174 assert!(
1175 band.lower[j] > 0.0 && band.lower[j] < 1.0,
1176 "Binomial lower bound at {j} = {} should be in (0,1)",
1177 band.lower[j]
1178 );
1179 assert!(
1180 band.upper[j] > 0.0 && band.upper[j] < 1.0,
1181 "Binomial upper bound at {j} = {} should be in (0,1)",
1182 band.upper[j]
1183 );
1184 }
1185 }
1186
1187 #[test]
1188 fn test_exp_family_invalid_input() {
1189 let data = make_test_data();
1190 assert!(exponential_family_tolerance_band(
1191 &data,
1192 ExponentialFamily::Gaussian,
1193 0,
1194 100,
1195 0.95,
1196 42
1197 )
1198 .is_none());
1199 assert!(exponential_family_tolerance_band(
1200 &data,
1201 ExponentialFamily::Gaussian,
1202 3,
1203 0,
1204 0.95,
1205 42
1206 )
1207 .is_none());
1208 }
1209
1210 fn make_elastic_test_data() -> (FdMatrix, Vec<f64>) {
1213 let m = 50;
1214 let t = uniform_grid(m);
1215 let data = sim_fundata(
1216 30,
1217 &t,
1218 5,
1219 EFunType::Fourier,
1220 EValType::Exponential,
1221 Some(42),
1222 );
1223 (data, t)
1224 }
1225
1226 #[test]
1227 fn test_elastic_band_valid_output() {
1228 let (data, t) = make_elastic_test_data();
1229 let m = t.len();
1230
1231 let band = elastic_tolerance_band(&data, &t, 3, 50, 0.95, BandType::Pointwise, 5, 42);
1232 let band = band.expect("Elastic band should succeed");
1233
1234 assert_eq!(band.lower.len(), m);
1235 assert_eq!(band.upper.len(), m);
1236 assert_eq!(band.center.len(), m);
1237 assert_eq!(band.half_width.len(), m);
1238 }
1239
1240 #[test]
1241 fn test_elastic_band_lower_less_than_upper() {
1242 let (data, t) = make_elastic_test_data();
1243 let m = t.len();
1244
1245 let band =
1246 elastic_tolerance_band(&data, &t, 3, 50, 0.95, BandType::Pointwise, 5, 42).unwrap();
1247
1248 for j in 0..m {
1249 assert!(
1250 band.lower[j] < band.upper[j],
1251 "lower[{j}] = {} >= upper[{j}] = {}",
1252 band.lower[j],
1253 band.upper[j]
1254 );
1255 }
1256 }
1257
1258 #[test]
1259 fn test_elastic_band_center_within_bounds() {
1260 let (data, t) = make_elastic_test_data();
1261 let m = t.len();
1262
1263 let band =
1264 elastic_tolerance_band(&data, &t, 3, 50, 0.95, BandType::Pointwise, 5, 42).unwrap();
1265
1266 for j in 0..m {
1267 assert!(
1268 band.center[j] >= band.lower[j] && band.center[j] <= band.upper[j],
1269 "center[{j}]={} should be in [{}, {}]",
1270 band.center[j],
1271 band.lower[j],
1272 band.upper[j]
1273 );
1274 }
1275 }
1276
1277 #[test]
1278 fn test_elastic_band_half_width_positive() {
1279 let (data, t) = make_elastic_test_data();
1280
1281 let band =
1282 elastic_tolerance_band(&data, &t, 3, 50, 0.95, BandType::Pointwise, 5, 42).unwrap();
1283
1284 for (j, &hw) in band.half_width.iter().enumerate() {
1285 assert!(hw > 0.0, "half_width[{j}] should be positive, got {hw}");
1286 }
1287 }
1288
1289 #[test]
1290 fn test_elastic_band_simultaneous() {
1291 let (data, t) = make_elastic_test_data();
1292 let m = t.len();
1293
1294 let band = elastic_tolerance_band(&data, &t, 3, 50, 0.95, BandType::Simultaneous, 5, 42);
1295 let band = band.expect("Elastic simultaneous band should succeed");
1296
1297 assert_eq!(band.lower.len(), m);
1298 for j in 0..m {
1299 assert!(band.lower[j] < band.upper[j]);
1300 }
1301 }
1302
1303 #[test]
1304 fn test_elastic_band_deterministic() {
1305 let (data, t) = make_elastic_test_data();
1306
1307 let b1 =
1308 elastic_tolerance_band(&data, &t, 3, 50, 0.95, BandType::Pointwise, 5, 123).unwrap();
1309 let b2 =
1310 elastic_tolerance_band(&data, &t, 3, 50, 0.95, BandType::Pointwise, 5, 123).unwrap();
1311
1312 for j in 0..t.len() {
1313 assert_eq!(
1314 b1.lower[j], b2.lower[j],
1315 "lower[{j}] should be deterministic"
1316 );
1317 assert_eq!(
1318 b1.upper[j], b2.upper[j],
1319 "upper[{j}] should be deterministic"
1320 );
1321 }
1322 }
1323
1324 #[test]
1325 fn test_elastic_band_higher_coverage_wider() {
1326 let (data, t) = make_elastic_test_data();
1327
1328 let b90 =
1329 elastic_tolerance_band(&data, &t, 3, 100, 0.90, BandType::Pointwise, 5, 42).unwrap();
1330 let b99 =
1331 elastic_tolerance_band(&data, &t, 3, 100, 0.99, BandType::Pointwise, 5, 42).unwrap();
1332
1333 let hw90: f64 = b90.half_width.iter().sum();
1334 let hw99: f64 = b99.half_width.iter().sum();
1335
1336 assert!(
1337 hw99 > hw90,
1338 "99% coverage band should be wider than 90%: hw99={hw99:.4}, hw90={hw90:.4}"
1339 );
1340 }
1341
1342 #[test]
1343 fn test_elastic_band_invalid_input() {
1344 let (data, t) = make_elastic_test_data();
1345
1346 let wrong_t = uniform_grid(t.len() + 1);
1348 assert!(
1349 elastic_tolerance_band(&data, &wrong_t, 3, 50, 0.95, BandType::Pointwise, 5, 42)
1350 .is_none()
1351 );
1352
1353 assert!(
1355 elastic_tolerance_band(&data, &t, 3, 50, 0.95, BandType::Pointwise, 0, 42).is_none()
1356 );
1357
1358 assert!(
1360 elastic_tolerance_band(&data, &t, 0, 50, 0.95, BandType::Pointwise, 5, 42).is_none()
1361 );
1362
1363 assert!(
1365 elastic_tolerance_band(&data, &t, 3, 0, 0.95, BandType::Pointwise, 5, 42).is_none()
1366 );
1367
1368 assert!(
1370 elastic_tolerance_band(&data, &t, 3, 50, 0.0, BandType::Pointwise, 5, 42).is_none()
1371 );
1372 assert!(
1373 elastic_tolerance_band(&data, &t, 3, 50, 1.0, BandType::Pointwise, 5, 42).is_none()
1374 );
1375
1376 let tiny = FdMatrix::zeros(2, t.len());
1378 assert!(
1379 elastic_tolerance_band(&tiny, &t, 1, 10, 0.95, BandType::Pointwise, 5, 42).is_none()
1380 );
1381 }
1382}