1#![allow(
6 clippy::cast_precision_loss,
7 clippy::cast_possible_truncation,
8 clippy::needless_range_loop,
9 clippy::too_many_lines,
10 clippy::many_single_char_names
11)]
12
13use std::sync::Arc;
14
15use antecedent_core::{CausalRng, ExecutionContext, KernelPolicy};
16use antecedent_estimate::{
17 BayesianGCompWorkspace, BayesianGComputationAte, CausalPosterior, PreparedBayesianProblem,
18};
19use antecedent_identify::IdentificationStatus;
20use antecedent_kernels::{PosteriorReduceOp, reduce_posterior_draws, standard_normal};
21use antecedent_prob::{
22 BayesDesignRef, BayesFitOptions, BayesLikelihood, ExternalPriorSource, InferenceBackend,
23 LaplaceGlmBackend, LaplaceWorkspace, PriorSensitivitySummary, PriorSet,
24 compose_external_priors_with_alphas,
25};
26use antecedent_stats::GlmFamily;
27
28use crate::common::RefutationReport;
29use crate::error::ValidationError;
30
31#[derive(Clone, Debug)]
33pub struct PredictiveCheckReport {
34 pub kind: PredictiveCheckKind,
36 pub observed: f64,
38 pub predictive_mean: f64,
40 pub predictive_sd: f64,
42 pub p_value: f64,
44 pub n_sims: u32,
46}
47
48impl PredictiveCheckReport {
49 #[must_use]
51 pub fn to_refutation_report(&self, original_ate: f64, alpha: f64) -> RefutationReport {
52 let name = match self.kind {
53 PredictiveCheckKind::Prior => "prior_predictive",
54 PredictiveCheckKind::Posterior => "posterior_predictive",
55 };
56 let passed = self.p_value.is_finite() && self.p_value >= alpha;
57 RefutationReport {
58 refuter: Arc::from(name),
59 original_ate,
60 refuted_ate: self.predictive_mean,
61 comparison: self.p_value,
62 informative: true,
63 passed,
64 failure_condition: if passed {
65 None
66 } else {
67 Some(Arc::from(format!(
68 "predictive check failed (p={} < alpha={alpha})",
69 self.p_value
70 )))
71 },
72 replicates: self.n_sims,
73 }
74 }
75}
76
77#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
79pub enum PredictiveCheckKind {
80 Prior,
82 Posterior,
84}
85
86#[derive(Clone, Debug)]
89pub struct PriorPredictiveCheck {
90 pub n_sims: u32,
92 pub seed: u64,
94 pub family: GlmFamily,
96}
97
98impl Default for PriorPredictiveCheck {
99 fn default() -> Self {
100 Self::new()
101 }
102}
103
104impl PriorPredictiveCheck {
105 #[must_use]
107 pub fn new() -> Self {
108 Self { n_sims: 200, seed: 0, family: GlmFamily::GaussianIdentity }
109 }
110
111 pub fn check(
119 &self,
120 problem: &PreparedBayesianProblem,
121 ctx: &ExecutionContext,
122 ) -> Result<PredictiveCheckReport, ValidationError> {
123 let p = problem.design.ncols;
124 let prior = PriorSet::weakly_informative(p);
125 self.check_with_prior(problem, &prior, ctx)
126 }
127
128 pub fn check_with_prior(
134 &self,
135 problem: &PreparedBayesianProblem,
136 prior: &PriorSet,
137 _ctx: &ExecutionContext,
138 ) -> Result<PredictiveCheckReport, ValidationError> {
139 let n = problem.design.nrows;
140 let p = problem.design.ncols;
141 if n == 0 || p == 0 {
142 return Err(ValidationError::estimation_msg("empty design for PPC"));
143 }
144 let observed = problem.design.outcome.iter().sum::<f64>() / n as f64;
145 let mut rng = CausalRng::from_seed(self.seed);
146 let coef_prior = prior.gaussian_coefficients().ok_or_else(|| {
147 ValidationError::estimation_msg("prior missing Gaussian coefficients for PPC")
148 })?;
149 if coef_prior.len() != p {
150 return Err(ValidationError::estimation_msg(
151 "prior coefficient dimension mismatch for PPC",
152 ));
153 }
154 let mut summaries = Vec::with_capacity(self.n_sims as usize);
155 let mut beta = vec![0.0; p];
156 for _ in 0..self.n_sims {
157 for c in 0..p {
159 beta[c] =
160 coef_prior.mean[c] + coef_prior.variance[c].sqrt() * standard_normal(&mut rng);
161 }
162 let mut mean_y = 0.0;
163 for r in 0..n {
164 let mut eta = 0.0;
165 for c in 0..p {
166 eta += problem.design.matrix[c * n + r] * beta[c];
167 }
168 mean_y += self.family.mean_from_eta(eta);
169 }
170 summaries.push(mean_y / n as f64);
171 }
172 Ok(summarize_check(PredictiveCheckKind::Prior, observed, &summaries, self.n_sims))
173 }
174}
175
176#[derive(Clone, Debug)]
178pub struct PosteriorPredictiveCheck {
179 pub n_sims: u32,
181 pub family: GlmFamily,
183}
184
185impl Default for PosteriorPredictiveCheck {
186 fn default() -> Self {
187 Self::new()
188 }
189}
190
191impl PosteriorPredictiveCheck {
192 #[must_use]
194 pub fn new() -> Self {
195 Self { n_sims: 200, family: GlmFamily::GaussianIdentity }
196 }
197
198 pub fn check(
204 &self,
205 problem: &PreparedBayesianProblem,
206 posterior: &CausalPosterior,
207 ) -> Result<PredictiveCheckReport, ValidationError> {
208 let n = problem.design.nrows;
209 let p = problem.design.ncols;
210 let observed = problem.design.outcome.iter().sum::<f64>() / n as f64;
211 let n_draws = posterior.draws.n_draws.min(self.n_sims as usize);
212 if n_draws == 0 {
213 return Err(ValidationError::estimation_msg("no posterior draws for PPC"));
214 }
215 let mut summaries = Vec::with_capacity(n_draws);
216 for d in 0..n_draws {
217 let mut mean_y = 0.0;
218 for r in 0..n {
219 let mut eta = 0.0;
220 for c in 0..p {
221 let x = problem.design.matrix[c * n + r];
222 let b = posterior.draws.get(d, c).map_err(ValidationError::from)?;
223 eta += x * b;
224 }
225 mean_y += self.family.mean_from_eta(eta);
226 }
227 summaries.push(mean_y / n as f64);
228 }
229 Ok(summarize_check(PredictiveCheckKind::Posterior, observed, &summaries, n_draws as u32))
230 }
231}
232
233pub const DEFAULT_MAX_RELATIVE_PRIOR_RANGE: f64 = 0.5;
235
236#[derive(Clone, Debug)]
238pub struct PriorSensitivity {
239 pub scales: Arc<[f64]>,
241 pub alphas: Arc<[f64]>,
243 pub max_relative_range: f64,
246}
247
248#[derive(Clone, Copy, Debug)]
250pub struct ExternalAlphaSensitivity<'a> {
251 pub sources: &'a [ExternalPriorSource],
253 pub alphas_applied: &'a [f64],
255}
256
257impl Default for PriorSensitivity {
258 fn default() -> Self {
259 Self::standard_grid()
260 }
261}
262
263impl PriorSensitivity {
264 #[must_use]
266 pub fn standard_grid() -> Self {
267 Self {
268 scales: Arc::from(vec![0.5, 1.0, 2.0, 5.0, 10.0, 20.0]),
269 alphas: Arc::from([]),
270 max_relative_range: DEFAULT_MAX_RELATIVE_PRIOR_RANGE,
271 }
272 }
273
274 #[must_use]
278 pub fn standard_alpha_grid() -> Self {
279 Self {
280 scales: Arc::from([]),
281 alphas: Arc::from(vec![0.0, 0.25, 0.5, 0.75, 1.0]),
282 max_relative_range: DEFAULT_MAX_RELATIVE_PRIOR_RANGE,
283 }
284 }
285
286 fn grid_len(&self) -> usize {
287 if self.alphas.is_empty() { self.scales.len() } else { self.alphas.len() }
288 }
289
290 pub fn evaluate(
296 &self,
297 estimator: &BayesianGComputationAte,
298 problem: &PreparedBayesianProblem,
299 identification: IdentificationStatus,
300 workspace: &mut BayesianGCompWorkspace,
301 ctx: &ExecutionContext,
302 ) -> Result<(PriorSensitivitySummary, Vec<CausalPosterior>), ValidationError> {
303 if self.scales.is_empty() {
304 return Err(ValidationError::estimation_msg(
305 "prior sensitivity scale grid is empty (use evaluate_external_alpha for α mode)",
306 ));
307 }
308 let mut means = Vec::with_capacity(self.scales.len());
309 let mut sds = Vec::with_capacity(self.scales.len());
310 let mut posts = Vec::with_capacity(self.scales.len());
311 for &scale in self.scales.iter() {
312 let est = BayesianGComputationAte {
313 prior_scale: scale,
314 n_draws: estimator.n_draws.min(200),
315 seed: estimator.seed,
316 backend: estimator.backend,
317 likelihood: estimator.likelihood,
318 overlap: estimator.overlap,
319 prior: None,
320 };
321 let post = est.fit(problem, identification, workspace, ctx).map_err(|e| {
322 ValidationError::estimation_msg(format!("prior sensitivity fit failed: {e}"))
323 })?;
324 let eq = post.effect_column().ok_or_else(|| {
325 ValidationError::estimation_msg("missing effect column in sensitivity fit")
326 })?;
327 means.push(post.summaries.mean[eq]);
328 sds.push(post.summaries.sd[eq]);
329 posts.push(post);
330 }
331 Ok((
332 PriorSensitivitySummary {
333 prior_scales: Arc::clone(&self.scales),
334 alphas: Arc::from([]),
335 effect_means: Arc::from(means),
336 effect_sds: Arc::from(sds),
337 },
338 posts,
339 ))
340 }
341
342 pub fn evaluate_external_alpha(
350 &self,
351 estimator: &BayesianGComputationAte,
352 problem: &PreparedBayesianProblem,
353 identification: IdentificationStatus,
354 workspace: &mut BayesianGCompWorkspace,
355 ctx: &ExecutionContext,
356 external: ExternalAlphaSensitivity<'_>,
357 ) -> Result<(PriorSensitivitySummary, Vec<CausalPosterior>), ValidationError> {
358 if self.alphas.is_empty() {
359 return Err(ValidationError::estimation_msg("prior sensitivity alpha grid is empty"));
360 }
361 if external.sources.len() != external.alphas_applied.len() {
362 return Err(ValidationError::estimation_msg(
363 "evaluate_external_alpha: sources / alphas_applied length mismatch",
364 ));
365 }
366 let n_coef = problem.design.ncols;
367 let baseline = PriorSet::weakly_informative(n_coef);
368 let requested: Vec<f64> = external.sources.iter().map(|s| s.weight.alpha).collect();
369 let mut means = Vec::with_capacity(self.alphas.len());
370 let mut sds = Vec::with_capacity(self.alphas.len());
371 let mut posts = Vec::with_capacity(self.alphas.len());
372 for &mult in self.alphas.iter() {
373 if !mult.is_finite() || !(0.0..=1.0).contains(&mult) {
374 return Err(ValidationError::estimation_msg(
375 "prior sensitivity alpha multiplier must be finite and in [0, 1]",
376 ));
377 }
378 let scaled: Vec<f64> =
379 external.alphas_applied.iter().map(|&a| (a * mult).clamp(0.0, 1.0)).collect();
380 let composed = compose_external_priors_with_alphas(
381 external.sources,
382 &requested,
383 &scaled,
384 &baseline,
385 )
386 .map_err(|e| {
387 ValidationError::estimation_msg(format!("prior sensitivity compose failed: {e}"))
388 })?;
389 let est = BayesianGComputationAte {
390 prior_scale: estimator.prior_scale,
391 n_draws: estimator.n_draws.min(200),
392 seed: estimator.seed,
393 backend: estimator.backend,
394 likelihood: estimator.likelihood,
395 overlap: estimator.overlap,
396 prior: Some(composed.prior),
397 };
398 let post = est.fit(problem, identification, workspace, ctx).map_err(|e| {
399 ValidationError::estimation_msg(format!("prior sensitivity α fit failed: {e}"))
400 })?;
401 let eq = post.effect_column().ok_or_else(|| {
402 ValidationError::estimation_msg("missing effect column in α sensitivity fit")
403 })?;
404 means.push(post.summaries.mean[eq]);
405 sds.push(post.summaries.sd[eq]);
406 posts.push(post);
407 }
408 Ok((
409 PriorSensitivitySummary {
410 prior_scales: Arc::from([]),
411 alphas: Arc::clone(&self.alphas),
412 effect_means: Arc::from(means),
413 effect_sds: Arc::from(sds),
414 },
415 posts,
416 ))
417 }
418
419 #[must_use]
424 pub fn to_report(
425 &self,
426 summary: &PriorSensitivitySummary,
427 original_ate: f64,
428 ) -> RefutationReport {
429 let min = summary.effect_means.iter().copied().fold(f64::INFINITY, f64::min);
430 let max = summary.effect_means.iter().copied().fold(f64::NEG_INFINITY, f64::max);
431 let range = max - min;
432 let denom = summary
433 .effect_means
434 .iter()
435 .copied()
436 .map(f64::abs)
437 .fold(original_ate.abs(), f64::max)
438 .max(1e-8);
439 let relative = range / denom;
440 let passed = relative.is_finite() && relative <= self.max_relative_range;
441 let kind =
442 if summary.alphas.is_empty() { "prior_sensitivity" } else { "prior_sensitivity_alpha" };
443 RefutationReport {
444 refuter: Arc::from(kind),
445 original_ate,
446 refuted_ate: summary.effect_means.last().copied().unwrap_or(original_ate),
447 comparison: relative,
448 informative: true,
449 passed,
450 failure_condition: if passed {
451 None
452 } else {
453 Some(Arc::from(format!(
454 "prior sensitivity relative range {relative} exceeds max {}",
455 self.max_relative_range
456 )))
457 },
458 replicates: u32::try_from(self.grid_len()).unwrap_or(u32::MAX),
459 }
460 }
461}
462
463fn summarize_check(
464 kind: PredictiveCheckKind,
465 observed: f64,
466 summaries: &[f64],
467 n_sims: u32,
468) -> PredictiveCheckReport {
469 let policy = KernelPolicy::default_policy();
470 let mean = reduce_posterior_draws(summaries, PosteriorReduceOp::Mean, &policy).unwrap_or(0.0);
471 let sd = reduce_posterior_draws(summaries, PosteriorReduceOp::Std, &policy).unwrap_or(0.0);
472 let n = summaries.len() as f64;
473 let below = summaries.iter().filter(|&&x| x <= observed).count() as f64;
474 let p = (2.0 * (below / n.max(1.0)).min(1.0 - below / n.max(1.0))).min(1.0);
475 PredictiveCheckReport {
476 kind,
477 observed,
478 predictive_mean: mean,
479 predictive_sd: sd,
480 p_value: p,
481 n_sims,
482 }
483}
484
485#[must_use]
487pub fn with_prior_sensitivity(
488 mut posterior: CausalPosterior,
489 summary: PriorSensitivitySummary,
490) -> CausalPosterior {
491 posterior.prior_sensitivity = Some(summary);
492 posterior
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498 use antecedent_core::{
499 AverageEffectQuery, CausalSchemaBuilder, MeasurementSpec, RoleHint, SmallRoleSet,
500 ValueType, VariableId,
501 };
502 use antecedent_data::{
503 Float64Column, OwnedColumn, OwnedColumnarStorage, TabularData, ValidityBitmap,
504 };
505 use antecedent_estimate::{BayesianBackendKind, BayesianGComputationAte};
506 use antecedent_expr::{ExprId, IdentifiedEstimand};
507 use antecedent_identify::IdentificationStatus;
508
509 fn toy() -> (TabularData, IdentifiedEstimand, AverageEffectQuery) {
510 let n = 60usize;
511 let mut b = CausalSchemaBuilder::new();
512 b.add_variable(
513 "t",
514 ValueType::Continuous,
515 SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
516 None,
517 None,
518 MeasurementSpec::default(),
519 )
520 .unwrap();
521 b.add_variable(
522 "y",
523 ValueType::Continuous,
524 SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
525 None,
526 None,
527 MeasurementSpec::default(),
528 )
529 .unwrap();
530 b.add_variable(
531 "z",
532 ValueType::Continuous,
533 SmallRoleSet::from_hint(RoleHint::Context),
534 None,
535 None,
536 MeasurementSpec::default(),
537 )
538 .unwrap();
539 let schema = b.build().unwrap();
540 let t: Vec<f64> = (0..n).map(|i| (i % 2) as f64).collect();
541 let z: Vec<f64> = (0..n).map(|i| i as f64 * 0.05).collect();
542 let y: Vec<f64> = (0..n).map(|i| 1.0 + 2.0 * t[i] + 0.3 * z[i]).collect();
543 let cols = vec![
544 OwnedColumn::Float64(
545 Float64Column::new(
546 VariableId::from_raw(0),
547 Arc::from(t),
548 ValidityBitmap::all_valid(n),
549 )
550 .unwrap(),
551 ),
552 OwnedColumn::Float64(
553 Float64Column::new(
554 VariableId::from_raw(1),
555 Arc::from(y),
556 ValidityBitmap::all_valid(n),
557 )
558 .unwrap(),
559 ),
560 OwnedColumn::Float64(
561 Float64Column::new(
562 VariableId::from_raw(2),
563 Arc::from(z),
564 ValidityBitmap::all_valid(n),
565 )
566 .unwrap(),
567 ),
568 ];
569 let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
570 let estimand = IdentifiedEstimand::backdoor(
571 "backdoor.adjustment",
572 Arc::from([VariableId::from_raw(2)]),
573 ExprId::from_raw(0),
574 );
575 let query =
576 AverageEffectQuery::binary_ate(VariableId::from_raw(0), VariableId::from_raw(1));
577 (TabularData::new(storage), estimand, query)
578 }
579
580 #[test]
581 fn prior_and_posterior_ppc_run() {
582 let (data, estimand, query) = toy();
583 let bayes = BayesianGComputationAte {
584 backend: BayesianBackendKind::ConjugateGaussian,
585 n_draws: 100,
586 seed: 2,
587 prior_scale: 10.0,
588 ..BayesianGComputationAte::new()
589 };
590 let prep = bayes.prepare(&data, &estimand, &query).unwrap();
591 let ctx = ExecutionContext::for_tests(1);
592 let prior_rep = PriorPredictiveCheck { n_sims: 50, seed: 3, ..PriorPredictiveCheck::new() }
593 .check(&prep, &ctx)
594 .unwrap();
595 assert_eq!(prior_rep.kind, PredictiveCheckKind::Prior);
596 assert!(prior_rep.p_value.is_finite());
597
598 let mut ws = BayesianGCompWorkspace::default();
599 let post = bayes
600 .fit(&prep, IdentificationStatus::NonparametricallyIdentified, &mut ws, &ctx)
601 .unwrap();
602 let post_rep = PosteriorPredictiveCheck { n_sims: 50, ..PosteriorPredictiveCheck::new() }
603 .check(&prep, &post)
604 .unwrap();
605 assert_eq!(post_rep.kind, PredictiveCheckKind::Posterior);
606 }
607
608 #[test]
609 fn prior_sensitivity_grid() {
610 let (data, estimand, query) = toy();
611 let bayes = BayesianGComputationAte {
612 backend: BayesianBackendKind::ConjugateGaussian,
613 n_draws: 80,
614 seed: 4,
615 ..BayesianGComputationAte::new()
616 };
617 let prep = bayes.prepare(&data, &estimand, &query).unwrap();
618 let mut ws = BayesianGCompWorkspace::default();
619 let ctx = ExecutionContext::for_tests(1);
620 let sens = PriorSensitivity {
621 scales: Arc::from(vec![1.0, 10.0, 50.0]),
622 alphas: Arc::from([]),
623 max_relative_range: DEFAULT_MAX_RELATIVE_PRIOR_RANGE,
624 };
625 let (summary, posts) = sens
626 .evaluate(
627 &bayes,
628 &prep,
629 IdentificationStatus::NonparametricallyIdentified,
630 &mut ws,
631 &ctx,
632 )
633 .unwrap();
634 assert_eq!(summary.prior_scales.len(), 3);
635 assert!(summary.alphas.is_empty());
636 assert_eq!(posts.len(), 3);
637 let rep =
638 sens.to_report(&summary, posts[0].summaries.mean[posts[0].effect_column().unwrap()]);
639 assert!(rep.passed);
640 }
641
642 #[test]
643 fn prior_sensitivity_external_alpha_pulls_toward_source() {
644 use antecedent_prob::{
645 ExternalPriorSource, ExternalPriorWeight, GaussianCoefficientPrior, PriorSpec,
646 };
647
648 let (data, estimand, query) = toy();
649 let bayes = BayesianGComputationAte {
651 backend: BayesianBackendKind::ConjugateGaussian,
652 n_draws: 120,
653 seed: 7,
654 ..BayesianGComputationAte::new()
655 };
656 let prep = bayes.prepare(&data, &estimand, &query).unwrap();
657 let n = prep.design.ncols;
658 let t_col = prep.design.treatment_column().expect("treatment column");
659 let mut mean = vec![0.0; n];
660 mean[t_col] = 8.0;
661 let mut source_prior = PriorSet::new();
662 source_prior.push(PriorSpec::GaussianCoefficients(GaussianCoefficientPrior {
663 mean: Arc::from(mean),
664 variance: Arc::from(vec![0.05; n]),
665 }));
666 let sources = [ExternalPriorSource {
667 id: Arc::from("survey_a"),
668 prior: source_prior,
669 weight: ExternalPriorWeight::power(1.0).unwrap(),
670 }];
671 let alphas_applied = [1.0_f64];
672 let mut ws = BayesianGCompWorkspace::default();
673 let ctx = ExecutionContext::for_tests(1);
674 let sens = PriorSensitivity::standard_alpha_grid();
675 let (summary, _) = sens
676 .evaluate_external_alpha(
677 &bayes,
678 &prep,
679 IdentificationStatus::NonparametricallyIdentified,
680 &mut ws,
681 &ctx,
682 ExternalAlphaSensitivity { sources: &sources, alphas_applied: &alphas_applied },
683 )
684 .unwrap();
685 assert_eq!(summary.alphas.len(), 5);
686 assert!(summary.prior_scales.is_empty());
687 assert!(summary.effect_means.iter().all(|m| m.is_finite()));
688 let m0 = summary.effect_means[0];
689 let m1 = *summary.effect_means.last().unwrap();
690 assert!(
692 (m1 - 8.0).abs() < (m0 - 8.0).abs(),
693 "m=1 mean {m1} should be closer to 8 than m=0 mean {m0}"
694 );
695 let rep = sens.to_report(&summary, m1);
696 assert_eq!(rep.refuter.as_ref(), "prior_sensitivity_alpha");
697 assert!(rep.informative);
698 assert!(rep.comparison.is_finite() && rep.comparison > 0.0);
699 }
700}
701
702#[derive(Clone, Copy, Debug)]
707pub struct McmcDiagnosticsCheck {
708 pub max_rhat: f64,
710 pub min_ess: f64,
712 pub max_divergences: u32,
714}
715
716impl Default for McmcDiagnosticsCheck {
717 fn default() -> Self {
718 Self { max_rhat: 1.05, min_ess: 10.0, max_divergences: u32::MAX / 4 }
719 }
720}
721
722impl McmcDiagnosticsCheck {
723 #[must_use]
725 pub fn new() -> Self {
726 Self::default()
727 }
728
729 #[must_use]
733 pub fn check(&self, posterior: &CausalPosterior) -> Option<RefutationReport> {
734 use antecedent_prob::HessianFactorization;
735 let d = &posterior.diagnostics;
736 if d.factorization != HessianFactorization::Mcmc {
737 return None;
738 }
739 let rhat = d.rhat_max.unwrap_or(f64::INFINITY);
740 let ess = d.ess_bulk_min.unwrap_or(0.0);
741 let divs = d.n_divergences.unwrap_or(u32::MAX);
742 let passed = rhat.is_finite()
743 && rhat <= self.max_rhat
744 && ess >= self.min_ess
745 && divs <= self.max_divergences
746 && d.allows_posterior();
747 let ate = posterior
748 .effect_column()
749 .and_then(|c| posterior.summaries.mean.get(c).copied())
750 .unwrap_or(f64::NAN);
751 Some(RefutationReport {
752 refuter: Arc::from("mcmc_diagnostics"),
753 original_ate: ate,
754 refuted_ate: ate,
755 comparison: rhat,
756 informative: true,
757 passed,
758 failure_condition: if passed {
759 None
760 } else {
761 Some(Arc::from(format!(
762 "MCMC diagnostics failed: rhat={rhat:.4} ess={ess:.1} divergences={divs}"
763 )))
764 },
765 replicates: d.n_chains.unwrap_or(0),
766 })
767 }
768}
769
770#[derive(Clone, Debug)]
775pub struct SimulationBasedCalibration {
776 pub n_reps: u32,
778 pub n_draws: usize,
780 pub seed: u64,
782}
783
784impl Default for SimulationBasedCalibration {
785 fn default() -> Self {
786 Self { n_reps: 50, n_draws: 100, seed: 0 }
787 }
788}
789
790#[derive(Clone, Debug)]
792pub struct SbcReport {
793 pub ranks: Arc<[u32]>,
795 pub mean_rank_frac: f64,
797 pub uniformity_stat: f64,
799}
800
801impl SimulationBasedCalibration {
802 #[must_use]
804 pub fn new(n_reps: u32) -> Self {
805 Self { n_reps: n_reps.max(1), ..Self::default() }
806 }
807
808 pub fn check(
816 &self,
817 estimator: &BayesianGComputationAte,
818 problem: &PreparedBayesianProblem,
819 identification: IdentificationStatus,
820 workspace: &mut BayesianGCompWorkspace,
821 ctx: &ExecutionContext,
822 ) -> Result<SbcReport, ValidationError> {
823 let mut rng = CausalRng::from_seed(self.seed);
824 let n = problem.design.nrows;
825 let p = problem.design.ncols;
826 let t_col = problem
827 .design
828 .treatment_column()
829 .ok_or_else(|| ValidationError::estimation_msg("SBC: missing treatment column"))?;
830 let mut ranks = Vec::with_capacity(self.n_reps as usize);
831 let mut est = estimator.clone();
832 est.n_draws = self.n_draws;
833 let scale = estimator.prior_scale.max(1e-6);
834
835 for rep in 0..self.n_reps {
836 let mut beta = vec![0.0; p];
837 for c in 0..p {
838 beta[c] = scale * standard_normal(&mut rng);
839 }
840 let true_effect = (problem.active - problem.control) * beta[t_col];
841 let mut y_rep = vec![0.0; n];
842 for r in 0..n {
843 let mut eta = 0.0;
844 for c in 0..p {
845 eta += problem.design.matrix[c * n + r] * beta[c];
846 }
847 y_rep[r] = eta + standard_normal(&mut rng);
848 }
849 let mut sim_problem = problem.clone();
850 let mut design = sim_problem.design.clone();
851 design.outcome = Arc::from(y_rep);
852 sim_problem.design = design;
853 est.seed = self.seed ^ (u64::from(rep).wrapping_mul(0x9E37));
854 let post = est
855 .fit(&sim_problem, identification, workspace, ctx)
856 .map_err(|e| ValidationError::estimation_msg(format!("SBC refit failed: {e}")))?;
857 let col = post
858 .effect_column()
859 .ok_or_else(|| ValidationError::estimation_msg("SBC: no effect column"))?;
860 let draws = post
861 .draws
862 .column(col)
863 .map_err(|e| ValidationError::estimation_msg(format!("SBC draws: {e}")))?;
864 let mut rank = 0u32;
865 for &d in draws {
866 if d < true_effect {
867 rank += 1;
868 }
869 }
870 ranks.push(rank);
871 }
872
873 let n_d = self.n_draws.max(1) as f64;
874 let fracs: Vec<f64> = ranks.iter().map(|&r| f64::from(r) / n_d).collect();
875 let mean_rank_frac =
876 reduce_posterior_draws(&fracs, PosteriorReduceOp::Mean, &ctx.kernel_policy)
877 .unwrap_or(0.5);
878 let bins = 10usize;
879 let mut counts = vec![0.0; bins];
880 let n_draws_u = u64::try_from(self.n_draws.max(1)).unwrap_or(1);
881 let bins_u = u64::try_from(bins).unwrap_or(1);
882 for &r in &ranks {
883 let b = usize::try_from(u64::from(r) * bins_u / n_draws_u).unwrap_or(0).min(bins - 1);
884 counts[b] += 1.0;
885 }
886 let expected = f64::from(self.n_reps) / bins as f64;
887 let mut chi2 = 0.0;
888 for c in counts {
889 let d = c - expected;
890 chi2 += d * d / expected.max(1.0);
891 }
892 Ok(SbcReport { ranks: Arc::from(ranks), mean_rank_frac, uniformity_stat: chi2 })
893 }
894
895 #[must_use]
897 pub fn to_report(&self, report: &SbcReport, original_ate: f64) -> RefutationReport {
898 let passed = (0.35..=0.65).contains(&report.mean_rank_frac);
899 RefutationReport {
900 refuter: Arc::from("sbc"),
901 original_ate,
902 refuted_ate: report.mean_rank_frac,
903 comparison: report.uniformity_stat,
904 informative: true,
905 passed,
906 failure_condition: if passed {
907 None
908 } else {
909 Some(Arc::from(format!(
910 "SBC mean rank frac {:.3} outside [0.35, 0.65]",
911 report.mean_rank_frac
912 )))
913 },
914 replicates: self.n_reps,
915 }
916 }
917}
918
919#[derive(Clone, Copy, Debug, Default)]
921pub struct LikelihoodFamilyComparison {
922 pub n_placeholder: u8,
924}
925
926impl LikelihoodFamilyComparison {
927 pub fn compare(
934 &self,
935 problem: &PreparedBayesianProblem,
936 ctx: &ExecutionContext,
937 ) -> Result<(Arc<str>, f64), ValidationError> {
938 let _ = self;
939 let design = BayesDesignRef {
940 x_colmajor: &problem.design.matrix,
941 nrows: problem.design.nrows,
942 ncols: problem.design.ncols,
943 y: &problem.design.outcome,
944 weights: None,
945 offsets: None,
946 };
947 let prior = PriorSet::weakly_informative(problem.design.ncols);
948 let opts = BayesFitOptions { n_draws: 80, seed: 1, ..BayesFitOptions::default() };
949 let mut ws = LaplaceWorkspace::default();
950 let g = LaplaceGlmBackend
951 .fit(BayesLikelihood::GaussianIdentity, design, &prior, &opts, &mut ws, ctx)
952 .map_err(|e| ValidationError::estimation_msg(format!("Gaussian fit: {e}")))?;
953 let g_score = loo_gaussian_lpd(
954 &g.map,
955 &problem.design.matrix,
956 problem.design.nrows,
957 problem.design.ncols,
958 &problem.design.outcome,
959 );
960
961 let binary = problem
962 .design
963 .outcome
964 .iter()
965 .all(|&y| (y - 0.0).abs() < f64::EPSILON || (y - 1.0).abs() < f64::EPSILON);
966 if !binary {
967 return Ok((Arc::from("gaussian_identity"), 0.0));
968 }
969 let b = LaplaceGlmBackend
970 .fit(BayesLikelihood::BernoulliLogit, design, &prior, &opts, &mut ws, ctx)
971 .map_err(|e| ValidationError::estimation_msg(format!("Bernoulli fit: {e}")))?;
972 let b_score = loo_bernoulli_lpd(
973 &b.map,
974 &problem.design.matrix,
975 problem.design.nrows,
976 problem.design.ncols,
977 &problem.design.outcome,
978 );
979 if b_score >= g_score {
980 Ok((Arc::from("bernoulli_logit"), b_score - g_score))
981 } else {
982 Ok((Arc::from("gaussian_identity"), g_score - b_score))
983 }
984 }
985}
986
987fn loo_gaussian_lpd(map: &[f64], x: &[f64], n: usize, p: usize, y: &[f64]) -> f64 {
988 let mut resid = vec![0.0; n];
989 let mut rss = 0.0;
990 for r in 0..n {
991 let mut eta = 0.0;
992 for c in 0..p {
993 eta += x[c * n + r] * map.get(c).copied().unwrap_or(0.0);
994 }
995 resid[r] = y[r] - eta;
996 rss += resid[r] * resid[r];
997 }
998 let sigma2 = (rss / n.max(1) as f64).max(1e-8);
999 let mut lpd = 0.0;
1000 for r in 0..n {
1001 let s2 = sigma2 * n as f64 / (n.saturating_sub(1)).max(1) as f64;
1002 lpd += -0.5
1003 * (s2.ln()
1004 + resid[r] * resid[r] / s2
1005 + std::f64::consts::LN_2
1006 + std::f64::consts::PI.ln());
1007 }
1008 lpd
1009}
1010
1011fn loo_bernoulli_lpd(map: &[f64], x: &[f64], n: usize, p: usize, y: &[f64]) -> f64 {
1012 let mut lpd = 0.0;
1013 for r in 0..n {
1014 let mut eta = 0.0;
1015 for c in 0..p {
1016 eta += x[c * n + r] * map.get(c).copied().unwrap_or(0.0);
1017 }
1018 let prob = 1.0 / (1.0 + (-eta).exp());
1019 lpd += if y[r] > 0.5 { prob.max(1e-12).ln() } else { (1.0 - prob).max(1e-12).ln() };
1020 }
1021 lpd
1022}
1023
1024#[derive(Clone, Debug)]
1026pub struct PosteriorCalibrationOnSyntheticScm {
1027 pub n_reps: u32,
1029 pub n_draws: usize,
1031 pub level: f64,
1033 pub seed: u64,
1035}
1036
1037impl Default for PosteriorCalibrationOnSyntheticScm {
1038 fn default() -> Self {
1039 Self { n_reps: 40, n_draws: 100, level: 0.9, seed: 0 }
1040 }
1041}
1042
1043#[derive(Clone, Debug)]
1045pub struct PosteriorCalibrationReport {
1046 pub coverage: f64,
1048 pub mean_abs_error: f64,
1050 pub n_reps: u32,
1052}
1053
1054impl PosteriorCalibrationOnSyntheticScm {
1055 pub fn check(
1061 &self,
1062 estimator: &BayesianGComputationAte,
1063 problem: &PreparedBayesianProblem,
1064 identification: IdentificationStatus,
1065 workspace: &mut BayesianGCompWorkspace,
1066 ctx: &ExecutionContext,
1067 ) -> Result<PosteriorCalibrationReport, ValidationError> {
1068 let mut rng = CausalRng::from_seed(self.seed);
1069 let n = problem.design.nrows;
1070 let p = problem.design.ncols;
1071 let t_col = problem
1072 .design
1073 .treatment_column()
1074 .ok_or_else(|| ValidationError::estimation_msg("calibration: missing treatment"))?;
1075 let mut covered = 0u32;
1076 let mut abs_err = 0.0;
1077 let mut est = estimator.clone();
1078 est.n_draws = self.n_draws;
1079 let alpha = ((1.0 - self.level) / 2.0).clamp(0.0, 0.5);
1080
1081 for rep in 0..self.n_reps {
1082 let true_ate = standard_normal(&mut rng);
1083 let mut beta = vec![0.0; p];
1084 let diff = problem.active - problem.control;
1085 beta[t_col] = if diff.abs() > 1e-12 { true_ate / diff } else { true_ate };
1086 for c in 0..p {
1087 if c != t_col {
1088 beta[c] = 0.5 * standard_normal(&mut rng);
1089 }
1090 }
1091 let mut y = vec![0.0; n];
1092 for r in 0..n {
1093 let mut eta = 0.0;
1094 for c in 0..p {
1095 eta += problem.design.matrix[c * n + r] * beta[c];
1096 }
1097 y[r] = eta + standard_normal(&mut rng);
1098 }
1099 let mut sim = problem.clone();
1100 let mut design = sim.design.clone();
1101 design.outcome = Arc::from(y);
1102 sim.design = design;
1103 est.seed = self.seed ^ (u64::from(rep).wrapping_mul(0xC2B2));
1104 let post = est
1105 .fit(&sim, identification, workspace, ctx)
1106 .map_err(|e| ValidationError::estimation_msg(format!("calibration refit: {e}")))?;
1107 let col = post
1108 .effect_column()
1109 .ok_or_else(|| ValidationError::estimation_msg("calibration: no effect"))?;
1110 let mut draws = post.draws.column(col).map_err(ValidationError::from)?.to_vec();
1111 draws.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1112 let lo = quantile_sorted(&draws, alpha);
1113 let hi = quantile_sorted(&draws, 1.0 - alpha);
1114 let mean = reduce_posterior_draws(&draws, PosteriorReduceOp::Mean, &ctx.kernel_policy)
1115 .unwrap_or(0.0);
1116 abs_err += (mean - true_ate).abs();
1117 if true_ate >= lo && true_ate <= hi {
1118 covered += 1;
1119 }
1120 }
1121 Ok(PosteriorCalibrationReport {
1122 coverage: f64::from(covered) / f64::from(self.n_reps.max(1)),
1123 mean_abs_error: abs_err / f64::from(self.n_reps.max(1)),
1124 n_reps: self.n_reps,
1125 })
1126 }
1127}
1128
1129fn quantile_sorted(sorted: &[f64], q: f64) -> f64 {
1130 if sorted.is_empty() {
1131 return 0.0;
1132 }
1133 let max_idx = sorted.len() - 1;
1134 let rank = (max_idx as f64 * q.clamp(0.0, 1.0)).round();
1135 let idx = (0..=max_idx)
1136 .min_by(|&a, &b| {
1137 (a as f64 - rank)
1138 .abs()
1139 .partial_cmp(&(b as f64 - rank).abs())
1140 .unwrap_or(std::cmp::Ordering::Equal)
1141 })
1142 .unwrap_or(0);
1143 sorted[idx]
1144}