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