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, HessianFactorization,
23 InferenceBackend, 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 use antecedent_prob::{ExternalPriorWeight, GaussianCoefficientPrior, PriorSpec};
509
510 fn toy() -> (TabularData, IdentifiedEstimand, AverageEffectQuery) {
511 let n = 60usize;
512 let mut b = CausalSchemaBuilder::new();
513 b.add_variable(
514 "t",
515 ValueType::Continuous,
516 SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
517 None,
518 None,
519 MeasurementSpec::default(),
520 )
521 .unwrap();
522 b.add_variable(
523 "y",
524 ValueType::Continuous,
525 SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
526 None,
527 None,
528 MeasurementSpec::default(),
529 )
530 .unwrap();
531 b.add_variable(
532 "z",
533 ValueType::Continuous,
534 SmallRoleSet::from_hint(RoleHint::Context),
535 None,
536 None,
537 MeasurementSpec::default(),
538 )
539 .unwrap();
540 let schema = b.build().unwrap();
541 let t: Vec<f64> = (0..n).map(|i| (i % 2) as f64).collect();
542 let z: Vec<f64> = (0..n).map(|i| i as f64 * 0.05).collect();
543 let y: Vec<f64> = (0..n).map(|i| 1.0 + 2.0 * t[i] + 0.3 * z[i]).collect();
544 let cols = vec![
545 OwnedColumn::Float64(
546 Float64Column::new(
547 VariableId::from_raw(0),
548 Arc::from(t),
549 ValidityBitmap::all_valid(n),
550 )
551 .unwrap(),
552 ),
553 OwnedColumn::Float64(
554 Float64Column::new(
555 VariableId::from_raw(1),
556 Arc::from(y),
557 ValidityBitmap::all_valid(n),
558 )
559 .unwrap(),
560 ),
561 OwnedColumn::Float64(
562 Float64Column::new(
563 VariableId::from_raw(2),
564 Arc::from(z),
565 ValidityBitmap::all_valid(n),
566 )
567 .unwrap(),
568 ),
569 ];
570 let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
571 let estimand = IdentifiedEstimand::backdoor(
572 "backdoor.adjustment",
573 Arc::from([VariableId::from_raw(2)]),
574 ExprId::from_raw(0),
575 );
576 let query =
577 AverageEffectQuery::binary_ate(VariableId::from_raw(0), VariableId::from_raw(1));
578 (TabularData::new(storage), estimand, query)
579 }
580
581 #[test]
582 fn prior_and_posterior_ppc_run() {
583 let (data, estimand, query) = toy();
584 let bayes = BayesianGComputationAte {
585 backend: BayesianBackendKind::ConjugateGaussian,
586 n_draws: 100,
587 seed: 2,
588 prior_scale: 10.0,
589 ..BayesianGComputationAte::new()
590 };
591 let prep = bayes.prepare(&data, &estimand, &query).unwrap();
592 let ctx = ExecutionContext::for_tests(1);
593 let prior_rep = PriorPredictiveCheck { n_sims: 50, seed: 3, ..PriorPredictiveCheck::new() }
594 .check(&prep, &ctx)
595 .unwrap();
596 assert_eq!(prior_rep.kind, PredictiveCheckKind::Prior);
597 assert!(prior_rep.p_value.is_finite());
598
599 let mut ws = BayesianGCompWorkspace::default();
600 let post = bayes
601 .fit(&prep, IdentificationStatus::NonparametricallyIdentified, &mut ws, &ctx)
602 .unwrap();
603 let post_rep = PosteriorPredictiveCheck { n_sims: 50, ..PosteriorPredictiveCheck::new() }
604 .check(&prep, &post)
605 .unwrap();
606 assert_eq!(post_rep.kind, PredictiveCheckKind::Posterior);
607 }
608
609 #[test]
610 fn prior_sensitivity_grid() {
611 let (data, estimand, query) = toy();
612 let bayes = BayesianGComputationAte {
613 backend: BayesianBackendKind::ConjugateGaussian,
614 n_draws: 80,
615 seed: 4,
616 ..BayesianGComputationAte::new()
617 };
618 let prep = bayes.prepare(&data, &estimand, &query).unwrap();
619 let mut ws = BayesianGCompWorkspace::default();
620 let ctx = ExecutionContext::for_tests(1);
621 let sens = PriorSensitivity {
622 scales: Arc::from(vec![1.0, 10.0, 50.0]),
623 alphas: Arc::from([]),
624 max_relative_range: DEFAULT_MAX_RELATIVE_PRIOR_RANGE,
625 };
626 let (summary, posts) = sens
627 .evaluate(
628 &bayes,
629 &prep,
630 IdentificationStatus::NonparametricallyIdentified,
631 &mut ws,
632 &ctx,
633 )
634 .unwrap();
635 assert_eq!(summary.prior_scales.len(), 3);
636 assert!(summary.alphas.is_empty());
637 assert_eq!(posts.len(), 3);
638 let rep =
639 sens.to_report(&summary, posts[0].summaries.mean[posts[0].effect_column().unwrap()]);
640 assert!(rep.passed);
641 }
642
643 #[test]
644 fn prior_sensitivity_external_alpha_pulls_toward_source() {
645 let fixture: serde_json::Value = serde_json::from_str(include_str!(
646 "../../../conformance/validate/bayesian_checks/expected.json"
647 ))
648 .unwrap();
649 assert!(
650 fixture["contracts"]["prior_sensitivity_full_trust_moves_toward_source"]
651 .as_bool()
652 .unwrap()
653 );
654
655 let (data, estimand, query) = toy();
656 let bayes = BayesianGComputationAte {
658 backend: BayesianBackendKind::ConjugateGaussian,
659 n_draws: 120,
660 seed: 7,
661 ..BayesianGComputationAte::new()
662 };
663 let prep = bayes.prepare(&data, &estimand, &query).unwrap();
664 let n = prep.design.ncols;
665 let t_col = prep.design.treatment_column().expect("treatment column");
666 let mut mean = vec![0.0; n];
667 mean[t_col] = 8.0;
668 let mut source_prior = PriorSet::new();
669 source_prior.push(PriorSpec::GaussianCoefficients(GaussianCoefficientPrior {
670 mean: Arc::from(mean),
671 variance: Arc::from(vec![0.05; n]),
672 }));
673 let sources = [ExternalPriorSource {
674 id: Arc::from("survey_a"),
675 prior: source_prior,
676 weight: ExternalPriorWeight::power(1.0).unwrap(),
677 }];
678 let alphas_applied = [1.0_f64];
679 let mut ws = BayesianGCompWorkspace::default();
680 let ctx = ExecutionContext::for_tests(1);
681 let sens = PriorSensitivity::standard_alpha_grid();
682 let (summary, _) = sens
683 .evaluate_external_alpha(
684 &bayes,
685 &prep,
686 IdentificationStatus::NonparametricallyIdentified,
687 &mut ws,
688 &ctx,
689 ExternalAlphaSensitivity { sources: &sources, alphas_applied: &alphas_applied },
690 )
691 .unwrap();
692 assert_eq!(summary.alphas.len(), 5);
693 assert!(summary.prior_scales.is_empty());
694 assert!(summary.effect_means.iter().all(|m| m.is_finite()));
695 let m0 = summary.effect_means[0];
696 let m1 = *summary.effect_means.last().unwrap();
697 assert!(
699 (m1 - 8.0).abs() < (m0 - 8.0).abs(),
700 "m=1 mean {m1} should be closer to 8 than m=0 mean {m0}"
701 );
702 let rep = sens.to_report(&summary, m1);
703 assert_eq!(rep.refuter.as_ref(), "prior_sensitivity_alpha");
704 assert!(rep.informative);
705 assert!(rep.comparison.is_finite() && rep.comparison > 0.0);
706 }
707}
708
709#[derive(Clone, Copy, Debug)]
714pub struct McmcDiagnosticsCheck {
715 pub max_rhat: f64,
717 pub min_ess: f64,
719 pub max_divergences: u32,
721}
722
723impl Default for McmcDiagnosticsCheck {
724 fn default() -> Self {
725 Self { max_rhat: 1.05, min_ess: 10.0, max_divergences: u32::MAX / 4 }
726 }
727}
728
729impl McmcDiagnosticsCheck {
730 #[must_use]
732 pub fn new() -> Self {
733 Self::default()
734 }
735
736 #[must_use]
740 pub fn check(&self, posterior: &CausalPosterior) -> Option<RefutationReport> {
741 let d = &posterior.diagnostics;
742 if d.factorization != HessianFactorization::Mcmc {
743 return None;
744 }
745 let rhat = d.rhat_max.unwrap_or(f64::INFINITY);
746 let ess = d.ess_bulk_min.unwrap_or(0.0);
747 let divs = d.n_divergences.unwrap_or(u32::MAX);
748 let passed = rhat.is_finite()
749 && rhat <= self.max_rhat
750 && ess >= self.min_ess
751 && divs <= self.max_divergences
752 && d.allows_posterior();
753 let ate = posterior
754 .effect_column()
755 .and_then(|c| posterior.summaries.mean.get(c).copied())
756 .unwrap_or(f64::NAN);
757 Some(RefutationReport {
758 refuter: Arc::from("mcmc_diagnostics"),
759 original_ate: ate,
760 refuted_ate: ate,
761 comparison: rhat,
762 informative: true,
763 passed,
764 failure_condition: if passed {
765 None
766 } else {
767 Some(Arc::from(format!(
768 "MCMC diagnostics failed: rhat={rhat:.4} ess={ess:.1} divergences={divs}"
769 )))
770 },
771 replicates: d.n_chains.unwrap_or(0),
772 })
773 }
774}
775
776#[derive(Clone, Debug)]
781pub struct SimulationBasedCalibration {
782 pub n_reps: u32,
784 pub n_draws: usize,
786 pub seed: u64,
788}
789
790impl Default for SimulationBasedCalibration {
791 fn default() -> Self {
792 Self { n_reps: 50, n_draws: 100, seed: 0 }
793 }
794}
795
796#[derive(Clone, Debug)]
798pub struct SbcReport {
799 pub ranks: Arc<[u32]>,
801 pub mean_rank_frac: f64,
803 pub uniformity_stat: f64,
805}
806
807impl SimulationBasedCalibration {
808 #[must_use]
810 pub fn new(n_reps: u32) -> Self {
811 Self { n_reps: n_reps.max(1), ..Self::default() }
812 }
813
814 pub fn check(
822 &self,
823 estimator: &BayesianGComputationAte,
824 problem: &PreparedBayesianProblem,
825 identification: IdentificationStatus,
826 workspace: &mut BayesianGCompWorkspace,
827 ctx: &ExecutionContext,
828 ) -> Result<SbcReport, ValidationError> {
829 let mut rng = CausalRng::from_seed(self.seed);
830 let n = problem.design.nrows;
831 let p = problem.design.ncols;
832 let t_col = problem
833 .design
834 .treatment_column()
835 .ok_or_else(|| ValidationError::estimation_msg("SBC: missing treatment column"))?;
836 let mut ranks = Vec::with_capacity(self.n_reps as usize);
837 let mut est = estimator.clone();
838 est.n_draws = self.n_draws;
839 let scale = estimator.prior_scale.max(1e-6);
840
841 for rep in 0..self.n_reps {
842 let mut beta = vec![0.0; p];
843 for c in 0..p {
844 beta[c] = scale * standard_normal(&mut rng);
845 }
846 let true_effect = (problem.active - problem.control) * beta[t_col];
847 let mut y_rep = vec![0.0; n];
848 for r in 0..n {
849 let mut eta = 0.0;
850 for c in 0..p {
851 eta += problem.design.matrix[c * n + r] * beta[c];
852 }
853 y_rep[r] = eta + standard_normal(&mut rng);
854 }
855 let mut sim_problem = problem.clone();
856 let mut design = sim_problem.design.clone();
857 design.outcome = Arc::from(y_rep);
858 sim_problem.design = design;
859 est.seed = self.seed ^ (u64::from(rep).wrapping_mul(0x9E37));
860 let post = est
861 .fit(&sim_problem, identification, workspace, ctx)
862 .map_err(|e| ValidationError::estimation_msg(format!("SBC refit failed: {e}")))?;
863 let col = post
864 .effect_column()
865 .ok_or_else(|| ValidationError::estimation_msg("SBC: no effect column"))?;
866 let draws = post
867 .draws
868 .column(col)
869 .map_err(|e| ValidationError::estimation_msg(format!("SBC draws: {e}")))?;
870 let mut rank = 0u32;
871 for &d in draws {
872 if d < true_effect {
873 rank += 1;
874 }
875 }
876 ranks.push(rank);
877 }
878
879 let n_d = self.n_draws.max(1) as f64;
880 let fracs: Vec<f64> = ranks.iter().map(|&r| f64::from(r) / n_d).collect();
881 let mean_rank_frac =
882 reduce_posterior_draws(&fracs, PosteriorReduceOp::Mean, &ctx.kernel_policy)
883 .unwrap_or(0.5);
884 let bins = 10usize;
885 let mut counts = vec![0.0; bins];
886 let n_draws_u = u64::try_from(self.n_draws.max(1)).unwrap_or(1);
887 let bins_u = u64::try_from(bins).unwrap_or(1);
888 for &r in &ranks {
889 let b = usize::try_from(u64::from(r) * bins_u / n_draws_u).unwrap_or(0).min(bins - 1);
890 counts[b] += 1.0;
891 }
892 let expected = f64::from(self.n_reps) / bins as f64;
893 let mut chi2 = 0.0;
894 for c in counts {
895 let d = c - expected;
896 chi2 += d * d / expected.max(1.0);
897 }
898 Ok(SbcReport { ranks: Arc::from(ranks), mean_rank_frac, uniformity_stat: chi2 })
899 }
900
901 #[must_use]
903 pub fn to_report(&self, report: &SbcReport, original_ate: f64) -> RefutationReport {
904 let passed = (0.35..=0.65).contains(&report.mean_rank_frac);
905 RefutationReport {
906 refuter: Arc::from("sbc"),
907 original_ate,
908 refuted_ate: report.mean_rank_frac,
909 comparison: report.uniformity_stat,
910 informative: true,
911 passed,
912 failure_condition: if passed {
913 None
914 } else {
915 Some(Arc::from(format!(
916 "SBC mean rank frac {:.3} outside [0.35, 0.65]",
917 report.mean_rank_frac
918 )))
919 },
920 replicates: self.n_reps,
921 }
922 }
923}
924
925#[derive(Clone, Copy, Debug, Default)]
927pub struct LikelihoodFamilyComparison {
928 pub n_placeholder: u8,
930}
931
932impl LikelihoodFamilyComparison {
933 pub fn compare(
940 &self,
941 problem: &PreparedBayesianProblem,
942 ctx: &ExecutionContext,
943 ) -> Result<(Arc<str>, f64), ValidationError> {
944 let _ = self;
945 let design = BayesDesignRef {
946 x_colmajor: &problem.design.matrix,
947 nrows: problem.design.nrows,
948 ncols: problem.design.ncols,
949 y: &problem.design.outcome,
950 weights: None,
951 offsets: None,
952 };
953 let prior = PriorSet::weakly_informative(problem.design.ncols);
954 let opts = BayesFitOptions { n_draws: 80, seed: 1, ..BayesFitOptions::default() };
955 let mut ws = LaplaceWorkspace::default();
956 let g = LaplaceGlmBackend
957 .fit(BayesLikelihood::GaussianIdentity, design, &prior, &opts, &mut ws, ctx)
958 .map_err(|e| ValidationError::estimation_msg(format!("Gaussian fit: {e}")))?;
959 let g_score = loo_gaussian_lpd(
960 &g.map,
961 &problem.design.matrix,
962 problem.design.nrows,
963 problem.design.ncols,
964 &problem.design.outcome,
965 );
966
967 let binary = problem
968 .design
969 .outcome
970 .iter()
971 .all(|&y| (y - 0.0).abs() < f64::EPSILON || (y - 1.0).abs() < f64::EPSILON);
972 if !binary {
973 return Ok((Arc::from("gaussian_identity"), 0.0));
974 }
975 let b = LaplaceGlmBackend
976 .fit(BayesLikelihood::BernoulliLogit, design, &prior, &opts, &mut ws, ctx)
977 .map_err(|e| ValidationError::estimation_msg(format!("Bernoulli fit: {e}")))?;
978 let b_score = loo_bernoulli_lpd(
979 &b.map,
980 &problem.design.matrix,
981 problem.design.nrows,
982 problem.design.ncols,
983 &problem.design.outcome,
984 );
985 if b_score >= g_score {
986 Ok((Arc::from("bernoulli_logit"), b_score - g_score))
987 } else {
988 Ok((Arc::from("gaussian_identity"), g_score - b_score))
989 }
990 }
991}
992
993fn loo_gaussian_lpd(map: &[f64], x: &[f64], n: usize, p: usize, y: &[f64]) -> f64 {
994 let mut resid = vec![0.0; n];
995 let mut rss = 0.0;
996 for r in 0..n {
997 let mut eta = 0.0;
998 for c in 0..p {
999 eta += x[c * n + r] * map.get(c).copied().unwrap_or(0.0);
1000 }
1001 resid[r] = y[r] - eta;
1002 rss += resid[r] * resid[r];
1003 }
1004 let sigma2 = (rss / n.max(1) as f64).max(1e-8);
1005 let mut lpd = 0.0;
1006 for r in 0..n {
1007 let s2 = sigma2 * n as f64 / (n.saturating_sub(1)).max(1) as f64;
1008 lpd += -0.5
1009 * (s2.ln()
1010 + resid[r] * resid[r] / s2
1011 + std::f64::consts::LN_2
1012 + std::f64::consts::PI.ln());
1013 }
1014 lpd
1015}
1016
1017fn loo_bernoulli_lpd(map: &[f64], x: &[f64], n: usize, p: usize, y: &[f64]) -> f64 {
1018 let mut lpd = 0.0;
1019 for r in 0..n {
1020 let mut eta = 0.0;
1021 for c in 0..p {
1022 eta += x[c * n + r] * map.get(c).copied().unwrap_or(0.0);
1023 }
1024 let prob = 1.0 / (1.0 + (-eta).exp());
1025 lpd += if y[r] > 0.5 { prob.max(1e-12).ln() } else { (1.0 - prob).max(1e-12).ln() };
1026 }
1027 lpd
1028}
1029
1030#[derive(Clone, Debug)]
1032pub struct PosteriorCalibrationOnSyntheticScm {
1033 pub n_reps: u32,
1035 pub n_draws: usize,
1037 pub level: f64,
1039 pub seed: u64,
1041}
1042
1043impl Default for PosteriorCalibrationOnSyntheticScm {
1044 fn default() -> Self {
1045 Self { n_reps: 40, n_draws: 100, level: 0.9, seed: 0 }
1046 }
1047}
1048
1049#[derive(Clone, Debug)]
1051pub struct PosteriorCalibrationReport {
1052 pub coverage: f64,
1054 pub mean_abs_error: f64,
1056 pub n_reps: u32,
1058}
1059
1060impl PosteriorCalibrationOnSyntheticScm {
1061 pub fn check(
1067 &self,
1068 estimator: &BayesianGComputationAte,
1069 problem: &PreparedBayesianProblem,
1070 identification: IdentificationStatus,
1071 workspace: &mut BayesianGCompWorkspace,
1072 ctx: &ExecutionContext,
1073 ) -> Result<PosteriorCalibrationReport, ValidationError> {
1074 let mut rng = CausalRng::from_seed(self.seed);
1075 let n = problem.design.nrows;
1076 let p = problem.design.ncols;
1077 let t_col = problem
1078 .design
1079 .treatment_column()
1080 .ok_or_else(|| ValidationError::estimation_msg("calibration: missing treatment"))?;
1081 let mut covered = 0u32;
1082 let mut abs_err = 0.0;
1083 let mut est = estimator.clone();
1084 est.n_draws = self.n_draws;
1085 let alpha = ((1.0 - self.level) / 2.0).clamp(0.0, 0.5);
1086
1087 for rep in 0..self.n_reps {
1088 let true_ate = standard_normal(&mut rng);
1089 let mut beta = vec![0.0; p];
1090 let diff = problem.active - problem.control;
1091 beta[t_col] = if diff.abs() > 1e-12 { true_ate / diff } else { true_ate };
1092 for c in 0..p {
1093 if c != t_col {
1094 beta[c] = 0.5 * standard_normal(&mut rng);
1095 }
1096 }
1097 let mut y = vec![0.0; n];
1098 for r in 0..n {
1099 let mut eta = 0.0;
1100 for c in 0..p {
1101 eta += problem.design.matrix[c * n + r] * beta[c];
1102 }
1103 y[r] = eta + standard_normal(&mut rng);
1104 }
1105 let mut sim = problem.clone();
1106 let mut design = sim.design.clone();
1107 design.outcome = Arc::from(y);
1108 sim.design = design;
1109 est.seed = self.seed ^ (u64::from(rep).wrapping_mul(0xC2B2));
1110 let post = est
1111 .fit(&sim, identification, workspace, ctx)
1112 .map_err(|e| ValidationError::estimation_msg(format!("calibration refit: {e}")))?;
1113 let col = post
1114 .effect_column()
1115 .ok_or_else(|| ValidationError::estimation_msg("calibration: no effect"))?;
1116 let mut draws = post.draws.column(col).map_err(ValidationError::from)?.to_vec();
1117 draws.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1118 let lo = quantile_sorted(&draws, alpha);
1119 let hi = quantile_sorted(&draws, 1.0 - alpha);
1120 let mean = reduce_posterior_draws(&draws, PosteriorReduceOp::Mean, &ctx.kernel_policy)
1121 .unwrap_or(0.0);
1122 abs_err += (mean - true_ate).abs();
1123 if true_ate >= lo && true_ate <= hi {
1124 covered += 1;
1125 }
1126 }
1127 Ok(PosteriorCalibrationReport {
1128 coverage: f64::from(covered) / f64::from(self.n_reps.max(1)),
1129 mean_abs_error: abs_err / f64::from(self.n_reps.max(1)),
1130 n_reps: self.n_reps,
1131 })
1132 }
1133}
1134
1135fn quantile_sorted(sorted: &[f64], q: f64) -> f64 {
1136 if sorted.is_empty() {
1137 return 0.0;
1138 }
1139 let max_idx = sorted.len() - 1;
1140 let rank = (max_idx as f64 * q.clamp(0.0, 1.0)).round();
1141 let idx = (0..=max_idx)
1142 .min_by(|&a, &b| {
1143 (a as f64 - rank)
1144 .abs()
1145 .partial_cmp(&(b as f64 - rank).abs())
1146 .unwrap_or(std::cmp::Ordering::Equal)
1147 })
1148 .unwrap_or(0);
1149 sorted[idx]
1150}