1#![allow(
6 clippy::cast_precision_loss,
7 clippy::cast_possible_truncation,
8 clippy::needless_range_loop,
9 clippy::too_many_arguments,
10 clippy::too_many_lines,
11 clippy::needless_pass_by_value,
12 clippy::doc_markdown,
13 clippy::many_single_char_names
14)]
15
16use std::sync::Arc;
17
18use antecedent_core::IdentificationStatus;
19use antecedent_core::{
20 Assumption, AssumptionRecord, AssumptionScope, AssumptionSet, AssumptionSource,
21 AssumptionStatus, AverageEffectQuery, ExecutionContext, PriorAssumption, TargetPopulation,
22 VariableId,
23};
24use antecedent_data::{TableView, TabularData};
25use antecedent_expr::IdentifiedEstimand;
26use antecedent_prob::{
27 BayesDesignRef, BayesFitOptions, BayesLikelihood, ConflictSummary, ConjugateGaussianBackend,
28 EffectBatch, EffectPrior, GaussianCoefficientPrior, GaussianVarianceModel, HmcGlmBackend,
29 HmcOptions, InferenceBackend, InferenceDiagnostics, LaplaceGlmBackend, LaplaceWorkspace,
30 PosteriorBatch, PosteriorDraws, PosteriorEvalWorkspace, PosteriorQuantityKind, PosteriorSchema,
31 PosteriorSummary, PriorSensitivitySummary, PriorSet, PriorSpec, sample_gaussian_mvn,
32};
33use antecedent_stats::{CompiledDesign, DesignColumnRole, GlmFamily};
34
35use crate::adjustment::{PreparedEstimationProblem, intervention_f64};
36use crate::error::EstimationError;
37use crate::overlap::OverlapPolicy;
38use crate::util::require_explicit_override;
39
40const HMC_MIN_DRAWS: usize = 3_000;
43
44#[derive(Clone, Debug)]
46pub struct CausalPosterior {
47 pub draws: PosteriorDraws,
49 pub summaries: PosteriorSummary,
51 pub identification: IdentificationStatus,
53 pub prior_sensitivity: Option<PriorSensitivitySummary>,
55 pub conflict_summary: Option<ConflictSummary>,
57 pub diagnostics: InferenceDiagnostics,
59 pub assumptions: AssumptionSet,
61 pub unidentified_mass: f64,
63 pub early_stopped: bool,
65}
66
67impl CausalPosterior {
68 #[must_use]
70 pub fn effect_column(&self) -> Option<usize> {
71 self.draws
72 .schema
73 .quantities
74 .iter()
75 .position(|q| matches!(q, PosteriorQuantityKind::Effect { .. }))
76 }
77
78 pub fn probability_below(&self, threshold: f64) -> Result<f64, EstimationError> {
84 let q = self
85 .effect_column()
86 .ok_or_else(|| EstimationError::stats_msg("CausalPosterior has no effect column"))?;
87 self.draws.probability_below(q, threshold).map_err(EstimationError::from)
88 }
89}
90
91const HYDRATE_VAR_FLOOR: f64 = 1e-12;
93
94pub fn hydrate_prior_from_quantity_summaries(
105 quantities: &[PosteriorQuantityKind],
106 mean: &[f64],
107 sd: &[f64],
108 expected_n_coef: Option<usize>,
109) -> Result<PriorSet, EstimationError> {
110 if mean.len() != quantities.len() || sd.len() != quantities.len() {
111 return Err(EstimationError::stats_msg(
112 "hydrate_prior: mean/sd length must match quantities",
113 ));
114 }
115 let mut coef_cols: Vec<(usize, usize)> = quantities
116 .iter()
117 .enumerate()
118 .filter_map(|(col, q)| match q {
119 PosteriorQuantityKind::Coefficient { index, .. } => Some((*index, col)),
120 _ => None,
121 })
122 .collect();
123 coef_cols.sort_by_key(|(index, _)| *index);
124 let n_coef = coef_cols.len();
125 if n_coef == 0 {
126 return Err(EstimationError::stats_msg(
127 "hydrate_prior_from_posterior: no coefficient columns in posterior",
128 ));
129 }
130 if let Some(expected) = expected_n_coef {
131 if n_coef != expected {
132 return Err(EstimationError::stats_msg(format!(
133 "posterior coefficient dimension {n_coef} != expected n_coef {expected}"
134 )));
135 }
136 }
137 for (i, (index, _)) in coef_cols.iter().enumerate() {
138 if *index != i {
139 return Err(EstimationError::stats_msg(format!(
140 "posterior coefficient indices are not contiguous (expected {i}, got {index})"
141 )));
142 }
143 }
144 let mut means = Vec::with_capacity(n_coef);
145 let mut variance = Vec::with_capacity(n_coef);
146 for (_, col) in &coef_cols {
147 let m = mean[*col];
148 let s = sd[*col];
149 if !m.is_finite() || !s.is_finite() {
150 return Err(EstimationError::stats_msg(
151 "posterior coefficient summary is non-finite; cannot hydrate prior",
152 ));
153 }
154 means.push(m);
155 variance.push((s * s).max(HYDRATE_VAR_FLOOR));
156 }
157 let coef = GaussianCoefficientPrior { mean: Arc::from(means), variance: Arc::from(variance) };
158 coef.validate().map_err(EstimationError::from)?;
159 Ok(PriorSet {
160 specs: vec![PriorSpec::GaussianCoefficients(coef)],
161 contrast: None,
162 categorical: Vec::new(),
163 restrictions: Vec::new(),
164 })
165}
166
167pub fn hydrate_prior_from_posterior(
173 posterior: &CausalPosterior,
174 expected_n_coef: Option<usize>,
175) -> Result<PriorSet, EstimationError> {
176 hydrate_prior_from_quantity_summaries(
177 &posterior.draws.schema.quantities,
178 &posterior.summaries.mean,
179 &posterior.summaries.sd,
180 expected_n_coef,
181 )
182}
183
184#[derive(Clone, Debug, PartialEq, Eq, Hash)]
189pub enum HydrateMapping {
190 IdenticalCoefficientSubspace,
192 EffectFunctional {
194 source_quantity: String,
196 },
197 NamedParameters {
199 pairs: Vec<(String, String)>,
201 },
202}
203
204pub fn hydrate_prior(
220 mapping: &HydrateMapping,
221 quantities: &[PosteriorQuantityKind],
222 mean: &[f64],
223 sd: &[f64],
224 baseline: &PriorSet,
225 target_coef_names: &[Arc<str>],
226 treatment_col: Option<usize>,
227) -> Result<PriorSet, EstimationError> {
228 if mean.len() != quantities.len() || sd.len() != quantities.len() {
229 return Err(EstimationError::stats_msg(
230 "hydrate_prior: mean/sd length must match quantities",
231 ));
232 }
233 let n_target = target_coef_names.len();
234 let base_coef = baseline.gaussian_coefficients().ok_or_else(|| {
235 EstimationError::stats_msg("hydrate_prior: baseline missing GaussianCoefficients")
236 })?;
237 if base_coef.len() != n_target {
238 return Err(EstimationError::stats_msg(format!(
239 "hydrate_prior: baseline n_coef {} != target_coef_names {}",
240 base_coef.len(),
241 n_target
242 )));
243 }
244
245 match mapping {
246 HydrateMapping::IdenticalCoefficientSubspace => {
247 let mut prior =
248 hydrate_prior_from_quantity_summaries(quantities, mean, sd, Some(n_target))?;
249 merge_baseline_residuals(&mut prior, baseline);
251 Ok(prior)
252 }
253 HydrateMapping::EffectFunctional { source_quantity } => {
254 let t_col = treatment_col.ok_or_else(|| {
255 EstimationError::stats_msg("hydrate_prior: EffectFunctional requires treatment_col")
256 })?;
257 if t_col >= n_target {
258 return Err(EstimationError::stats_msg(format!(
259 "hydrate_prior: treatment_col {t_col} out of range for {n_target} coefs"
260 )));
261 }
262 let (m, s) = quantity_moments(quantities, mean, sd, source_quantity.as_str())?;
263 let effect = EffectPrior::new(m, s.max(HYDRATE_VAR_FLOOR.sqrt()))
264 .map_err(EstimationError::from)?;
265 let mut means: Vec<f64> = base_coef.mean.to_vec();
266 let mut vars: Vec<f64> = base_coef.variance.to_vec();
267 means[t_col] = effect.mean;
268 vars[t_col] = (effect.sd * effect.sd).max(HYDRATE_VAR_FLOOR);
269 let coef =
270 GaussianCoefficientPrior { mean: Arc::from(means), variance: Arc::from(vars) };
271 coef.validate().map_err(EstimationError::from)?;
272 let mut prior = PriorSet {
273 specs: vec![PriorSpec::GaussianCoefficients(coef)],
274 contrast: baseline.contrast,
275 categorical: baseline.categorical.clone(),
276 restrictions: vec![PriorAssumption {
277 id: Arc::from("external_effect_prior"),
278 description: Arc::from(format!(
279 "external effect-functional prior from quantity `{source_quantity}` onto treatment coefficient"
280 )),
281 }],
282 };
283 merge_baseline_residuals(&mut prior, baseline);
284 Ok(prior)
285 }
286 HydrateMapping::NamedParameters { pairs } => {
287 if pairs.is_empty() {
288 return Err(EstimationError::stats_msg(
289 "hydrate_prior: NamedParameters requires at least one pair",
290 ));
291 }
292 let mut means: Vec<f64> = base_coef.mean.to_vec();
293 let mut vars: Vec<f64> = base_coef.variance.to_vec();
294 let name_index: std::collections::HashMap<&str, usize> =
295 target_coef_names.iter().enumerate().map(|(i, n)| (n.as_ref(), i)).collect();
296 for (src, tgt) in pairs {
297 let (m, s) = quantity_moments(quantities, mean, sd, src)?;
298 let Some(&idx) = name_index.get(tgt.as_str()) else {
299 return Err(EstimationError::stats_msg(format!(
300 "hydrate_prior: unknown target coefficient name `{tgt}`"
301 )));
302 };
303 means[idx] = m;
304 vars[idx] = (s * s).max(HYDRATE_VAR_FLOOR);
305 }
306 let coef =
307 GaussianCoefficientPrior { mean: Arc::from(means), variance: Arc::from(vars) };
308 coef.validate().map_err(EstimationError::from)?;
309 let pair_desc =
310 pairs.iter().map(|(a, b)| format!("{a}->{b}")).collect::<Vec<_>>().join(", ");
311 let mut prior = PriorSet {
312 specs: vec![PriorSpec::GaussianCoefficients(coef)],
313 contrast: baseline.contrast,
314 categorical: baseline.categorical.clone(),
315 restrictions: vec![PriorAssumption {
316 id: Arc::from("external_named_prior"),
317 description: Arc::from(format!("external named-parameter prior ({pair_desc})")),
318 }],
319 };
320 merge_baseline_residuals(&mut prior, baseline);
321 Ok(prior)
322 }
323 }
324}
325
326fn merge_baseline_residuals(prior: &mut PriorSet, baseline: &PriorSet) {
327 for spec in &baseline.specs {
328 match spec {
329 PriorSpec::ResidualInvGamma(_) | PriorSpec::KnownResidualVariance(_) => {
330 if !prior.specs.iter().any(|s| {
331 matches!(
332 s,
333 PriorSpec::ResidualInvGamma(_) | PriorSpec::KnownResidualVariance(_)
334 )
335 }) {
336 prior.specs.push(spec.clone());
337 }
338 }
339 PriorSpec::GaussianCoefficients(_) => {}
340 }
341 }
342}
343
344fn quantity_moments(
345 quantities: &[PosteriorQuantityKind],
346 mean: &[f64],
347 sd: &[f64],
348 name: &str,
349) -> Result<(f64, f64), EstimationError> {
350 for (i, q) in quantities.iter().enumerate() {
351 let q_name = match q {
352 PosteriorQuantityKind::Effect { name: n }
353 | PosteriorQuantityKind::Scalar { name: n } => Some(n.as_ref()),
354 PosteriorQuantityKind::Coefficient { name: n, .. } => {
355 n.as_ref().map(std::convert::AsRef::as_ref)
356 }
357 PosteriorQuantityKind::ResidualVariance => Some("residual_variance"),
358 };
359 if q_name == Some(name) {
360 let m = mean[i];
361 let s = sd[i];
362 if !m.is_finite() || !s.is_finite() {
363 return Err(EstimationError::stats_msg(format!(
364 "hydrate_prior: non-finite summary for quantity `{name}`"
365 )));
366 }
367 return Ok((m, s.max(HYDRATE_VAR_FLOOR.sqrt())));
368 }
369 }
370 Err(EstimationError::stats_msg(format!("hydrate_prior: missing quantity `{name}`")))
371}
372
373#[derive(Clone, Debug)]
375pub struct BayesianGlmMechanism {
376 pub coefficient_draws: PosteriorDraws,
378 pub map: Vec<f64>,
380 pub likelihood: BayesLikelihood,
382 pub diagnostics: InferenceDiagnostics,
384 pub design: CompiledDesign,
386 pub treatment_col: usize,
388 pub active: f64,
390 pub control: f64,
392}
393
394#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
396pub enum BayesianBackendKind {
397 ConjugateGaussian,
399 Laplace,
401 Hmc,
403}
404
405#[derive(Clone, Debug)]
407pub struct BayesianGComputationAte {
408 pub backend: BayesianBackendKind,
410 pub likelihood: BayesLikelihood,
412 pub n_draws: usize,
414 pub seed: u64,
416 pub overlap: OverlapPolicy,
418 pub prior_scale: f64,
420 pub prior: Option<PriorSet>,
423}
424
425impl Default for BayesianGComputationAte {
426 fn default() -> Self {
427 Self::new()
428 }
429}
430
431impl BayesianGComputationAte {
432 #[must_use]
434 pub fn new() -> Self {
435 Self {
436 backend: BayesianBackendKind::Laplace,
437 likelihood: BayesLikelihood::GaussianIdentity,
438 n_draws: 1000,
439 seed: 0,
440 overlap: OverlapPolicy::ExplicitOverride,
441 prior_scale: 10.0,
442 prior: None,
443 }
444 }
445
446 #[must_use]
448 pub fn conjugate() -> Self {
449 Self {
450 backend: BayesianBackendKind::ConjugateGaussian,
451 likelihood: BayesLikelihood::GaussianIdentity,
452 ..Self::new()
453 }
454 }
455
456 #[must_use]
458 pub const fn with_backend(mut self, backend: BayesianBackendKind) -> Self {
459 self.backend = backend;
460 self
461 }
462
463 #[must_use]
468 pub const fn with_likelihood(mut self, likelihood: BayesLikelihood) -> Self {
469 self.likelihood = likelihood;
470 self
471 }
472
473 #[must_use]
478 pub const fn with_n_draws(mut self, n_draws: usize) -> Self {
479 self.n_draws = n_draws;
480 self
481 }
482
483 #[must_use]
485 pub const fn with_seed(mut self, seed: u64) -> Self {
486 self.seed = seed;
487 self
488 }
489
490 #[must_use]
492 pub const fn with_overlap(mut self, overlap: OverlapPolicy) -> Self {
493 self.overlap = overlap;
494 self
495 }
496
497 #[must_use]
501 pub const fn with_prior_scale(mut self, prior_scale: f64) -> Self {
502 self.prior_scale = prior_scale;
503 self
504 }
505
506 #[must_use]
509 pub fn with_prior(mut self, prior: PriorSet) -> Self {
510 self.prior = Some(prior);
511 self
512 }
513
514 pub fn prepare(
520 &self,
521 data: &TabularData,
522 estimand: &IdentifiedEstimand,
523 query: &AverageEffectQuery,
524 ) -> Result<PreparedBayesianProblem, EstimationError> {
525 require_explicit_override(
526 self.overlap,
527 "BayesianGComputationAte requires ExplicitOverride overlap policy",
528 )?;
529 if !matches!(
530 estimand.method_kind().ok(),
531 Some(
532 antecedent_expr::EstimandMethod::BackdoorAdjustment
533 | antecedent_expr::EstimandMethod::BackdoorEfficient
534 )
535 ) {
536 return Err(EstimationError::IncompatibleEstimand {
537 message: "BayesianGComputationAte expects backdoor.adjustment/efficient",
538 });
539 }
540 query.validate()?;
541 if !query.effect_modifiers.is_empty() {
542 return Err(EstimationError::unsupported(
543 "Bayesian g-comp does not support effect modifiers",
544 ));
545 }
546 if query.target_population != TargetPopulation::AllObserved {
547 return Err(EstimationError::unsupported(
548 "Bayesian g-comp only supports TargetPopulation::AllObserved",
549 ));
550 }
551 let active = intervention_f64(&query.active)?;
552 let control = intervention_f64(&query.control)?;
553 if (active - control).abs() < f64::EPSILON {
554 return Err(EstimationError::unsupported(
555 "active and control treatment levels must differ",
556 ));
557 }
558
559 let treatment = query.treatment;
560 let outcome = query.outcome;
561 let mut ids = Vec::with_capacity(2 + estimand.adjustment_set.len());
562 ids.push(treatment);
563 ids.push(outcome);
564 ids.extend_from_slice(&estimand.adjustment_set);
565 let row_mask = data.complete_case_mask(&ids).map_err(EstimationError::from)?;
566 let t = data.float64_masked(treatment, &row_mask).map_err(EstimationError::from)?;
567 let y = data.float64_masked(outcome, &row_mask).map_err(EstimationError::from)?;
568 let mut covs: Vec<(VariableId, Vec<f64>)> = Vec::new();
569 for &z in estimand.adjustment_set.iter() {
570 covs.push((z, data.float64_masked(z, &row_mask).map_err(EstimationError::from)?));
571 }
572 let cov_refs: Vec<(VariableId, &[f64])> =
573 covs.iter().map(|(id, v)| (*id, v.as_slice())).collect();
574 let selected_rows: Vec<usize> =
575 row_mask.iter().enumerate().filter_map(|(i, keep)| keep.then_some(i)).collect();
576 let design = CompiledDesign::linear_adjustment(&t, &cov_refs, &y, &selected_rows)
577 .map_err(EstimationError::from)?;
578 let schema = data.schema();
579 let treatment_name = schema.get(treatment).map(|v| v.name.as_ref()).unwrap_or("treatment");
580 let coef_names = coefficient_names_from_design(&design, treatment_name, |id| {
581 schema.get(id).ok().map(|v| Arc::clone(&v.name))
582 });
583 Ok(PreparedBayesianProblem {
584 design,
585 method: Arc::clone(&estimand.method),
586 adjustment_set: Arc::clone(&estimand.adjustment_set),
587 active,
588 control,
589 overlap: self.overlap,
590 coef_names: Some(coef_names),
591 })
592 }
593
594 #[must_use]
599 pub fn from_prepared_estimation(prep: &PreparedEstimationProblem) -> PreparedBayesianProblem {
600 PreparedBayesianProblem {
601 design: prep.design.clone(),
602 method: Arc::clone(&prep.method),
603 adjustment_set: Arc::clone(&prep.adjustment_set),
604 active: prep.active,
605 control: prep.control,
606 overlap: prep.overlap,
607 coef_names: None,
608 }
609 }
610
611 pub fn fit(
619 &self,
620 problem: &PreparedBayesianProblem,
621 identification: IdentificationStatus,
622 workspace: &mut BayesianGCompWorkspace,
623 ctx: &ExecutionContext,
624 ) -> Result<CausalPosterior, EstimationError> {
625 let sequential = self.prior.is_some();
626 let prior = if let Some(p) = &self.prior {
627 if let Some(coef) = p.gaussian_coefficients() {
628 if coef.len() != problem.design.ncols {
629 return Err(EstimationError::stats_msg(format!(
630 "sequential prior coefficient dimension {} != design ncols {}",
631 coef.len(),
632 problem.design.ncols
633 )));
634 }
635 } else {
636 return Err(EstimationError::stats_msg(
637 "sequential prior missing GaussianCoefficients entry",
638 ));
639 }
640 p.clone()
641 } else {
642 PriorSet {
643 specs: vec![PriorSpec::GaussianCoefficients(
644 antecedent_prob::GaussianCoefficientPrior::isotropic(
645 problem.design.ncols,
646 self.prior_scale,
647 ),
648 )],
649 contrast: None,
650 categorical: Vec::new(),
651 restrictions: Vec::new(),
652 }
653 };
654 let mut assumptions = AssumptionSet::new();
655 let source = if sequential {
656 AssumptionSource::Artifact
657 } else {
658 AssumptionSource::AlgorithmDefault { algorithm: Arc::from("bayesian_gcomp") }
659 };
660 for spec in &prior.specs {
661 let mut pa = spec.as_assumption();
662 if sequential {
663 pa.description = Arc::from(format!(
664 "{} (sequential prior from posterior artifact)",
665 pa.description
666 ));
667 }
668 assumptions.push(AssumptionRecord {
669 assumption: Assumption::PriorRestriction(pa),
670 source: source.clone(),
671 scope: AssumptionScope::Estimation,
672 status: AssumptionStatus::Untestable,
673 });
674 }
675 for pa in &prior.restrictions {
676 assumptions.push(AssumptionRecord {
677 assumption: Assumption::PriorRestriction(pa.clone()),
678 source: AssumptionSource::Artifact,
679 scope: AssumptionScope::Estimation,
680 status: AssumptionStatus::Untestable,
681 });
682 }
683
684 let likelihood = match self.backend {
685 BayesianBackendKind::ConjugateGaussian => BayesLikelihood::GaussianIdentity,
686 BayesianBackendKind::Laplace | BayesianBackendKind::Hmc => self.likelihood,
687 };
688 let max_draws = match self.backend {
692 BayesianBackendKind::Hmc => self.n_draws.max(HMC_MIN_DRAWS),
693 _ => self.n_draws.max(1),
694 };
695 let adaptive = ctx.adaptive_draws;
696 let laplace_mvn_redraw_supported = match likelihood {
701 BayesLikelihood::GaussianIdentity => matches!(
702 GaussianVarianceModel::from_prior_set(&prior).map_err(prob_err)?,
703 GaussianVarianceModel::Known { .. }
704 ),
705 _ => true,
706 };
707 let laplace_adaptive = adaptive.enabled
708 && matches!(self.backend, BayesianBackendKind::Laplace)
709 && laplace_mvn_redraw_supported
710 && max_draws > adaptive.min_draws.max(2);
711 let initial_draws =
712 if laplace_adaptive { adaptive.min_draws.max(2).min(max_draws) } else { max_draws };
713 let opts = BayesFitOptions {
714 n_draws: initial_draws,
715 seed: self.seed,
716 ..BayesFitOptions::default()
717 };
718 let design_ref = BayesDesignRef {
719 x_colmajor: &problem.design.matrix,
720 nrows: problem.design.nrows,
721 ncols: problem.design.ncols,
722 y: &problem.design.outcome,
723 weights: None,
724 offsets: None,
725 };
726
727 let fit = match self.backend {
728 BayesianBackendKind::ConjugateGaussian => ConjugateGaussianBackend.fit(
729 likelihood,
730 design_ref,
731 &prior,
732 &opts,
733 &mut workspace.laplace,
734 ctx,
735 ),
736 BayesianBackendKind::Laplace => LaplaceGlmBackend.fit(
737 likelihood,
738 design_ref,
739 &prior,
740 &opts,
741 &mut workspace.laplace,
742 ctx,
743 ),
744 BayesianBackendKind::Hmc => {
745 let n_warmup = max_draws.max(1_500);
748 HmcGlmBackend::new()
749 .with_options(HmcOptions {
750 n_chains: 4,
751 n_warmup,
752 leapfrog_steps: 16,
753 step_size: 0.04,
754 target_accept: 0.85,
755 ..HmcOptions::default()
756 })
757 .fit(likelihood, design_ref, &prior, &opts, &mut workspace.laplace, ctx)
758 }
759 }
760 .map_err(prob_err)?;
761
762 if !fit.diagnostics.allows_posterior() {
763 return Err(EstimationError::stats_msg("Bayesian fit refused without diagnostics"));
764 }
765
766 let t_col = problem
767 .design
768 .treatment_column()
769 .ok_or_else(|| EstimationError::stats_msg("missing treatment column"))?;
770
771 let glm_family = likelihood_to_glm_family(likelihood);
772 let evaluator = GCompAteEvaluator {
773 family: glm_family,
774 treatment_col: t_col,
775 active: problem.active,
776 control: problem.control,
777 nrows: problem.design.nrows,
778 ncols: problem.design.ncols,
779 matrix: Arc::clone(&problem.design.matrix),
780 };
781 let compiled = evaluator.compile()?;
782
783 let mut early_stopped = false;
784 let mut n_draws = fit.draws.n_draws;
785 let coef_draws = coefficient_only_draws(&fit.draws)?;
788
789 if laplace_adaptive {
790 let cov = fit.cov.as_ref().ok_or_else(|| {
791 EstimationError::stats_msg("Laplace adaptive draws require posterior covariance")
792 })?;
793 let map = fit.map.clone();
794 let batch = 32usize;
795 let mut effect_acc: Vec<f64> = Vec::with_capacity(max_draws);
796 let mut extra_blocks: Vec<PosteriorDraws> = Vec::new();
797 let mut width_prev: Option<f64> = None;
798
799 {
801 workspace.eval.prepare(n_draws, problem.design.ncols);
802 let mut effect_out = EffectBatch::default();
803 effect_out.prepare(n_draws);
804 let batch_view = coef_draws.batch(0, n_draws).map_err(EstimationError::from)?;
805 evaluator.evaluate_batch(
806 &compiled,
807 batch_view,
808 &mut effect_out,
809 &mut workspace.eval,
810 ctx,
811 )?;
812 effect_acc.extend_from_slice(&effect_out.values[..n_draws]);
813 }
814
815 loop {
816 let width = quantile_width_95(&effect_acc);
817 let ess = effect_acc.len() as f64; if effect_acc.len() >= adaptive.min_draws.max(2) {
819 let width_ok = width_prev.is_some_and(|prev| {
820 let rel = (width - prev).abs() / prev.abs().max(1e-12);
821 rel < adaptive.quantile_width_rel_epsilon
822 });
823 if width_ok || ess >= adaptive.ess_target {
824 early_stopped = n_draws < max_draws;
825 break;
826 }
827 }
828 width_prev = Some(width);
829 if n_draws >= max_draws {
830 break;
831 }
832 let next = (n_draws + batch).min(max_draws);
833 let add = next - n_draws;
834 let extra = sample_gaussian_mvn(
835 &map,
836 cov,
837 add,
838 self.seed.wrapping_add(n_draws as u64),
839 &mut workspace.laplace,
840 )
841 .map_err(EstimationError::from)?;
842 let extra_draws = PosteriorDraws::from_column_major(
843 PosteriorSchema::coefficients(problem.design.ncols),
844 add,
845 extra,
846 )
847 .map_err(EstimationError::from)?;
848 workspace.eval.prepare(add, problem.design.ncols);
849 let mut effect_out = EffectBatch::default();
850 effect_out.prepare(add);
851 let batch_view = extra_draws.batch(0, add).map_err(EstimationError::from)?;
852 evaluator.evaluate_batch(
853 &compiled,
854 batch_view,
855 &mut effect_out,
856 &mut workspace.eval,
857 ctx,
858 )?;
859 effect_acc.extend_from_slice(&effect_out.values[..add]);
860 extra_blocks.push(extra_draws);
863 n_draws = next;
864 }
865
866 let mechanism_draws = concat_coefficient_draws(&coef_draws, &extra_blocks)?;
868 let mut quantities = mechanism_draws.schema.quantities.to_vec();
869 quantities.retain(|q| !matches!(q, PosteriorQuantityKind::ResidualVariance));
870 let effect_idx = quantities.len();
871 quantities.push(PosteriorQuantityKind::Effect { name: Arc::from("ate") });
872 let n_q = quantities.len();
873 let mut values = vec![0.0; n_draws * n_q];
874 for (qi, q) in mechanism_draws.schema.quantities.iter().enumerate() {
875 if matches!(q, PosteriorQuantityKind::ResidualVariance) {
876 continue;
877 }
878 let dest = quantities.iter().position(|qq| qq == q).ok_or_else(|| {
879 EstimationError::stats_msg(format!(
880 "posterior quantity missing from schema: {q:?}"
881 ))
882 })?;
883 let coef_col = mechanism_draws.column(qi).map_err(EstimationError::from)?;
884 values[dest * n_draws..(dest + 1) * n_draws].copy_from_slice(coef_col);
885 }
886 values[effect_idx * n_draws..(effect_idx + 1) * n_draws]
887 .copy_from_slice(&effect_acc[..n_draws]);
888 if let Some(names) = problem.coef_names.as_ref() {
889 apply_coefficient_names(&mut quantities, names);
890 }
891 let draws = PosteriorDraws::from_column_major(
892 PosteriorSchema { quantities: Arc::from(quantities) },
893 n_draws,
894 values,
895 )
896 .map_err(EstimationError::from)?;
897 let summaries = draws.summarize();
898 return Ok(CausalPosterior {
899 draws,
900 summaries,
901 identification,
902 prior_sensitivity: None,
903 conflict_summary: None,
904 diagnostics: fit.diagnostics,
905 assumptions,
906 unidentified_mass: 0.0,
907 early_stopped,
908 });
909 }
910
911 let mechanism = BayesianGlmMechanism {
912 coefficient_draws: coef_draws,
913 map: fit.map,
914 likelihood,
915 diagnostics: fit.diagnostics.clone(),
916 design: problem.design.clone(),
917 treatment_col: t_col,
918 active: problem.active,
919 control: problem.control,
920 };
921
922 workspace.eval.prepare(n_draws, problem.design.ncols);
923 let mut effect_out = EffectBatch::default();
924 effect_out.prepare(n_draws);
925 let batch = mechanism.coefficient_draws.batch(0, n_draws).map_err(EstimationError::from)?;
926 evaluator.evaluate_batch(&compiled, batch, &mut effect_out, &mut workspace.eval, ctx)?;
927
928 let mut quantities = mechanism.coefficient_draws.schema.quantities.to_vec();
929 quantities.retain(|q| !matches!(q, PosteriorQuantityKind::ResidualVariance));
931 let effect_idx = quantities.len();
932 quantities.push(PosteriorQuantityKind::Effect { name: Arc::from("ate") });
933 let n_q = quantities.len();
934 let mut values = vec![0.0; n_draws * n_q];
935 for (qi, q) in mechanism.coefficient_draws.schema.quantities.iter().enumerate() {
936 if matches!(q, PosteriorQuantityKind::ResidualVariance) {
937 continue;
938 }
939 let dest = quantities.iter().position(|qq| qq == q).ok_or_else(|| {
940 EstimationError::stats_msg(format!("posterior quantity missing from schema: {q:?}"))
941 })?;
942 let col = mechanism.coefficient_draws.column(qi).map_err(EstimationError::from)?;
943 values[dest * n_draws..(dest + 1) * n_draws].copy_from_slice(col);
944 }
945 values[effect_idx * n_draws..(effect_idx + 1) * n_draws]
946 .copy_from_slice(&effect_out.values[..n_draws]);
947
948 if let Some(names) = problem.coef_names.as_ref() {
949 apply_coefficient_names(&mut quantities, names);
950 }
951
952 let draws = PosteriorDraws::from_column_major(
953 PosteriorSchema { quantities: Arc::from(quantities) },
954 n_draws,
955 values,
956 )
957 .map_err(EstimationError::from)?;
958 let summaries = draws.summarize();
959
960 let _ = mechanism;
961 Ok(CausalPosterior {
962 draws,
963 summaries,
964 identification,
965 prior_sensitivity: None,
966 conflict_summary: None,
967 diagnostics: fit.diagnostics,
968 assumptions,
969 unidentified_mass: 0.0,
970 early_stopped: false,
971 })
972 }
973}
974
975#[derive(Clone, Debug, Default)]
981pub struct BayesianTemporalGcomp {
982 pub inner: BayesianGComputationAte,
984}
985
986impl BayesianTemporalGcomp {
987 #[must_use]
989 pub fn new() -> Self {
990 Self { inner: BayesianGComputationAte::new() }
991 }
992
993 #[must_use]
995 pub fn conjugate() -> Self {
996 Self { inner: BayesianGComputationAte::conjugate() }
997 }
998
999 #[must_use]
1001 pub fn with_inner(mut self, inner: BayesianGComputationAte) -> Self {
1002 self.inner = inner;
1003 self
1004 }
1005
1006 #[must_use]
1008 pub fn from_prepared_estimation(prep: &PreparedEstimationProblem) -> PreparedBayesianProblem {
1009 BayesianGComputationAte::from_prepared_estimation(prep)
1010 }
1011
1012 pub fn fit(
1018 &self,
1019 problem: &PreparedBayesianProblem,
1020 identification: IdentificationStatus,
1021 workspace: &mut BayesianGCompWorkspace,
1022 ctx: &ExecutionContext,
1023 ) -> Result<CausalPosterior, EstimationError> {
1024 self.inner.fit(problem, identification, workspace, ctx)
1025 }
1026}
1027
1028#[must_use]
1032pub fn coefficient_names_from_design(
1033 design: &CompiledDesign,
1034 treatment_name: &str,
1035 covariate_name: impl Fn(VariableId) -> Option<Arc<str>>,
1036) -> Arc<[Arc<str>]> {
1037 let names: Vec<Arc<str>> = design
1038 .columns
1039 .iter()
1040 .map(|col| match col.role {
1041 DesignColumnRole::Intercept => Arc::from("intercept"),
1042 DesignColumnRole::Treatment => Arc::from(format!("coef_{treatment_name}")),
1043 DesignColumnRole::Covariate(id) => covariate_name(id).map_or_else(
1044 || Arc::from(format!("coef_var_{}", id.raw())),
1045 |n| Arc::from(format!("coef_{n}")),
1046 ),
1047 })
1048 .collect();
1049 Arc::from(names)
1050}
1051
1052fn apply_coefficient_names(quantities: &mut [PosteriorQuantityKind], names: &[Arc<str>]) {
1054 for q in quantities {
1055 if let PosteriorQuantityKind::Coefficient { index, name } = q {
1056 if let Some(n) = names.get(*index) {
1057 *name = Some(Arc::clone(n));
1058 }
1059 }
1060 }
1061}
1062
1063#[derive(Clone, Debug)]
1065pub struct PreparedBayesianProblem {
1066 pub design: CompiledDesign,
1068 pub method: Arc<str>,
1070 pub adjustment_set: Arc<[VariableId]>,
1072 pub active: f64,
1074 pub control: f64,
1076 pub overlap: OverlapPolicy,
1078 pub coef_names: Option<Arc<[Arc<str>]>>,
1080}
1081
1082#[derive(Clone, Debug, Default)]
1084pub struct BayesianGCompWorkspace {
1085 pub laplace: LaplaceWorkspace,
1087 pub eval: PosteriorEvalWorkspace,
1089}
1090
1091pub trait PosteriorFunctionalEvaluator {
1093 type Compiled;
1095
1096 fn compile(&self) -> Result<Self::Compiled, EstimationError>;
1102
1103 fn evaluate_batch(
1109 &self,
1110 compiled: &Self::Compiled,
1111 posterior: PosteriorBatch<'_>,
1112 output: &mut EffectBatch,
1113 workspace: &mut PosteriorEvalWorkspace,
1114 ctx: &ExecutionContext,
1115 ) -> Result<(), EstimationError>;
1116}
1117
1118#[derive(Clone, Debug)]
1120pub struct GCompAteEvaluator {
1121 pub family: GlmFamily,
1123 pub treatment_col: usize,
1125 pub active: f64,
1127 pub control: f64,
1129 pub nrows: usize,
1131 pub ncols: usize,
1133 pub matrix: Arc<[f64]>,
1135}
1136
1137#[derive(Clone, Copy, Debug, Default)]
1139pub struct CompiledGCompAte;
1140
1141impl PosteriorFunctionalEvaluator for GCompAteEvaluator {
1142 type Compiled = CompiledGCompAte;
1143
1144 fn compile(&self) -> Result<Self::Compiled, EstimationError> {
1145 if self.treatment_col >= self.ncols {
1146 return Err(EstimationError::stats_msg("treatment column out of range"));
1147 }
1148 Ok(CompiledGCompAte)
1149 }
1150
1151 fn evaluate_batch(
1152 &self,
1153 _compiled: &Self::Compiled,
1154 posterior: PosteriorBatch<'_>,
1155 output: &mut EffectBatch,
1156 workspace: &mut PosteriorEvalWorkspace,
1157 _ctx: &ExecutionContext,
1158 ) -> Result<(), EstimationError> {
1159 let n_draws = posterior.len;
1160 workspace.prepare(n_draws, self.ncols);
1161 output.prepare(n_draws);
1162
1163 let mut coef_cols: Vec<&[f64]> = Vec::with_capacity(self.ncols);
1165 for c in 0..self.ncols {
1166 let col = posterior.column(c).map_err(EstimationError::from)?;
1167 coef_cols.push(col);
1168 }
1169
1170 for d in 0..n_draws {
1171 for c in 0..self.ncols {
1172 workspace.row[c] = coef_cols[c][d];
1173 }
1174 let beta = &workspace.row[..self.ncols];
1175 let mut sum = 0.0;
1176 for r in 0..self.nrows {
1177 let mu_a = predict_row(
1178 self.family,
1179 &self.matrix,
1180 self.nrows,
1181 self.ncols,
1182 self.treatment_col,
1183 beta,
1184 r,
1185 self.active,
1186 );
1187 let mu_c = predict_row(
1188 self.family,
1189 &self.matrix,
1190 self.nrows,
1191 self.ncols,
1192 self.treatment_col,
1193 beta,
1194 r,
1195 self.control,
1196 );
1197 sum += mu_a - mu_c;
1198 }
1199 output.values[d] = sum / self.nrows as f64;
1200 }
1201 Ok(())
1202 }
1203}
1204
1205fn predict_row(
1206 family: GlmFamily,
1207 matrix: &[f64],
1208 nrows: usize,
1209 ncols: usize,
1210 t_col: usize,
1211 beta: &[f64],
1212 row: usize,
1213 t_value: f64,
1214) -> f64 {
1215 let mut eta = 0.0;
1216 for c in 0..ncols {
1217 let x = if c == t_col { t_value } else { matrix[c * nrows + row] };
1218 eta += x * beta[c];
1219 }
1220 family.mean_from_eta(eta)
1221}
1222
1223fn likelihood_to_glm_family(l: BayesLikelihood) -> GlmFamily {
1224 match l {
1225 BayesLikelihood::GaussianIdentity => GlmFamily::GaussianIdentity,
1226 BayesLikelihood::BernoulliLogit => GlmFamily::BinomialLogit,
1227 BayesLikelihood::BernoulliProbit => GlmFamily::BinomialProbit,
1228 BayesLikelihood::PoissonLog => GlmFamily::PoissonLog,
1229 }
1230}
1231
1232fn prob_err(e: antecedent_prob::ProbError) -> EstimationError {
1233 EstimationError::from(e)
1234}
1235
1236fn quantile_width_95(values: &[f64]) -> f64 {
1238 if values.len() < 2 {
1239 return f64::NAN;
1240 }
1241 let schema = PosteriorSchema {
1243 quantities: Arc::from([PosteriorQuantityKind::Effect { name: Arc::from("w") }]),
1244 };
1245 let Ok(draws) = PosteriorDraws::from_column_major(schema, values.len(), values.to_vec()) else {
1246 return f64::NAN;
1247 };
1248 let s = draws.summarize();
1249 s.q975[0] - s.q025[0]
1250}
1251
1252fn concat_coefficient_draws(
1258 first: &PosteriorDraws,
1259 rest: &[PosteriorDraws],
1260) -> Result<PosteriorDraws, EstimationError> {
1261 if rest.is_empty() {
1262 return Ok(first.clone());
1263 }
1264 for block in rest {
1265 if block.schema != first.schema {
1266 return Err(EstimationError::stats_msg("concat_coefficient_draws: schema mismatch"));
1267 }
1268 }
1269 let n_q = first.schema.quantities.len();
1270 let n = first.n_draws + rest.iter().map(|b| b.n_draws).sum::<usize>();
1271 let mut values = vec![0.0; n * n_q];
1272 for q in 0..n_q {
1273 let mut offset = q * n;
1274 let col = first.column(q).map_err(EstimationError::from)?;
1275 values[offset..offset + first.n_draws].copy_from_slice(col);
1276 offset += first.n_draws;
1277 for block in rest {
1278 let col = block.column(q).map_err(EstimationError::from)?;
1279 values[offset..offset + block.n_draws].copy_from_slice(col);
1280 offset += block.n_draws;
1281 }
1282 }
1283 PosteriorDraws::from_column_major(first.schema.clone(), n, values)
1284 .map_err(EstimationError::from)
1285}
1286
1287fn coefficient_only_draws(draws: &PosteriorDraws) -> Result<PosteriorDraws, EstimationError> {
1289 let coef_idx: Vec<usize> = draws
1290 .schema
1291 .quantities
1292 .iter()
1293 .enumerate()
1294 .filter_map(|(i, q)| matches!(q, PosteriorQuantityKind::Coefficient { .. }).then_some(i))
1295 .collect();
1296 if coef_idx.is_empty() {
1297 return Err(EstimationError::stats_msg(
1298 "coefficient_only_draws: no coefficient quantities",
1299 ));
1300 }
1301 if coef_idx.len() == draws.schema.quantities.len() {
1302 return Ok(draws.clone());
1303 }
1304 let n = draws.n_draws;
1305 let n_q = coef_idx.len();
1306 let mut quantities = Vec::with_capacity(n_q);
1307 let mut values = vec![0.0; n * n_q];
1308 for (dest, &src) in coef_idx.iter().enumerate() {
1309 quantities.push(draws.schema.quantities[src].clone());
1310 let col = draws.column(src).map_err(EstimationError::from)?;
1311 values[dest * n..(dest + 1) * n].copy_from_slice(col);
1312 }
1313 PosteriorDraws::from_column_major(
1314 PosteriorSchema { quantities: Arc::from(quantities) },
1315 n,
1316 values,
1317 )
1318 .map_err(EstimationError::from)
1319}
1320
1321#[must_use]
1327pub fn nonidentified_with_prior(
1328 prior: &PriorSet,
1329 diagnostics: InferenceDiagnostics,
1330 n_draws: usize,
1331 seed: u64,
1332) -> CausalPosterior {
1333 let mut assumptions = AssumptionSet::new();
1334 for spec in &prior.specs {
1335 assumptions.push(AssumptionRecord {
1336 assumption: Assumption::PriorRestriction(spec.as_assumption()),
1337 source: AssumptionSource::UserDeclared,
1338 scope: AssumptionScope::Estimation,
1339 status: AssumptionStatus::Untestable,
1340 });
1341 }
1342 let schema = PosteriorSchema {
1343 quantities: Arc::from([PosteriorQuantityKind::Effect { name: Arc::from("ate") }]),
1344 };
1345 let (mean, scale) = prior_predictive_effect_params(prior);
1346 let n = n_draws.max(1);
1347 let mut values = vec![0.0; n];
1348 let mut rng = ExecutionContext::for_tests(seed).rng.stream(0xBA7E_u64);
1349 for v in &mut values {
1350 *v = mean + scale * antecedent_kernels::standard_normal(&mut rng);
1351 }
1352 let draws = PosteriorDraws::from_column_major(schema, n, Arc::<[f64]>::from(values))
1353 .unwrap_or_else(|_| PosteriorDraws {
1354 schema: PosteriorSchema {
1355 quantities: Arc::from([PosteriorQuantityKind::Effect { name: Arc::from("ate") }]),
1356 },
1357 n_draws: 0,
1358 values: Arc::from([]),
1359 });
1360 let summaries = draws.summarize();
1361 CausalPosterior {
1362 draws,
1363 summaries,
1364 identification: IdentificationStatus::NotIdentified,
1365 prior_sensitivity: None,
1366 conflict_summary: None,
1367 diagnostics,
1368 assumptions,
1369 unidentified_mass: 1.0,
1370 early_stopped: false,
1371 }
1372}
1373
1374fn prior_predictive_effect_params(prior: &PriorSet) -> (f64, f64) {
1375 if let Some(g) = prior.gaussian_coefficients() {
1376 let mean = g.mean.first().copied().unwrap_or(0.0);
1377 let var = g.variance.first().copied().unwrap_or(100.0).max(1e-12);
1378 return (mean, var.sqrt());
1379 }
1380 (0.0, 10.0)
1381}
1382
1383#[cfg(test)]
1384mod tests {
1385 use super::*;
1386 use antecedent_core::{
1387 CausalSchemaBuilder, MeasurementSpec, RoleHint, SmallRoleSet, ValueType, VariableId,
1388 };
1389 use antecedent_data::column::{Float64Column, ValidityBitmap};
1390 use antecedent_data::{OwnedColumn, OwnedColumnarStorage, TabularData};
1391 use antecedent_expr::{ExprId, IdentifiedEstimand};
1392 use antecedent_prob::InferenceDiagnostics;
1393
1394 fn linear_scm_table(n: usize) -> (TabularData, VariableId, VariableId, VariableId) {
1395 let mut b = CausalSchemaBuilder::new();
1396 b.add_variable(
1397 "Z",
1398 ValueType::Continuous,
1399 SmallRoleSet::from_hint(RoleHint::Context),
1400 None,
1401 None,
1402 MeasurementSpec::default(),
1403 )
1404 .unwrap();
1405 b.add_variable(
1406 "T",
1407 ValueType::Continuous,
1408 SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
1409 None,
1410 None,
1411 MeasurementSpec::default(),
1412 )
1413 .unwrap();
1414 b.add_variable(
1415 "Y",
1416 ValueType::Continuous,
1417 SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
1418 None,
1419 None,
1420 MeasurementSpec::default(),
1421 )
1422 .unwrap();
1423 let schema = b.build().unwrap();
1424 let z = VariableId::from_raw(0);
1425 let t = VariableId::from_raw(1);
1426 let y = VariableId::from_raw(2);
1427 let mut zv = vec![0.0; n];
1428 let mut tv = vec![0.0; n];
1429 let mut yv = vec![0.0; n];
1430 for i in 0..n {
1431 zv[i] = (i as f64) * 0.1;
1432 tv[i] = if i % 2 == 0 { 1.0 } else { 0.0 };
1433 yv[i] = 2.0 * tv[i] + 0.5 * zv[i];
1434 }
1435 let validity = ValidityBitmap::all_valid(n);
1436 let cols = vec![
1437 OwnedColumn::Float64(Float64Column::new(z, Arc::from(zv), validity.clone()).unwrap()),
1438 OwnedColumn::Float64(Float64Column::new(t, Arc::from(tv), validity.clone()).unwrap()),
1439 OwnedColumn::Float64(Float64Column::new(y, Arc::from(yv), validity).unwrap()),
1440 ];
1441 let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1442 (TabularData::new(storage), t, y, z)
1443 }
1444
1445 #[test]
1446 fn bayesian_and_frequentist_share_ate() {
1447 let n = 80;
1448 let (data, t, y, z) = linear_scm_table(n);
1449 let estimand = IdentifiedEstimand::backdoor(
1450 "backdoor.adjustment",
1451 Arc::from(vec![z]),
1452 ExprId::from_raw(0),
1453 );
1454 let query = AverageEffectQuery::binary_ate(t, y);
1455
1456 let freq = crate::adjustment::LinearAdjustmentAte {
1457 bootstrap_replicates: 0,
1458 ..crate::adjustment::LinearAdjustmentAte::new()
1459 };
1460 let prep = freq.prepare(&data, &estimand, &query).unwrap();
1461 let mut ws = crate::adjustment::EstimationWorkspace::default();
1462 let freq_est = freq
1463 .fit(&prep, &mut ws, &ExecutionContext::for_tests(1), AssumptionSet::new())
1464 .unwrap();
1465
1466 let bayes = BayesianGComputationAte {
1467 backend: BayesianBackendKind::ConjugateGaussian,
1468 n_draws: 400,
1469 seed: 5,
1470 prior_scale: 100.0,
1471 ..BayesianGComputationAte::new()
1472 };
1473 let bprep = bayes.prepare(&data, &estimand, &query).unwrap();
1474 let mut bws = BayesianGCompWorkspace::default();
1475 let post = bayes
1476 .fit(
1477 &bprep,
1478 IdentificationStatus::NonparametricallyIdentified,
1479 &mut bws,
1480 &ExecutionContext::for_tests(1),
1481 )
1482 .unwrap();
1483 let eq = post.effect_column().unwrap();
1484 let mean = post.summaries.mean[eq];
1485 assert!((freq_est.ate - 2.0).abs() < 1e-6, "frequentist ate={}", freq_est.ate);
1486 assert!((mean - freq_est.ate).abs() < 0.05, "bayes={mean} freq={}", freq_est.ate);
1487 assert_eq!(post.identification, IdentificationStatus::NonparametricallyIdentified);
1488 let coef_names: Vec<_> = post
1489 .draws
1490 .schema
1491 .quantities
1492 .iter()
1493 .filter_map(|q| match q {
1494 PosteriorQuantityKind::Coefficient { name, .. } => name.as_ref().map(AsRef::as_ref),
1495 _ => None,
1496 })
1497 .collect();
1498 assert!(coef_names.contains(&"intercept"), "{coef_names:?}");
1499 assert!(coef_names.iter().any(|n| n.starts_with("coef_")), "{coef_names:?}");
1500 }
1501
1502 #[test]
1503 fn adaptive_laplace_unknown_variance_keeps_exact_nig_draws() {
1504 let (data, t, y, z) = linear_scm_table(80);
1505 let estimand = IdentifiedEstimand::backdoor(
1506 "backdoor.adjustment",
1507 Arc::from(vec![z]),
1508 ExprId::from_raw(0),
1509 );
1510 let query = AverageEffectQuery::binary_ate(t, y);
1511 let estimator =
1512 BayesianGComputationAte { n_draws: 96, seed: 17, ..BayesianGComputationAte::new() };
1513 let prepared = estimator.prepare(&data, &estimand, &query).unwrap();
1514 let mut workspace = BayesianGCompWorkspace::default();
1515 let posterior = estimator
1516 .fit(
1517 &prepared,
1518 IdentificationStatus::NonparametricallyIdentified,
1519 &mut workspace,
1520 &ExecutionContext::production(17, 1),
1521 )
1522 .unwrap();
1523
1524 assert_eq!(posterior.diagnostics.backend_id.as_ref(), "conjugate_gaussian");
1525 assert_eq!(posterior.draws.n_draws, 96, "NIG draws must not be replaced by MVN redraws");
1526 assert!(!posterior.early_stopped, "exact NIG sampling materializes the requested draws");
1527 }
1528
1529 #[test]
1530 fn prior_does_not_create_identification() {
1531 let prior = PriorSet::weakly_informative(3);
1532 let post = nonidentified_with_prior(&prior, InferenceDiagnostics::analytic("none"), 64, 1);
1533 assert_eq!(post.identification, IdentificationStatus::NotIdentified);
1534 assert!(!post.assumptions.is_empty());
1535 assert!((post.unidentified_mass - 1.0).abs() < 1e-12);
1536 assert!(post.draws.n_draws > 0, "prior-predictive draws required");
1537 }
1538
1539 #[test]
1540 fn temporal_prepared_design_conjugate_recovers_pulse() {
1541 use antecedent_core::{
1542 CausalSchemaBuilder, Lag, MeasurementSpec, RoleHint, SmallRoleSet, TemporalEffectQuery,
1543 TemporalPolicy, ValueType,
1544 };
1545 use antecedent_data::{
1546 Float64Column, OwnedColumn, OwnedColumnarStorage, SamplingRegularity, TimeIndex,
1547 TimeSeriesData, ValidityBitmap,
1548 };
1549 use antecedent_graph::{TemporalDag, ensure_lagged};
1550 use antecedent_identify::TemporalBackdoorIdentifier;
1551
1552 use crate::temporal_adjustment::TemporalLinearAdjustment;
1553
1554 let n = 300usize;
1555 let mut b = CausalSchemaBuilder::new();
1556 b.add_variable(
1557 "x",
1558 ValueType::Continuous,
1559 SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
1560 None,
1561 None,
1562 MeasurementSpec::default(),
1563 )
1564 .unwrap();
1565 b.add_variable(
1566 "y",
1567 ValueType::Continuous,
1568 SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
1569 None,
1570 None,
1571 MeasurementSpec::default(),
1572 )
1573 .unwrap();
1574 let schema = b.build().unwrap();
1575 let mut x = vec![0.0; n];
1576 let mut y = vec![0.0; n];
1577 for t in 1..n {
1578 x[t] = ((t as f64) * 0.07).sin();
1579 y[t] = 0.8 * x[t - 1];
1580 }
1581 let cols = vec![
1582 OwnedColumn::Float64(
1583 Float64Column::new(
1584 VariableId::from_raw(0),
1585 Arc::from(x),
1586 ValidityBitmap::all_valid(n),
1587 )
1588 .unwrap(),
1589 ),
1590 OwnedColumn::Float64(
1591 Float64Column::new(
1592 VariableId::from_raw(1),
1593 Arc::from(y),
1594 ValidityBitmap::all_valid(n),
1595 )
1596 .unwrap(),
1597 ),
1598 ];
1599 let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1600 let data = TimeSeriesData::try_new(
1601 storage,
1602 TimeIndex { regularity: SamplingRegularity::Regular { interval_ns: 1 }, length: n },
1603 )
1604 .unwrap();
1605 let mut g = TemporalDag::empty();
1606 let x1 = ensure_lagged(&mut g, VariableId::from_raw(0), Lag::from_raw(1)).unwrap();
1607 let y0 = ensure_lagged(&mut g, VariableId::from_raw(1), Lag::CONTEMPORANEOUS).unwrap();
1608 g.insert_directed(x1, y0).unwrap();
1609
1610 let q = TemporalEffectQuery::pulse(VariableId::from_raw(0), VariableId::from_raw(1), 1.0)
1611 .with_policy(TemporalPolicy::pulse(-1))
1612 .with_horizon_steps(1)
1613 .with_max_history_lag(Some(1));
1614 let id_res = TemporalBackdoorIdentifier::new().identify_temporal(&g, &q).unwrap();
1615 let estimand = id_res.result.estimands.first().unwrap();
1616 let temporal = TemporalLinearAdjustment::new();
1617 let prep = temporal
1618 .prepare(
1619 &data,
1620 estimand,
1621 &q,
1622 &id_res.indexer,
1623 None,
1624 &ExecutionContext::for_tests(1).kernel_policy,
1625 )
1626 .unwrap();
1627 let bayes = BayesianTemporalGcomp {
1628 inner: BayesianGComputationAte {
1629 backend: BayesianBackendKind::ConjugateGaussian,
1630 n_draws: 200,
1631 seed: 7,
1632 prior_scale: 100.0,
1633 ..BayesianGComputationAte::new()
1634 },
1635 };
1636 let bprep = BayesianTemporalGcomp::from_prepared_estimation(&prep);
1637 let mut ws = BayesianGCompWorkspace::default();
1638 let post = bayes
1639 .fit(
1640 &bprep,
1641 IdentificationStatus::NonparametricallyIdentified,
1642 &mut ws,
1643 &ExecutionContext::for_tests(1),
1644 )
1645 .unwrap();
1646 let eq = post.effect_column().unwrap();
1647 let mean = post.summaries.mean[eq];
1648 assert!((mean - 0.8).abs() < 0.05, "bayesian temporal pulse mean={mean}");
1649 assert!(post.probability_below(0.0).unwrap().is_finite());
1650 }
1651
1652 #[test]
1653 fn hydrate_prior_from_posterior_and_refit() {
1654 let n = 60;
1655 let (data, t, y, z) = linear_scm_table(n);
1656 let estimand = IdentifiedEstimand::backdoor(
1657 "backdoor.adjustment",
1658 Arc::from(vec![z]),
1659 ExprId::from_raw(0),
1660 );
1661 let query = AverageEffectQuery::binary_ate(t, y);
1662 let bayes = BayesianGComputationAte {
1663 backend: BayesianBackendKind::ConjugateGaussian,
1664 n_draws: 200,
1665 seed: 3,
1666 prior_scale: 10.0,
1667 ..BayesianGComputationAte::new()
1668 };
1669 let prep = bayes.prepare(&data, &estimand, &query).unwrap();
1670 let mut ws = BayesianGCompWorkspace::default();
1671 let post = bayes
1672 .fit(
1673 &prep,
1674 IdentificationStatus::NonparametricallyIdentified,
1675 &mut ws,
1676 &ExecutionContext::for_tests(1),
1677 )
1678 .unwrap();
1679 let prior = hydrate_prior_from_posterior(&post, Some(prep.design.ncols)).unwrap();
1680 assert_eq!(prior.gaussian_coefficients().unwrap().len(), prep.design.ncols);
1681 assert!(hydrate_prior_from_posterior(&post, Some(prep.design.ncols + 1)).is_err());
1682
1683 let sequential = BayesianGComputationAte { prior: Some(prior), ..bayes };
1684 let post2 = sequential
1685 .fit(
1686 &prep,
1687 IdentificationStatus::NonparametricallyIdentified,
1688 &mut ws,
1689 &ExecutionContext::for_tests(1),
1690 )
1691 .unwrap();
1692 assert!(post2.assumptions.entries.iter().any(|a| {
1693 matches!(a.source, AssumptionSource::Artifact)
1694 && matches!(&a.assumption, Assumption::PriorRestriction(pa) if pa.description.contains("sequential"))
1695 }));
1696 let eq = post2.effect_column().unwrap();
1697 assert!(post2.summaries.mean[eq].is_finite());
1698 }
1699
1700 #[test]
1701 fn hydrate_effect_functional_maps_treatment_coef() {
1702 let quantities = vec![
1703 PosteriorQuantityKind::Coefficient { index: 0, name: Some(Arc::from("intercept")) },
1704 PosteriorQuantityKind::Coefficient { index: 1, name: Some(Arc::from("coef_t")) },
1705 PosteriorQuantityKind::Effect { name: Arc::from("ate") },
1706 ];
1707 let mean = vec![0.1, 0.5, 2.0];
1708 let sd = vec![1.0, 1.0, 0.4];
1709 let names: Vec<Arc<str>> =
1710 vec![Arc::from("intercept"), Arc::from("coef_t"), Arc::from("coef_z")];
1711 let baseline = PriorSet::weakly_informative(3);
1712 let prior = hydrate_prior(
1713 &HydrateMapping::EffectFunctional { source_quantity: "ate".into() },
1714 &quantities,
1715 &mean,
1716 &sd,
1717 &baseline,
1718 &names,
1719 Some(1),
1720 )
1721 .unwrap();
1722 let coef = prior.gaussian_coefficients().unwrap();
1723 assert!((coef.mean[1] - 2.0).abs() < 1e-12);
1724 assert!((coef.variance[1] - 0.16).abs() < 1e-12);
1725 assert!((coef.mean[0] - 0.0).abs() < 1e-12);
1727 assert!((coef.variance[0] - 100.0).abs() < 1e-12);
1728 assert!((coef.variance[2] - 100.0).abs() < 1e-12);
1729 assert!(prior.restrictions.iter().any(|r| r.id.as_ref() == "external_effect_prior"));
1730 }
1731
1732 #[test]
1733 fn hydrate_mapping_hard_errors() {
1734 let quantities = vec![
1735 PosteriorQuantityKind::Coefficient { index: 0, name: Some(Arc::from("intercept")) },
1736 PosteriorQuantityKind::Coefficient { index: 1, name: Some(Arc::from("coef_t")) },
1737 PosteriorQuantityKind::Effect { name: Arc::from("ate") },
1738 ];
1739 let mean = vec![0.0, 1.0, 2.0];
1740 let sd = vec![1.0, 1.0, 0.5];
1741 let names2: Vec<Arc<str>> = vec![Arc::from("intercept"), Arc::from("coef_t")];
1742 let baseline2 = PriorSet::weakly_informative(2);
1743 let names3: Vec<Arc<str>> =
1745 vec![Arc::from("intercept"), Arc::from("coef_t"), Arc::from("coef_w")];
1746 let baseline3 = PriorSet::weakly_informative(3);
1747 assert!(
1748 hydrate_prior(
1749 &HydrateMapping::IdenticalCoefficientSubspace,
1750 &quantities,
1751 &mean,
1752 &sd,
1753 &baseline3,
1754 &names3,
1755 None,
1756 )
1757 .is_err()
1758 );
1759
1760 assert!(
1761 hydrate_prior(
1762 &HydrateMapping::EffectFunctional { source_quantity: "missing".into() },
1763 &quantities,
1764 &mean,
1765 &sd,
1766 &baseline2,
1767 &names2,
1768 Some(1),
1769 )
1770 .is_err()
1771 );
1772
1773 assert!(
1774 hydrate_prior(
1775 &HydrateMapping::NamedParameters {
1776 pairs: vec![("ate".into(), "no_such_coef".into())],
1777 },
1778 &quantities,
1779 &mean,
1780 &sd,
1781 &baseline2,
1782 &names2,
1783 None,
1784 )
1785 .is_err()
1786 );
1787
1788 assert!(
1789 hydrate_prior(
1790 &HydrateMapping::NamedParameters {
1791 pairs: vec![("no_src".into(), "coef_t".into())],
1792 },
1793 &quantities,
1794 &mean,
1795 &sd,
1796 &baseline2,
1797 &names2,
1798 None,
1799 )
1800 .is_err()
1801 );
1802 }
1803
1804 #[test]
1805 fn hydrate_named_parameters_overwrites_target() {
1806 let quantities = vec![
1807 PosteriorQuantityKind::Coefficient { index: 0, name: Some(Arc::from("intercept")) },
1808 PosteriorQuantityKind::Coefficient { index: 1, name: Some(Arc::from("coef_t")) },
1809 PosteriorQuantityKind::Effect { name: Arc::from("ate") },
1810 ];
1811 let mean = vec![0.0, 0.0, 1.5];
1812 let sd = vec![1.0, 1.0, 0.2];
1813 let names: Vec<Arc<str>> = vec![Arc::from("intercept"), Arc::from("coef_t")];
1814 let baseline = PriorSet::weakly_informative(2);
1815 let prior = hydrate_prior(
1816 &HydrateMapping::NamedParameters { pairs: vec![("ate".into(), "coef_t".into())] },
1817 &quantities,
1818 &mean,
1819 &sd,
1820 &baseline,
1821 &names,
1822 None,
1823 )
1824 .unwrap();
1825 let coef = prior.gaussian_coefficients().unwrap();
1826 assert!((coef.mean[1] - 1.5).abs() < 1e-12);
1827 assert!(prior.restrictions.iter().any(|r| r.id.as_ref() == "external_named_prior"));
1828 }
1829}