1#![allow(
9 clippy::cast_precision_loss,
10 clippy::many_single_char_names,
11 clippy::unused_self,
12 clippy::too_many_lines
13)]
14
15use std::sync::Arc;
16
17use antecedent_core::ExecutionContext;
18use antecedent_estimate::EstimationWorkspace;
19
20use crate::bayesian_checks::{
21 McmcDiagnosticsCheck, PosteriorPredictiveCheck, PriorPredictiveCheck, PriorSensitivity,
22};
23use crate::bootstrap_refute::BootstrapRefute;
24use crate::common::{RefutationProblem, RefutationReport};
25use crate::custom::CustomEffectValidator;
26use crate::data_subset::DataSubsetRefuter;
27use crate::dummy_outcome::DummyOutcome;
28use crate::error::ValidationError;
29use crate::evalue::EValue;
30use crate::graph_refute::GraphRefuter;
31use crate::overlap::OverlapRefuter;
32use crate::overlap_rule::OverlapRuleRefuter;
33use crate::placebo::PlaceboTreatment;
34use crate::rcc::RandomCommonCause;
35use crate::riesz::RieszSensitivity;
36use crate::sensitivity::{LinearSensitivity, NonparametricSensitivity, PartialLinearSensitivity};
37use crate::unobserved_common_cause::UnobservedCommonCause;
38use crate::validator::run_validator;
39
40use antecedent_estimate::{
41 BayesianGCompWorkspace, BayesianGComputationAte, CausalPosterior, PreparedBayesianProblem,
42};
43use antecedent_identify::IdentificationStatus;
44
45pub struct BayesianSuiteContext<'a> {
47 pub estimator: &'a BayesianGComputationAte,
49 pub prepared: &'a PreparedBayesianProblem,
51 pub posterior: &'a CausalPosterior,
53 pub identification: IdentificationStatus,
55 pub workspace: &'a mut BayesianGCompWorkspace,
57 pub original_ate: f64,
59 pub ppc_alpha: f64,
61}
62
63impl<'a> BayesianSuiteContext<'a> {
64 #[must_use]
66 pub fn new(
67 estimator: &'a BayesianGComputationAte,
68 prepared: &'a PreparedBayesianProblem,
69 posterior: &'a CausalPosterior,
70 identification: IdentificationStatus,
71 workspace: &'a mut BayesianGCompWorkspace,
72 original_ate: f64,
73 ) -> Self {
74 Self {
75 estimator,
76 prepared,
77 posterior,
78 identification,
79 workspace,
80 original_ate,
81 ppc_alpha: 0.05,
82 }
83 }
84}
85
86#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
88pub enum ValidatorId {
89 Placebo,
91 RandomCommonCause,
93 Bootstrap,
95 UnobservedCommonCause,
97 Overlap,
99 OverlapRule,
101 DataSubset,
103 DummyOutcome,
105 EValue,
107 Graph,
109 LinearSensitivity,
111 PartialLinearSensitivity,
113 NonparametricSensitivity,
115 Riesz,
117 PriorPredictive,
119 PosteriorPredictive,
121 PriorSensitivity,
123 McmcDiagnostics,
125}
126
127#[derive(Clone, Debug)]
129pub enum ValidationOutcome {
130 Report(RefutationReport),
132 NotApplicable {
134 validator: ValidatorId,
136 reason: Arc<str>,
138 },
139}
140
141#[derive(Clone, Default)]
143pub struct ValidationSuite {
144 validators: Vec<ValidatorId>,
145 custom: Vec<Arc<dyn CustomEffectValidator>>,
146}
147
148impl std::fmt::Debug for ValidationSuite {
149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150 f.debug_struct("ValidationSuite")
151 .field("validators", &self.validators)
152 .field("custom", &self.custom.len())
153 .finish()
154 }
155}
156
157impl ValidationSuite {
158 #[must_use]
160 pub fn new() -> Self {
161 Self::default()
162 }
163
164 #[must_use]
166 pub fn with(mut self, id: ValidatorId) -> Self {
167 self.validators.push(id);
168 self
169 }
170
171 #[must_use]
173 pub fn with_custom(mut self, validator: Arc<dyn CustomEffectValidator>) -> Self {
174 self.custom.push(validator);
175 self
176 }
177
178 #[must_use]
180 pub fn placebo_and_rcc() -> Self {
181 Self::new().with(ValidatorId::Placebo).with(ValidatorId::RandomCommonCause)
182 }
183
184 #[must_use]
186 pub fn overlap_and_evalue() -> Self {
187 Self::new().with(ValidatorId::Overlap).with(ValidatorId::EValue)
188 }
189
190 #[must_use]
193 pub fn falsification_effect() -> Self {
194 Self::new()
195 .with(ValidatorId::Placebo)
196 .with(ValidatorId::RandomCommonCause)
197 .with(ValidatorId::UnobservedCommonCause)
198 .with(ValidatorId::Overlap)
199 .with(ValidatorId::OverlapRule)
200 .with(ValidatorId::DummyOutcome)
201 .with(ValidatorId::EValue)
202 .with(ValidatorId::LinearSensitivity)
203 .with(ValidatorId::PartialLinearSensitivity)
204 .with(ValidatorId::NonparametricSensitivity)
205 .with(ValidatorId::Riesz)
206 }
207
208 #[must_use]
210 pub fn stability_effect() -> Self {
211 Self::new()
212 .with(ValidatorId::Bootstrap)
213 .with(ValidatorId::DataSubset)
214 .with(ValidatorId::Graph)
215 }
216
217 #[must_use]
219 pub fn full_effect() -> Self {
220 let mut s = Self::falsification_effect();
221 s.validators.extend(Self::stability_effect().validators);
222 s
223 }
224
225 pub fn run(
231 &self,
232 problem: &RefutationProblem<'_>,
233 workspace: &mut EstimationWorkspace,
234 ctx: &ExecutionContext,
235 ) -> Result<Vec<ValidationOutcome>, ValidationError> {
236 let mut out = Vec::with_capacity(self.validators.len() + self.custom.len());
237 for &id in &self.validators {
238 out.push(self.run_one(id, problem, workspace, ctx)?);
239 }
240 for custom in &self.custom {
241 out.push(ValidationOutcome::Report(custom.validate(problem, ctx)?));
242 }
243 Ok(out)
244 }
245
246 pub fn run_with_propensity(
255 &self,
256 problem: &RefutationProblem<'_>,
257 workspace: &mut EstimationWorkspace,
258 propensity: &mut antecedent_stats::PropensityWorkspace,
259 ctx: &ExecutionContext,
260 ) -> Result<Vec<ValidationOutcome>, ValidationError> {
261 let mut out = Vec::with_capacity(self.validators.len() + self.custom.len());
262 for &id in &self.validators {
263 let outcome = match id {
266 ValidatorId::Overlap => ValidationOutcome::Report(
267 crate::overlap::OverlapRefuter::new()
268 .refute_with_propensity(problem, propensity)?,
269 ),
270 ValidatorId::OverlapRule if problem.temporal.is_none() => {
271 ValidationOutcome::Report(
272 OverlapRuleRefuter::new().refute_with_propensity(problem, propensity)?,
273 )
274 }
275 ValidatorId::Riesz if problem.temporal.is_none() => ValidationOutcome::Report(
276 RieszSensitivity::new().refute_with_propensity(problem, propensity)?,
277 ),
278 _ => self.run_one(id, problem, workspace, ctx)?,
279 };
280 out.push(outcome);
281 }
282 for custom in &self.custom {
283 out.push(ValidationOutcome::Report(custom.validate(problem, ctx)?));
284 }
285 Ok(out)
286 }
287
288 #[must_use]
290 pub fn reports_only(outcomes: &[ValidationOutcome]) -> Vec<RefutationReport> {
291 outcomes
292 .iter()
293 .filter_map(|o| match o {
294 ValidationOutcome::Report(r) => Some(r.clone()),
295 ValidationOutcome::NotApplicable { .. } => None,
296 })
297 .collect()
298 }
299
300 pub fn run_bayesian(
309 &self,
310 bayes: &mut BayesianSuiteContext<'_>,
311 ctx: &ExecutionContext,
312 ) -> Result<Vec<ValidationOutcome>, ValidationError> {
313 let mut out = Vec::with_capacity(self.validators.len() + self.custom.len());
314 for &id in &self.validators {
315 out.push(self.run_one_bayesian(id, bayes, ctx)?);
316 }
317 let _ = &self.custom;
319 Ok(out)
320 }
321
322 fn run_one(
323 &self,
324 id: ValidatorId,
325 problem: &RefutationProblem<'_>,
326 workspace: &mut EstimationWorkspace,
327 ctx: &ExecutionContext,
328 ) -> Result<ValidationOutcome, ValidationError> {
329 let method = problem.estimand.method_kind().ok();
330 let static_linear = method == Some(antecedent_expr::EstimandMethod::BackdoorAdjustment)
331 && problem.estimator.is_none_or(|e| e == "linear.adjustment.ate");
332 let temporal_linear = method
333 == Some(antecedent_expr::EstimandMethod::TemporalBackdoorUnfolded)
334 && problem.temporal.is_some()
335 && problem.estimator.is_none_or(|e| {
336 matches!(e, "temporal.linear.adjustment" | "bayesian.temporal.gcomp")
337 });
338 let linear_ok = static_linear || temporal_linear;
339 match id {
340 ValidatorId::Placebo => {
341 if !linear_ok {
342 return Ok(na(
343 id,
344 "PlaceboTreatment requires backdoor.adjustment + linear path \
345 (or temporal.backdoor.unfolded + temporal linear path)",
346 ));
347 }
348 Ok(ValidationOutcome::Report(run_validator(
349 &PlaceboTreatment::new(),
350 problem,
351 workspace,
352 ctx,
353 )?))
354 }
355 ValidatorId::RandomCommonCause => {
356 if !linear_ok {
357 return Ok(na(
358 id,
359 "RandomCommonCause requires backdoor.adjustment + linear path \
360 (or temporal.backdoor.unfolded + temporal linear path)",
361 ));
362 }
363 Ok(ValidationOutcome::Report(run_validator(
364 &RandomCommonCause::new(),
365 problem,
366 workspace,
367 ctx,
368 )?))
369 }
370 ValidatorId::Bootstrap => {
371 if !linear_ok {
372 return Ok(na(
373 id,
374 "BootstrapCiCoverage requires backdoor.adjustment + linear path \
375 (or temporal.backdoor.unfolded + temporal linear path)",
376 ));
377 }
378 Ok(ValidationOutcome::Report(run_validator(
379 &BootstrapRefute::new(),
380 problem,
381 workspace,
382 ctx,
383 )?))
384 }
385 ValidatorId::UnobservedCommonCause => {
386 if !linear_ok {
387 return Ok(na(
388 id,
389 "UnobservedCommonCause requires backdoor.adjustment or temporal.backdoor.unfolded",
390 ));
391 }
392 Ok(ValidationOutcome::Report(run_validator(
393 &UnobservedCommonCause::new(),
394 problem,
395 workspace,
396 ctx,
397 )?))
398 }
399 ValidatorId::Overlap => {
400 if problem.temporal.is_some() {
401 return Ok(na(
402 id,
403 "OverlapRefuter not applicable to temporal unfolded designs \
404 (propensity uses schema adjustment columns)",
405 ));
406 }
407 Ok(ValidationOutcome::Report(run_validator(
408 &OverlapRefuter::new(),
409 problem,
410 workspace,
411 ctx,
412 )?))
413 }
414 ValidatorId::OverlapRule => {
415 if problem.temporal.is_some() {
416 return Ok(na(
417 id,
418 "OverlapRuleRefuter not applicable to temporal unfolded designs",
419 ));
420 }
421 Ok(ValidationOutcome::Report(run_validator(
422 &OverlapRuleRefuter::new(),
423 problem,
424 workspace,
425 ctx,
426 )?))
427 }
428 ValidatorId::DataSubset => {
429 if !linear_ok {
430 return Ok(na(
431 id,
432 "DataSubsetRefuter requires backdoor.adjustment + linear path \
433 (or temporal.backdoor.unfolded + temporal linear path)",
434 ));
435 }
436 Ok(ValidationOutcome::Report(run_validator(
437 &DataSubsetRefuter::new(),
438 problem,
439 workspace,
440 ctx,
441 )?))
442 }
443 ValidatorId::DummyOutcome => {
444 if !linear_ok {
445 return Ok(na(
446 id,
447 "DummyOutcome requires backdoor.adjustment + linear path \
448 (or temporal.backdoor.unfolded + temporal linear path)",
449 ));
450 }
451 Ok(ValidationOutcome::Report(run_validator(
452 &DummyOutcome::new(),
453 problem,
454 workspace,
455 ctx,
456 )?))
457 }
458 ValidatorId::EValue => Ok(ValidationOutcome::Report(run_validator(
459 &EValue::new(),
460 problem,
461 workspace,
462 ctx,
463 )?)),
464 ValidatorId::Graph => {
465 if !static_linear {
467 return Ok(na(
468 id,
469 "DropAdjustmentCovariate requires static backdoor.adjustment + linear path \
470 (not applicable to temporal unfolded designs)",
471 ));
472 }
473 Ok(ValidationOutcome::Report(run_validator(
474 &GraphRefuter::new(),
475 problem,
476 workspace,
477 ctx,
478 )?))
479 }
480 ValidatorId::LinearSensitivity => {
481 if !linear_ok {
482 return Ok(na(
483 id,
484 "LinearSensitivity requires backdoor.adjustment or temporal.backdoor.unfolded",
485 ));
486 }
487 Ok(ValidationOutcome::Report(run_validator(
488 &LinearSensitivity::new(),
489 problem,
490 workspace,
491 ctx,
492 )?))
493 }
494 ValidatorId::PartialLinearSensitivity => {
495 if !linear_ok {
496 return Ok(na(
497 id,
498 "PartialLinearSensitivity requires backdoor.adjustment or temporal.backdoor.unfolded",
499 ));
500 }
501 Ok(ValidationOutcome::Report(run_validator(
502 &PartialLinearSensitivity::new(),
503 problem,
504 workspace,
505 ctx,
506 )?))
507 }
508 ValidatorId::NonparametricSensitivity => Ok(ValidationOutcome::Report(run_validator(
509 &NonparametricSensitivity::new(),
510 problem,
511 workspace,
512 ctx,
513 )?)),
514 ValidatorId::Riesz => {
515 if problem.temporal.is_some() {
516 return Ok(na(
517 id,
518 "RieszSensitivity not applicable to temporal unfolded designs",
519 ));
520 }
521 Ok(ValidationOutcome::Report(run_validator(
522 &RieszSensitivity::new(),
523 problem,
524 workspace,
525 ctx,
526 )?))
527 }
528 ValidatorId::PriorPredictive
529 | ValidatorId::PosteriorPredictive
530 | ValidatorId::PriorSensitivity
531 | ValidatorId::McmcDiagnostics => Ok(na(
532 id,
533 "Bayesian PPC/prior-sensitivity/MCMC diagnostics require ValidationSuite::run_bayesian with a fitted posterior",
534 )),
535 }
536 }
537
538 fn run_one_bayesian(
539 &self,
540 id: ValidatorId,
541 bayes: &mut BayesianSuiteContext<'_>,
542 ctx: &ExecutionContext,
543 ) -> Result<ValidationOutcome, ValidationError> {
544 match id {
545 ValidatorId::PriorPredictive => {
546 let check = PriorPredictiveCheck {
547 n_sims: 200,
548 seed: ctx.rng.master_seed(),
549 ..PriorPredictiveCheck::new()
550 };
551 let rep = check.check(bayes.prepared, ctx)?;
552 Ok(ValidationOutcome::Report(
553 rep.to_refutation_report(bayes.original_ate, bayes.ppc_alpha),
554 ))
555 }
556 ValidatorId::PosteriorPredictive => {
557 let check = PosteriorPredictiveCheck::new();
558 let rep = check.check(bayes.prepared, bayes.posterior)?;
559 Ok(ValidationOutcome::Report(
560 rep.to_refutation_report(bayes.original_ate, bayes.ppc_alpha),
561 ))
562 }
563 ValidatorId::PriorSensitivity => {
564 let sens = PriorSensitivity::standard_grid();
565 let (summary, _posts) = sens.evaluate(
566 bayes.estimator,
567 bayes.prepared,
568 bayes.identification,
569 bayes.workspace,
570 ctx,
571 )?;
572 Ok(ValidationOutcome::Report(sens.to_report(&summary, bayes.original_ate)))
573 }
574 ValidatorId::McmcDiagnostics => {
575 match McmcDiagnosticsCheck::new().check(bayes.posterior) {
576 Some(rep) => Ok(ValidationOutcome::Report(rep)),
577 None => Ok(na(
578 ValidatorId::McmcDiagnostics,
579 "MCMC diagnostics require an HMC/SMC posterior (Laplace/conjugate NotApplicable)",
580 )),
581 }
582 }
583 other => {
584 Ok(na(other, "validator is not a Bayesian diagnostic; use ValidationSuite::run"))
585 }
586 }
587 }
588
589 #[must_use]
591 pub fn bayesian_diagnostics() -> Self {
592 Self::new()
593 .with(ValidatorId::PriorPredictive)
594 .with(ValidatorId::PosteriorPredictive)
595 .with(ValidatorId::PriorSensitivity)
596 .with(ValidatorId::McmcDiagnostics)
597 }
598
599 #[must_use]
601 pub fn prior_predictive() -> Self {
602 Self::new().with(ValidatorId::PriorPredictive)
603 }
604}
605
606fn na(id: ValidatorId, reason: &str) -> ValidationOutcome {
607 ValidationOutcome::NotApplicable { validator: id, reason: Arc::from(reason) }
608}
609
610#[cfg(test)]
611mod tests {
612 use antecedent_core::{
613 AssumptionSet, AverageEffectQuery, CausalSchemaBuilder, ExecutionContext, MeasurementSpec,
614 RoleHint, SmallRoleSet, ValueType, VariableId,
615 };
616 use antecedent_data::{
617 Float64Column, OwnedColumn, OwnedColumnarStorage, TabularData, ValidityBitmap,
618 };
619 use antecedent_estimate::{EstimationWorkspace, LinearAdjustmentAte};
620 use antecedent_expr::ExprId;
621 use antecedent_identify::IdentifiedEstimand;
622
623 use super::*;
624 use crate::common::RefutationProblem;
625
626 fn toy() -> (TabularData, IdentifiedEstimand) {
627 let n = 120usize;
628 let mut b = CausalSchemaBuilder::new();
629 b.add_variable(
630 "t",
631 ValueType::Continuous,
632 SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
633 None,
634 None,
635 MeasurementSpec::default(),
636 )
637 .unwrap();
638 b.add_variable(
639 "y",
640 ValueType::Continuous,
641 SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
642 None,
643 None,
644 MeasurementSpec::default(),
645 )
646 .unwrap();
647 b.add_variable(
648 "z",
649 ValueType::Continuous,
650 SmallRoleSet::from_hint(RoleHint::Context),
651 None,
652 None,
653 MeasurementSpec::default(),
654 )
655 .unwrap();
656 let schema = b.build().unwrap();
657 let t: Vec<f64> = (0..n).map(|i| (i % 2) as f64).collect();
658 let z: Vec<f64> = (0..n).map(|i| (i as f64) / n as f64).collect();
659 let y: Vec<f64> = (0..n).map(|i| 1.0 + 2.0 * t[i] + z[i]).collect();
660 let cols = vec![
661 OwnedColumn::Float64(
662 Float64Column::new(
663 VariableId::from_raw(0),
664 Arc::from(t),
665 ValidityBitmap::all_valid(n),
666 )
667 .unwrap(),
668 ),
669 OwnedColumn::Float64(
670 Float64Column::new(
671 VariableId::from_raw(1),
672 Arc::from(y),
673 ValidityBitmap::all_valid(n),
674 )
675 .unwrap(),
676 ),
677 OwnedColumn::Float64(
678 Float64Column::new(
679 VariableId::from_raw(2),
680 Arc::from(z),
681 ValidityBitmap::all_valid(n),
682 )
683 .unwrap(),
684 ),
685 ];
686 let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
687 let estimand = IdentifiedEstimand::backdoor(
688 "backdoor.adjustment",
689 Arc::from([VariableId::from_raw(2)]),
690 ExprId::from_raw(0),
691 );
692 (TabularData::new(storage), estimand)
693 }
694
695 #[test]
696 fn full_suite_runs_applicable_validators() {
697 let (data, estimand) = toy();
698 let query =
699 AverageEffectQuery::binary_ate(VariableId::from_raw(0), VariableId::from_raw(1));
700 let est = LinearAdjustmentAte { bootstrap_replicates: 0, ..LinearAdjustmentAte::new() };
701 let prep = est.prepare(&data, &estimand, &query).unwrap();
702 let mut ws = EstimationWorkspace::default();
703 let ctx = ExecutionContext::for_tests(2);
704 let original = est.fit(&prep, &mut ws, &ctx, AssumptionSet::new()).unwrap();
705 let problem = RefutationProblem::new(
706 &data,
707 &estimand,
708 &query,
709 &original,
710 Some("linear.adjustment.ate"),
711 None,
712 );
713 let outcomes = ValidationSuite::full_effect().run(&problem, &mut ws, &ctx).unwrap();
714 assert_eq!(outcomes.len(), 14);
715 let reports = ValidationSuite::reports_only(&outcomes);
716 assert!(reports.len() >= 10, "reports={}", reports.len());
717 }
718}