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::reisz::ReiszSensitivity;
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 Reisz,
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]
192 pub fn full_effect() -> Self {
193 Self::new()
194 .with(ValidatorId::Placebo)
195 .with(ValidatorId::RandomCommonCause)
196 .with(ValidatorId::Bootstrap)
197 .with(ValidatorId::UnobservedCommonCause)
198 .with(ValidatorId::Overlap)
199 .with(ValidatorId::OverlapRule)
200 .with(ValidatorId::DataSubset)
201 .with(ValidatorId::DummyOutcome)
202 .with(ValidatorId::EValue)
203 .with(ValidatorId::Graph)
204 .with(ValidatorId::LinearSensitivity)
205 .with(ValidatorId::PartialLinearSensitivity)
206 .with(ValidatorId::NonparametricSensitivity)
207 .with(ValidatorId::Reisz)
208 }
209
210 pub fn run(
216 &self,
217 problem: &RefutationProblem<'_>,
218 workspace: &mut EstimationWorkspace,
219 ctx: &ExecutionContext,
220 ) -> Result<Vec<ValidationOutcome>, ValidationError> {
221 let mut out = Vec::with_capacity(self.validators.len() + self.custom.len());
222 for &id in &self.validators {
223 out.push(self.run_one(id, problem, workspace, ctx)?);
224 }
225 for custom in &self.custom {
226 out.push(ValidationOutcome::Report(custom.validate(problem, ctx)?));
227 }
228 Ok(out)
229 }
230
231 pub fn run_with_propensity(
237 &self,
238 problem: &RefutationProblem<'_>,
239 workspace: &mut EstimationWorkspace,
240 propensity: &mut antecedent_stats::PropensityWorkspace,
241 ctx: &ExecutionContext,
242 ) -> Result<Vec<ValidationOutcome>, ValidationError> {
243 let mut out = Vec::with_capacity(self.validators.len() + self.custom.len());
244 for &id in &self.validators {
245 if id == ValidatorId::Overlap {
246 out.push(ValidationOutcome::Report(
247 crate::overlap::OverlapRefuter::new()
248 .refute_with_propensity(problem, propensity)?,
249 ));
250 } else {
251 out.push(self.run_one(id, problem, workspace, ctx)?);
252 }
253 }
254 for custom in &self.custom {
255 out.push(ValidationOutcome::Report(custom.validate(problem, ctx)?));
256 }
257 Ok(out)
258 }
259
260 #[must_use]
262 pub fn reports_only(outcomes: &[ValidationOutcome]) -> Vec<RefutationReport> {
263 outcomes
264 .iter()
265 .filter_map(|o| match o {
266 ValidationOutcome::Report(r) => Some(r.clone()),
267 ValidationOutcome::NotApplicable { .. } => None,
268 })
269 .collect()
270 }
271
272 pub fn run_bayesian(
281 &self,
282 bayes: &mut BayesianSuiteContext<'_>,
283 ctx: &ExecutionContext,
284 ) -> Result<Vec<ValidationOutcome>, ValidationError> {
285 let mut out = Vec::with_capacity(self.validators.len() + self.custom.len());
286 for &id in &self.validators {
287 out.push(self.run_one_bayesian(id, bayes, ctx)?);
288 }
289 let _ = &self.custom;
291 Ok(out)
292 }
293
294 fn run_one(
295 &self,
296 id: ValidatorId,
297 problem: &RefutationProblem<'_>,
298 workspace: &mut EstimationWorkspace,
299 ctx: &ExecutionContext,
300 ) -> Result<ValidationOutcome, ValidationError> {
301 let method = problem.estimand.method_kind().ok();
302 let static_linear = method == Some(antecedent_expr::EstimandMethod::BackdoorAdjustment)
303 && problem.estimator.is_none_or(|e| e == "linear.adjustment.ate");
304 let temporal_linear = method
305 == Some(antecedent_expr::EstimandMethod::TemporalBackdoorUnfolded)
306 && problem.temporal.is_some()
307 && problem.estimator.is_none_or(|e| {
308 matches!(e, "temporal.linear.adjustment" | "bayesian.temporal.gcomp")
309 });
310 let linear_ok = static_linear || temporal_linear;
311 match id {
312 ValidatorId::Placebo => {
313 if !linear_ok {
314 return Ok(na(
315 id,
316 "PlaceboTreatment requires backdoor.adjustment + linear path \
317 (or temporal.backdoor.unfolded + temporal linear path)",
318 ));
319 }
320 Ok(ValidationOutcome::Report(run_validator(
321 &PlaceboTreatment::new(),
322 problem,
323 workspace,
324 ctx,
325 )?))
326 }
327 ValidatorId::RandomCommonCause => {
328 if !linear_ok {
329 return Ok(na(
330 id,
331 "RandomCommonCause requires backdoor.adjustment + linear path \
332 (or temporal.backdoor.unfolded + temporal linear path)",
333 ));
334 }
335 Ok(ValidationOutcome::Report(run_validator(
336 &RandomCommonCause::new(),
337 problem,
338 workspace,
339 ctx,
340 )?))
341 }
342 ValidatorId::Bootstrap => {
343 if !linear_ok {
344 return Ok(na(
345 id,
346 "BootstrapCiCoverage requires backdoor.adjustment + linear path \
347 (or temporal.backdoor.unfolded + temporal linear path)",
348 ));
349 }
350 Ok(ValidationOutcome::Report(run_validator(
351 &BootstrapRefute::new(),
352 problem,
353 workspace,
354 ctx,
355 )?))
356 }
357 ValidatorId::UnobservedCommonCause => {
358 if !linear_ok {
359 return Ok(na(
360 id,
361 "UnobservedCommonCause requires backdoor.adjustment or temporal.backdoor.unfolded",
362 ));
363 }
364 Ok(ValidationOutcome::Report(run_validator(
365 &UnobservedCommonCause::new(),
366 problem,
367 workspace,
368 ctx,
369 )?))
370 }
371 ValidatorId::Overlap => {
372 if problem.temporal.is_some() {
373 return Ok(na(
374 id,
375 "OverlapRefuter not applicable to temporal unfolded designs \
376 (propensity uses schema adjustment columns)",
377 ));
378 }
379 Ok(ValidationOutcome::Report(run_validator(
380 &OverlapRefuter::new(),
381 problem,
382 workspace,
383 ctx,
384 )?))
385 }
386 ValidatorId::OverlapRule => {
387 if problem.temporal.is_some() {
388 return Ok(na(
389 id,
390 "OverlapRuleRefuter not applicable to temporal unfolded designs",
391 ));
392 }
393 Ok(ValidationOutcome::Report(run_validator(
394 &OverlapRuleRefuter::new(),
395 problem,
396 workspace,
397 ctx,
398 )?))
399 }
400 ValidatorId::DataSubset => {
401 if !linear_ok {
402 return Ok(na(
403 id,
404 "DataSubsetRefuter requires backdoor.adjustment + linear path \
405 (or temporal.backdoor.unfolded + temporal linear path)",
406 ));
407 }
408 Ok(ValidationOutcome::Report(run_validator(
409 &DataSubsetRefuter::new(),
410 problem,
411 workspace,
412 ctx,
413 )?))
414 }
415 ValidatorId::DummyOutcome => {
416 if !linear_ok {
417 return Ok(na(
418 id,
419 "DummyOutcome requires backdoor.adjustment + linear path \
420 (or temporal.backdoor.unfolded + temporal linear path)",
421 ));
422 }
423 Ok(ValidationOutcome::Report(run_validator(
424 &DummyOutcome::new(),
425 problem,
426 workspace,
427 ctx,
428 )?))
429 }
430 ValidatorId::EValue => Ok(ValidationOutcome::Report(run_validator(
431 &EValue::new(),
432 problem,
433 workspace,
434 ctx,
435 )?)),
436 ValidatorId::Graph => {
437 if !static_linear {
439 return Ok(na(
440 id,
441 "DropAdjustmentCovariate requires static backdoor.adjustment + linear path \
442 (not applicable to temporal unfolded designs)",
443 ));
444 }
445 Ok(ValidationOutcome::Report(run_validator(
446 &GraphRefuter::new(),
447 problem,
448 workspace,
449 ctx,
450 )?))
451 }
452 ValidatorId::LinearSensitivity => {
453 if !linear_ok {
454 return Ok(na(
455 id,
456 "LinearSensitivity requires backdoor.adjustment or temporal.backdoor.unfolded",
457 ));
458 }
459 Ok(ValidationOutcome::Report(run_validator(
460 &LinearSensitivity::new(),
461 problem,
462 workspace,
463 ctx,
464 )?))
465 }
466 ValidatorId::PartialLinearSensitivity => {
467 if !linear_ok {
468 return Ok(na(
469 id,
470 "PartialLinearSensitivity requires backdoor.adjustment or temporal.backdoor.unfolded",
471 ));
472 }
473 Ok(ValidationOutcome::Report(run_validator(
474 &PartialLinearSensitivity::new(),
475 problem,
476 workspace,
477 ctx,
478 )?))
479 }
480 ValidatorId::NonparametricSensitivity => Ok(ValidationOutcome::Report(run_validator(
481 &NonparametricSensitivity::new(),
482 problem,
483 workspace,
484 ctx,
485 )?)),
486 ValidatorId::Reisz => {
487 if problem.temporal.is_some() {
488 return Ok(na(
489 id,
490 "ReiszSensitivity not applicable to temporal unfolded designs",
491 ));
492 }
493 Ok(ValidationOutcome::Report(run_validator(
494 &ReiszSensitivity::new(),
495 problem,
496 workspace,
497 ctx,
498 )?))
499 }
500 ValidatorId::PriorPredictive
501 | ValidatorId::PosteriorPredictive
502 | ValidatorId::PriorSensitivity
503 | ValidatorId::McmcDiagnostics => Ok(na(
504 id,
505 "Bayesian PPC/prior-sensitivity/MCMC diagnostics require ValidationSuite::run_bayesian with a fitted posterior",
506 )),
507 }
508 }
509
510 fn run_one_bayesian(
511 &self,
512 id: ValidatorId,
513 bayes: &mut BayesianSuiteContext<'_>,
514 ctx: &ExecutionContext,
515 ) -> Result<ValidationOutcome, ValidationError> {
516 match id {
517 ValidatorId::PriorPredictive => {
518 let check = PriorPredictiveCheck {
519 n_sims: 200,
520 seed: ctx.rng.master_seed(),
521 ..PriorPredictiveCheck::new()
522 };
523 let rep = check.check(bayes.prepared, ctx)?;
524 Ok(ValidationOutcome::Report(
525 rep.to_refutation_report(bayes.original_ate, bayes.ppc_alpha),
526 ))
527 }
528 ValidatorId::PosteriorPredictive => {
529 let check = PosteriorPredictiveCheck::new();
530 let rep = check.check(bayes.prepared, bayes.posterior)?;
531 Ok(ValidationOutcome::Report(
532 rep.to_refutation_report(bayes.original_ate, bayes.ppc_alpha),
533 ))
534 }
535 ValidatorId::PriorSensitivity => {
536 let sens = PriorSensitivity::standard_grid();
537 let (summary, _posts) = sens.evaluate(
538 bayes.estimator,
539 bayes.prepared,
540 bayes.identification,
541 bayes.workspace,
542 ctx,
543 )?;
544 Ok(ValidationOutcome::Report(sens.to_report(&summary, bayes.original_ate)))
545 }
546 ValidatorId::McmcDiagnostics => {
547 match McmcDiagnosticsCheck::new().check(bayes.posterior) {
548 Some(rep) => Ok(ValidationOutcome::Report(rep)),
549 None => Ok(na(
550 ValidatorId::McmcDiagnostics,
551 "MCMC diagnostics require an HMC/SMC posterior (Laplace/conjugate NotApplicable)",
552 )),
553 }
554 }
555 other => {
556 Ok(na(other, "validator is not a Bayesian diagnostic; use ValidationSuite::run"))
557 }
558 }
559 }
560
561 #[must_use]
563 pub fn bayesian_diagnostics() -> Self {
564 Self::new()
565 .with(ValidatorId::PriorPredictive)
566 .with(ValidatorId::PosteriorPredictive)
567 .with(ValidatorId::PriorSensitivity)
568 .with(ValidatorId::McmcDiagnostics)
569 }
570
571 #[must_use]
573 pub fn prior_predictive() -> Self {
574 Self::new().with(ValidatorId::PriorPredictive)
575 }
576}
577
578fn na(id: ValidatorId, reason: &str) -> ValidationOutcome {
579 ValidationOutcome::NotApplicable { validator: id, reason: Arc::from(reason) }
580}
581
582#[cfg(test)]
583mod tests {
584 use antecedent_core::{
585 AssumptionSet, AverageEffectQuery, CausalSchemaBuilder, ExecutionContext, MeasurementSpec,
586 RoleHint, SmallRoleSet, ValueType, VariableId,
587 };
588 use antecedent_data::{
589 Float64Column, OwnedColumn, OwnedColumnarStorage, TabularData, ValidityBitmap,
590 };
591 use antecedent_estimate::{EstimationWorkspace, LinearAdjustmentAte};
592 use antecedent_expr::ExprId;
593 use antecedent_identify::IdentifiedEstimand;
594
595 use super::*;
596 use crate::common::RefutationProblem;
597
598 fn toy() -> (TabularData, IdentifiedEstimand) {
599 let n = 120usize;
600 let mut b = CausalSchemaBuilder::new();
601 b.add_variable(
602 "t",
603 ValueType::Continuous,
604 SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
605 None,
606 None,
607 MeasurementSpec::default(),
608 )
609 .unwrap();
610 b.add_variable(
611 "y",
612 ValueType::Continuous,
613 SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
614 None,
615 None,
616 MeasurementSpec::default(),
617 )
618 .unwrap();
619 b.add_variable(
620 "z",
621 ValueType::Continuous,
622 SmallRoleSet::from_hint(RoleHint::Context),
623 None,
624 None,
625 MeasurementSpec::default(),
626 )
627 .unwrap();
628 let schema = b.build().unwrap();
629 let t: Vec<f64> = (0..n).map(|i| (i % 2) as f64).collect();
630 let z: Vec<f64> = (0..n).map(|i| (i as f64) / n as f64).collect();
631 let y: Vec<f64> = (0..n).map(|i| 1.0 + 2.0 * t[i] + z[i]).collect();
632 let cols = vec![
633 OwnedColumn::Float64(
634 Float64Column::new(
635 VariableId::from_raw(0),
636 Arc::from(t),
637 ValidityBitmap::all_valid(n),
638 )
639 .unwrap(),
640 ),
641 OwnedColumn::Float64(
642 Float64Column::new(
643 VariableId::from_raw(1),
644 Arc::from(y),
645 ValidityBitmap::all_valid(n),
646 )
647 .unwrap(),
648 ),
649 OwnedColumn::Float64(
650 Float64Column::new(
651 VariableId::from_raw(2),
652 Arc::from(z),
653 ValidityBitmap::all_valid(n),
654 )
655 .unwrap(),
656 ),
657 ];
658 let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
659 let estimand = IdentifiedEstimand::backdoor(
660 "backdoor.adjustment",
661 Arc::from([VariableId::from_raw(2)]),
662 ExprId::from_raw(0),
663 );
664 (TabularData::new(storage), estimand)
665 }
666
667 #[test]
668 fn full_suite_runs_applicable_validators() {
669 let (data, estimand) = toy();
670 let query =
671 AverageEffectQuery::binary_ate(VariableId::from_raw(0), VariableId::from_raw(1));
672 let est = LinearAdjustmentAte { bootstrap_replicates: 0, ..LinearAdjustmentAte::new() };
673 let prep = est.prepare(&data, &estimand, &query).unwrap();
674 let mut ws = EstimationWorkspace::default();
675 let ctx = ExecutionContext::for_tests(2);
676 let original = est.fit(&prep, &mut ws, &ctx, AssumptionSet::new()).unwrap();
677 let problem = RefutationProblem {
678 data: &data,
679 estimand: &estimand,
680 query: &query,
681 original: &original,
682 estimator: Some("linear.adjustment.ate"),
683 temporal: None,
684 };
685 let outcomes = ValidationSuite::full_effect().run(&problem, &mut ws, &ctx).unwrap();
686 assert_eq!(outcomes.len(), 14);
687 let reports = ValidationSuite::reports_only(&outcomes);
688 assert!(reports.len() >= 10, "reports={}", reports.len());
689 }
690}