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