1use faer::Side;
9use gam_linalg::faer_ndarray::FaerEigh;
10use gam_math::probability::standard_normal_quantile;
11use gam_math::quantile::quantile_from_sorted;
12use gam_solve::estimate::UnifiedFitResult;
13use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
14use rand::{RngExt, SeedableRng, rngs::StdRng};
15use std::error::Error;
16use std::fmt;
17
18pub const DEFAULT_BAND_LEVEL: f64 = 0.95;
20pub const DEFAULT_SIMULATIONS: usize = 10_000;
22pub const DEFAULT_SIMULATION_SEED: u64 = 12_345;
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27pub enum CovarianceSource {
28 Conditional,
30 SmoothingCorrected,
32 Frequentist,
34}
35
36impl fmt::Display for CovarianceSource {
37 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
38 formatter.write_str(match self {
39 Self::Conditional => "conditional",
40 Self::SmoothingCorrected => "smoothing-corrected",
41 Self::Frequentist => "frequentist",
42 })
43 }
44}
45
46#[derive(Clone, Copy, Debug)]
48pub struct SelectedCovariance<'a> {
49 pub source: CovarianceSource,
50 pub matrix: ArrayView2<'a, f64>,
51}
52
53pub fn select_covariance<'a>(
59 fit: &'a UnifiedFitResult,
60 source: CovarianceSource,
61) -> Result<SelectedCovariance<'a>, EffectError> {
62 let matrix =
63 covariance_by_source(fit, source).ok_or(EffectError::MissingCovariance { source })?;
64
65 Ok(SelectedCovariance {
66 source,
67 matrix: matrix.view(),
68 })
69}
70
71fn covariance_by_source(fit: &UnifiedFitResult, source: CovarianceSource) -> Option<&Array2<f64>> {
72 match source {
73 CovarianceSource::Conditional => fit.beta_covariance(),
74 CovarianceSource::SmoothingCorrected => fit.beta_covariance_corrected(),
75 CovarianceSource::Frequentist => fit.beta_covariance_ve(),
76 }
77}
78
79#[derive(Clone, Copy, Debug, PartialEq)]
81pub struct PointwiseBandOptions {
82 pub level: f64,
83}
84
85impl Default for PointwiseBandOptions {
86 fn default() -> Self {
87 Self {
88 level: DEFAULT_BAND_LEVEL,
89 }
90 }
91}
92
93#[derive(Clone, Copy, Debug, PartialEq)]
95pub struct SimultaneousBandOptions {
96 pub level: f64,
97 pub simulations: usize,
98 pub seed: u64,
99}
100
101impl Default for SimultaneousBandOptions {
102 fn default() -> Self {
103 Self {
104 level: DEFAULT_BAND_LEVEL,
105 simulations: DEFAULT_SIMULATIONS,
106 seed: DEFAULT_SIMULATION_SEED,
107 }
108 }
109}
110
111#[derive(Clone, Copy, Debug, PartialEq)]
113pub enum BandOptions {
114 Pointwise(PointwiseBandOptions),
116 Simultaneous(SimultaneousBandOptions),
119}
120
121impl Default for BandOptions {
122 fn default() -> Self {
123 Self::Pointwise(PointwiseBandOptions::default())
124 }
125}
126
127#[derive(Clone, Debug, PartialEq)]
129pub struct EffectReport {
130 pub center: Array1<f64>,
131 pub se: Array1<f64>,
132 pub lower: Array1<f64>,
133 pub upper: Array1<f64>,
134 pub critical: f64,
135}
136
137#[derive(Clone, Debug, PartialEq)]
139pub enum EffectError {
140 MissingCovariance {
141 source: CovarianceSource,
142 },
143 EmptyCoefficients,
144 EmptyContrastDesign,
145 InvalidLevel {
146 level: f64,
147 },
148 InvalidSimulationCount,
149 CovarianceShape {
150 rows: usize,
151 columns: usize,
152 expected: usize,
153 },
154 ContrastShape {
155 columns: usize,
156 expected: usize,
157 },
158 NonFiniteInput {
159 input: &'static str,
160 },
161 NonSymmetricCovariance {
162 row: usize,
163 column: usize,
164 difference: f64,
165 tolerance: f64,
166 },
167 IndefiniteCovariance {
168 matrix: &'static str,
169 minimum_eigenvalue: f64,
170 tolerance: f64,
171 },
172 Eigendecomposition {
173 matrix: &'static str,
174 detail: String,
175 },
176 NormalQuantile {
177 detail: String,
178 },
179}
180
181impl fmt::Display for EffectError {
182 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
183 match self {
184 Self::MissingCovariance { source } => {
185 write!(formatter, "fit has no {source} coefficient covariance")
186 }
187 Self::EmptyCoefficients => {
188 formatter.write_str("beta must contain at least one coefficient")
189 }
190 Self::EmptyContrastDesign => {
191 formatter.write_str("contrast design must contain at least one row")
192 }
193 Self::InvalidLevel { level } => {
194 write!(
195 formatter,
196 "confidence level must be finite and in (0, 1), got {level}"
197 )
198 }
199 Self::InvalidSimulationCount => {
200 formatter.write_str("simultaneous-band simulation count must be positive")
201 }
202 Self::CovarianceShape {
203 rows,
204 columns,
205 expected,
206 } => write!(
207 formatter,
208 "covariance must have shape {expected}x{expected}, got {rows}x{columns}"
209 ),
210 Self::ContrastShape { columns, expected } => write!(
211 formatter,
212 "contrast design must have {expected} columns, got {columns}"
213 ),
214 Self::NonFiniteInput { input } => {
215 write!(formatter, "{input} contains a non-finite value")
216 }
217 Self::NonSymmetricCovariance {
218 row,
219 column,
220 difference,
221 tolerance,
222 } => write!(
223 formatter,
224 "covariance is not symmetric at ({row}, {column}): absolute difference {difference:e} exceeds tolerance {tolerance:e}"
225 ),
226 Self::IndefiniteCovariance {
227 matrix,
228 minimum_eigenvalue,
229 tolerance,
230 } => write!(
231 formatter,
232 "{matrix} is materially indefinite: minimum eigenvalue {minimum_eigenvalue:e} is below -{tolerance:e}"
233 ),
234 Self::Eigendecomposition { matrix, detail } => {
235 write!(formatter, "{matrix} eigendecomposition failed: {detail}")
236 }
237 Self::NormalQuantile { detail } => {
238 write!(
239 formatter,
240 "normal critical-value calculation failed: {detail}"
241 )
242 }
243 }
244 }
245}
246
247impl Error for EffectError {}
248
249pub fn effect_report_from_fit(
252 fit: &UnifiedFitResult,
253 source: CovarianceSource,
254 contrast_design: ArrayView2<'_, f64>,
255 options: BandOptions,
256) -> Result<EffectReport, EffectError> {
257 let selected = select_covariance(fit, source)?;
258 effect_report(fit.beta.view(), selected.matrix, contrast_design, options)
259}
260
261pub fn effect_report(
272 beta: ArrayView1<'_, f64>,
273 covariance: ArrayView2<'_, f64>,
274 contrast_design: ArrayView2<'_, f64>,
275 options: BandOptions,
276) -> Result<EffectReport, EffectError> {
277 validate_inputs(beta, covariance, contrast_design, options)?;
278
279 let covariance = validated_symmetric_matrix(covariance)?;
280 let center = contrast_design.dot(&beta);
281 let (se, critical) = match options {
282 BandOptions::Pointwise(pointwise) => {
283 let se = pointwise_standard_errors(contrast_design, covariance.view())?;
284 let critical = standard_normal_quantile(0.5 * (1.0 + pointwise.level))
285 .map_err(|detail| EffectError::NormalQuantile { detail })?;
286 (se, critical)
287 }
288 BandOptions::Simultaneous(simultaneous) => {
289 let curve_factor = simultaneous_curve_factor(contrast_design, covariance.view())?;
290 let se = factor_standard_errors(&curve_factor);
291 let critical = simultaneous_critical(
292 &curve_factor,
293 se.view(),
294 simultaneous.level,
295 simultaneous.simulations,
296 simultaneous.seed,
297 );
298 (se, critical)
299 }
300 };
301
302 let half_width = se.mapv(|value| critical * value);
303 let lower = ¢er - &half_width;
304 let upper = ¢er + &half_width;
305 Ok(EffectReport {
306 center,
307 se,
308 lower,
309 upper,
310 critical,
311 })
312}
313
314fn pointwise_standard_errors(
315 contrast_design: ArrayView2<'_, f64>,
316 covariance: ArrayView2<'_, f64>,
317) -> Result<Array1<f64>, EffectError> {
318 let p = covariance.nrows();
319 let mut se = Array1::<f64>::zeros(contrast_design.nrows());
320 let mut product = vec![0.0_f64; p];
321 for (row_index, row) in contrast_design.rows().into_iter().enumerate() {
322 product.fill(0.0);
323 for covariance_row in 0..p {
324 for column in 0..p {
325 product[covariance_row] += covariance[[covariance_row, column]] * row[column];
326 }
327 }
328 let variance = row
329 .iter()
330 .zip(&product)
331 .map(|(&loading, &projected)| loading * projected)
332 .sum::<f64>();
333 let scale = row
334 .iter()
335 .zip(&product)
336 .map(|(&loading, &projected)| (loading * projected).abs())
337 .sum::<f64>();
338 let tolerance = roundoff_tolerance(scale, p);
339 if variance < -tolerance {
340 return Err(EffectError::IndefiniteCovariance {
341 matrix: "projected curve covariance",
342 minimum_eigenvalue: variance,
343 tolerance,
344 });
345 }
346 se[row_index] = variance.max(0.0).sqrt();
347 }
348 Ok(se)
349}
350
351fn simultaneous_curve_factor(
352 contrast_design: ArrayView2<'_, f64>,
353 covariance: ArrayView2<'_, f64>,
354) -> Result<Array2<f64>, EffectError> {
355 if covariance.nrows() <= contrast_design.nrows() {
356 let coefficient_eigen = psd_eigendecomposition(covariance, "coefficient covariance")?;
357 let coefficient_factor = covariance_factor(&coefficient_eigen);
358 let factor = contrast_design.dot(&coefficient_factor);
359 if factor.iter().any(|value| !value.is_finite()) {
360 return Err(EffectError::NonFiniteInput {
361 input: "projected curve factor",
362 });
363 }
364 return Ok(factor);
365 }
366
367 let mut curve_covariance = contrast_design.dot(&covariance).dot(&contrast_design.t());
368 if curve_covariance.iter().any(|value| !value.is_finite()) {
369 return Err(EffectError::NonFiniteInput {
370 input: "projected curve covariance",
371 });
372 }
373 symmetrize_in_place(&mut curve_covariance);
374 let curve_eigen =
375 psd_eigendecomposition(curve_covariance.view(), "projected curve covariance")?;
376 Ok(covariance_factor(&curve_eigen))
377}
378
379fn validate_inputs(
380 beta: ArrayView1<'_, f64>,
381 covariance: ArrayView2<'_, f64>,
382 contrast_design: ArrayView2<'_, f64>,
383 options: BandOptions,
384) -> Result<(), EffectError> {
385 if beta.is_empty() {
386 return Err(EffectError::EmptyCoefficients);
387 }
388 if contrast_design.nrows() == 0 {
389 return Err(EffectError::EmptyContrastDesign);
390 }
391 let p = beta.len();
392 if covariance.dim() != (p, p) {
393 return Err(EffectError::CovarianceShape {
394 rows: covariance.nrows(),
395 columns: covariance.ncols(),
396 expected: p,
397 });
398 }
399 if contrast_design.ncols() != p {
400 return Err(EffectError::ContrastShape {
401 columns: contrast_design.ncols(),
402 expected: p,
403 });
404 }
405 if beta.iter().any(|value| !value.is_finite()) {
406 return Err(EffectError::NonFiniteInput { input: "beta" });
407 }
408 if covariance.iter().any(|value| !value.is_finite()) {
409 return Err(EffectError::NonFiniteInput {
410 input: "covariance",
411 });
412 }
413 if contrast_design.iter().any(|value| !value.is_finite()) {
414 return Err(EffectError::NonFiniteInput {
415 input: "contrast design",
416 });
417 }
418
419 let (level, simulations) = match options {
420 BandOptions::Pointwise(pointwise) => (pointwise.level, None),
421 BandOptions::Simultaneous(simultaneous) => {
422 (simultaneous.level, Some(simultaneous.simulations))
423 }
424 };
425 if !level.is_finite() || !(0.0..1.0).contains(&level) || level == 0.0 {
426 return Err(EffectError::InvalidLevel { level });
427 }
428 if simulations == Some(0) {
429 return Err(EffectError::InvalidSimulationCount);
430 }
431 Ok(())
432}
433
434struct PsdEigen {
435 vectors: Array2<f64>,
436 active: Vec<(usize, f64)>,
437}
438
439fn validated_symmetric_matrix(matrix: ArrayView2<'_, f64>) -> Result<Array2<f64>, EffectError> {
440 let n = matrix.nrows();
441 let scale = matrix
442 .iter()
443 .fold(0.0_f64, |maximum, value| maximum.max(value.abs()));
444 let symmetry_tolerance = roundoff_tolerance(scale, n);
445 let mut symmetric = matrix.to_owned();
446 for row in 0..n {
447 for column in 0..row {
448 let difference = (matrix[[row, column]] - matrix[[column, row]]).abs();
449 if difference > symmetry_tolerance {
450 return Err(EffectError::NonSymmetricCovariance {
451 row,
452 column,
453 difference,
454 tolerance: symmetry_tolerance,
455 });
456 }
457 let average = 0.5 * (matrix[[row, column]] + matrix[[column, row]]);
458 symmetric[[row, column]] = average;
459 symmetric[[column, row]] = average;
460 }
461 }
462 Ok(symmetric)
463}
464
465fn symmetrize_in_place(matrix: &mut Array2<f64>) {
466 for row in 0..matrix.nrows() {
467 for column in 0..row {
468 let average = 0.5 * (matrix[[row, column]] + matrix[[column, row]]);
469 matrix[[row, column]] = average;
470 matrix[[column, row]] = average;
471 }
472 }
473}
474
475fn psd_eigendecomposition(
476 matrix: ArrayView2<'_, f64>,
477 label: &'static str,
478) -> Result<PsdEigen, EffectError> {
479 let n = matrix.nrows();
480 let symmetric = validated_symmetric_matrix(matrix)?;
481
482 let (values, vectors) =
483 symmetric
484 .eigh(Side::Lower)
485 .map_err(|error| EffectError::Eigendecomposition {
486 matrix: label,
487 detail: error.to_string(),
488 })?;
489 let spectral_scale = values
490 .iter()
491 .fold(0.0_f64, |maximum, value| maximum.max(value.abs()));
492 let tolerance = roundoff_tolerance(spectral_scale, n);
493 let minimum_eigenvalue = values.iter().copied().fold(f64::INFINITY, f64::min);
494 if minimum_eigenvalue < -tolerance {
495 return Err(EffectError::IndefiniteCovariance {
496 matrix: label,
497 minimum_eigenvalue,
498 tolerance,
499 });
500 }
501 let active = values
502 .iter()
503 .copied()
504 .enumerate()
505 .filter_map(|(column, value)| (value > tolerance).then(|| (column, value.sqrt())))
506 .collect();
507 Ok(PsdEigen { vectors, active })
508}
509
510fn roundoff_tolerance(scale: f64, dimension: usize) -> f64 {
511 scale * f64::EPSILON * dimension.max(1) as f64
512}
513
514fn covariance_factor(eigen: &PsdEigen) -> Array2<f64> {
515 let mut factor = Array2::zeros((eigen.vectors.nrows(), eigen.active.len()));
516 for (active_column, &(eigen_column, eigenvalue_sqrt)) in eigen.active.iter().enumerate() {
517 for row in 0..eigen.vectors.nrows() {
518 factor[[row, active_column]] = eigen.vectors[[row, eigen_column]] * eigenvalue_sqrt;
519 }
520 }
521 factor
522}
523
524fn factor_standard_errors(curve_factor: &Array2<f64>) -> Array1<f64> {
525 let variances = Array1::from_iter(
526 curve_factor
527 .rows()
528 .into_iter()
529 .map(|row| row.iter().map(|value| value * value).sum::<f64>()),
530 );
531 let variance_scale = variances.iter().copied().fold(0.0_f64, f64::max);
532 let variance_tolerance = roundoff_tolerance(variance_scale, curve_factor.ncols());
533 variances.mapv(|variance| {
534 if variance > variance_tolerance {
535 variance.sqrt()
536 } else {
537 0.0
538 }
539 })
540}
541
542fn simultaneous_critical(
543 curve_factor: &Array2<f64>,
544 se: ArrayView1<'_, f64>,
545 level: f64,
546 simulations: usize,
547 seed: u64,
548) -> f64 {
549 if curve_factor.ncols() == 0 {
550 return 0.0;
551 }
552
553 let mut standardized_factor = curve_factor.clone();
554 for row in 0..standardized_factor.nrows() {
555 if se[row] == 0.0 {
556 standardized_factor.row_mut(row).fill(0.0);
557 } else {
558 standardized_factor
559 .row_mut(row)
560 .mapv_inplace(|value| value / se[row]);
561 }
562 }
563
564 let mut rng = StdRng::seed_from_u64(seed);
565 let mut normal_coordinates = vec![0.0; curve_factor.ncols()];
566 let mut maxima = Vec::with_capacity(simulations);
567 for _ in 0..simulations {
568 fill_standard_normals(&mut rng, &mut normal_coordinates);
569 let maximum = standardized_factor
570 .rows()
571 .into_iter()
572 .map(|row| {
573 row.iter()
574 .zip(&normal_coordinates)
575 .map(|(&loading, &coordinate)| loading * coordinate)
576 .sum::<f64>()
577 .abs()
578 })
579 .fold(0.0_f64, f64::max);
580 maxima.push(maximum);
581 }
582 maxima.sort_by(f64::total_cmp);
583 quantile_from_sorted(&maxima, level)
584}
585
586fn fill_standard_normals(rng: &mut StdRng, output: &mut [f64]) {
587 for pair in output.chunks_mut(2) {
588 let uniform_radius = rng.random::<f64>().max(f64::MIN_POSITIVE);
589 let uniform_angle = rng.random::<f64>();
590 let radius = (-2.0 * uniform_radius.ln()).sqrt();
591 let angle = std::f64::consts::TAU * uniform_angle;
592 pair[0] = radius * angle.cos();
593 if pair.len() == 2 {
594 pair[1] = radius * angle.sin();
595 }
596 }
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602 use approx::assert_abs_diff_eq;
603 use ndarray::array;
604
605 #[test]
606 fn closed_form_centers_and_standard_errors() {
607 let beta = array![2.0, -1.0];
608 let covariance = array![[4.0, 1.0], [1.0, 9.0]];
609 let contrast = array![[1.0, 0.0], [1.0, 2.0]];
610
611 let report = effect_report(
612 beta.view(),
613 covariance.view(),
614 contrast.view(),
615 BandOptions::default(),
616 )
617 .unwrap();
618
619 assert_abs_diff_eq!(report.center[0], 2.0, epsilon = 1e-14);
620 assert_abs_diff_eq!(report.center[1], 0.0, epsilon = 1e-14);
621 assert_abs_diff_eq!(report.se[0], 2.0, epsilon = 1e-14);
622 assert_abs_diff_eq!(report.se[1], 44.0_f64.sqrt(), epsilon = 1e-13);
623 }
624
625 #[test]
626 fn singular_psd_simulation_is_reproducible() {
627 let beta = array![0.5, -0.5];
628 let covariance = array![[1.0, 1.0], [1.0, 1.0]];
629 let contrast = array![[1.0, 0.0], [0.0, 1.0], [1.0, -1.0]];
630 let options = BandOptions::Simultaneous(SimultaneousBandOptions {
631 simulations: 2_000,
632 ..SimultaneousBandOptions::default()
633 });
634
635 let first =
636 effect_report(beta.view(), covariance.view(), contrast.view(), options).unwrap();
637 let second =
638 effect_report(beta.view(), covariance.view(), contrast.view(), options).unwrap();
639
640 assert_eq!(first, second);
641 assert_abs_diff_eq!(first.se[0], 1.0, epsilon = 1e-14);
642 assert_abs_diff_eq!(first.se[1], 1.0, epsilon = 1e-14);
643 assert_eq!(first.se[2], 0.0);
644 assert!(first.critical.is_finite());
645 }
646
647 #[test]
648 fn materially_indefinite_covariance_is_rejected() {
649 let error = effect_report(
650 array![0.0, 0.0].view(),
651 array![[1.0, 0.0], [0.0, -0.1]].view(),
652 array![[0.0, 1.0]].view(),
653 BandOptions::default(),
654 )
655 .unwrap_err();
656
657 assert!(matches!(
658 error,
659 EffectError::IndefiniteCovariance {
660 matrix: "projected curve covariance",
661 ..
662 }
663 ));
664 }
665
666 #[test]
667 fn pointwise_band_uses_two_sided_normal_critical_value() {
668 let report = effect_report(
669 array![0.0].view(),
670 array![[1.0]].view(),
671 array![[1.0]].view(),
672 BandOptions::default(),
673 )
674 .unwrap();
675
676 assert_abs_diff_eq!(report.critical, 1.959_963_984_540_054, epsilon = 1e-9);
683 }
684
685 #[test]
686 fn simultaneous_critical_is_beta_and_contrast_sign_invariant() {
687 let covariance = array![[2.0, 0.25], [0.25, 1.0]];
688 let contrast = array![[1.0, 0.5], [-0.25, 1.0]];
689 let options = BandOptions::Simultaneous(SimultaneousBandOptions {
690 simulations: 1_000,
691 ..SimultaneousBandOptions::default()
692 });
693 let first = effect_report(
694 array![1.0, -2.0].view(),
695 covariance.view(),
696 contrast.view(),
697 options,
698 )
699 .unwrap();
700 let shifted = effect_report(
701 array![8.0, 3.0].view(),
702 covariance.view(),
703 contrast.view(),
704 options,
705 )
706 .unwrap();
707 let signed = effect_report(
708 array![1.0, -2.0].view(),
709 covariance.view(),
710 (-&contrast).view(),
711 options,
712 )
713 .unwrap();
714
715 assert_eq!(first.critical, shifted.critical);
716 assert_eq!(first.critical, signed.critical);
717 assert_eq!(first.se, shifted.se);
718 assert_eq!(first.se, signed.se);
719 for row in 0..contrast.nrows() {
720 assert_abs_diff_eq!(signed.center[row], -first.center[row], epsilon = 1e-14);
721 assert_abs_diff_eq!(signed.lower[row], -first.upper[row], epsilon = 1e-14);
722 assert_abs_diff_eq!(signed.upper[row], -first.lower[row], epsilon = 1e-14);
723 }
724 }
725}