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, HmcGlmBackend, HmcOptions,
29 InferenceBackend, InferenceDiagnostics, LaplaceGlmBackend, LaplaceWorkspace, PosteriorBatch,
30 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 pub fn prepare(
462 &self,
463 data: &TabularData,
464 estimand: &IdentifiedEstimand,
465 query: &AverageEffectQuery,
466 ) -> Result<PreparedBayesianProblem, EstimationError> {
467 require_explicit_override(
468 self.overlap,
469 "BayesianGComputationAte requires ExplicitOverride overlap policy",
470 )?;
471 if !matches!(
472 estimand.method_kind().ok(),
473 Some(
474 antecedent_expr::EstimandMethod::BackdoorAdjustment
475 | antecedent_expr::EstimandMethod::BackdoorEfficient
476 )
477 ) {
478 return Err(EstimationError::IncompatibleEstimand {
479 message: "BayesianGComputationAte expects backdoor.adjustment/efficient",
480 });
481 }
482 query.validate()?;
483 if !query.effect_modifiers.is_empty() {
484 return Err(EstimationError::unsupported(
485 "Bayesian g-comp does not support effect modifiers",
486 ));
487 }
488 if query.target_population != TargetPopulation::AllObserved {
489 return Err(EstimationError::unsupported(
490 "Bayesian g-comp only supports TargetPopulation::AllObserved",
491 ));
492 }
493 let active = intervention_f64(&query.active)?;
494 let control = intervention_f64(&query.control)?;
495 if (active - control).abs() < f64::EPSILON {
496 return Err(EstimationError::unsupported(
497 "active and control treatment levels must differ",
498 ));
499 }
500
501 let treatment = query.treatment;
502 let outcome = query.outcome;
503 let mut ids = Vec::with_capacity(2 + estimand.adjustment_set.len());
504 ids.push(treatment);
505 ids.push(outcome);
506 ids.extend_from_slice(&estimand.adjustment_set);
507 let row_mask = data.complete_case_mask(&ids).map_err(EstimationError::from)?;
508 let t = data.float64_masked(treatment, &row_mask).map_err(EstimationError::from)?;
509 let y = data.float64_masked(outcome, &row_mask).map_err(EstimationError::from)?;
510 let mut covs: Vec<(VariableId, Vec<f64>)> = Vec::new();
511 for &z in estimand.adjustment_set.iter() {
512 covs.push((z, data.float64_masked(z, &row_mask).map_err(EstimationError::from)?));
513 }
514 let cov_refs: Vec<(VariableId, &[f64])> =
515 covs.iter().map(|(id, v)| (*id, v.as_slice())).collect();
516 let selected_rows: Vec<usize> =
517 row_mask.iter().enumerate().filter_map(|(i, keep)| keep.then_some(i)).collect();
518 let design = CompiledDesign::linear_adjustment(&t, &cov_refs, &y, &selected_rows)
519 .map_err(EstimationError::from)?;
520 let schema = data.schema();
521 let treatment_name = schema.get(treatment).map(|v| v.name.as_ref()).unwrap_or("treatment");
522 let coef_names = coefficient_names_from_design(&design, treatment_name, |id| {
523 schema.get(id).ok().map(|v| Arc::clone(&v.name))
524 });
525 Ok(PreparedBayesianProblem {
526 design,
527 method: Arc::clone(&estimand.method),
528 adjustment_set: Arc::clone(&estimand.adjustment_set),
529 active,
530 control,
531 overlap: self.overlap,
532 coef_names: Some(coef_names),
533 })
534 }
535
536 #[must_use]
541 pub fn from_prepared_estimation(prep: &PreparedEstimationProblem) -> PreparedBayesianProblem {
542 PreparedBayesianProblem {
543 design: prep.design.clone(),
544 method: Arc::clone(&prep.method),
545 adjustment_set: Arc::clone(&prep.adjustment_set),
546 active: prep.active,
547 control: prep.control,
548 overlap: prep.overlap,
549 coef_names: None,
550 }
551 }
552
553 pub fn fit(
561 &self,
562 problem: &PreparedBayesianProblem,
563 identification: IdentificationStatus,
564 workspace: &mut BayesianGCompWorkspace,
565 ctx: &ExecutionContext,
566 ) -> Result<CausalPosterior, EstimationError> {
567 let sequential = self.prior.is_some();
568 let prior = if let Some(p) = &self.prior {
569 if let Some(coef) = p.gaussian_coefficients() {
570 if coef.len() != problem.design.ncols {
571 return Err(EstimationError::stats_msg(format!(
572 "sequential prior coefficient dimension {} != design ncols {}",
573 coef.len(),
574 problem.design.ncols
575 )));
576 }
577 } else {
578 return Err(EstimationError::stats_msg(
579 "sequential prior missing GaussianCoefficients entry",
580 ));
581 }
582 p.clone()
583 } else {
584 PriorSet {
585 specs: vec![PriorSpec::GaussianCoefficients(
586 antecedent_prob::GaussianCoefficientPrior::isotropic(
587 problem.design.ncols,
588 self.prior_scale,
589 ),
590 )],
591 contrast: None,
592 categorical: Vec::new(),
593 restrictions: Vec::new(),
594 }
595 };
596 let mut assumptions = AssumptionSet::new();
597 let source = if sequential {
598 AssumptionSource::Artifact
599 } else {
600 AssumptionSource::AlgorithmDefault { algorithm: Arc::from("bayesian_gcomp") }
601 };
602 for spec in &prior.specs {
603 let mut pa = spec.as_assumption();
604 if sequential {
605 pa.description = Arc::from(format!(
606 "{} (sequential prior from posterior artifact)",
607 pa.description
608 ));
609 }
610 assumptions.push(AssumptionRecord {
611 assumption: Assumption::PriorRestriction(pa),
612 source: source.clone(),
613 scope: AssumptionScope::Estimation,
614 status: AssumptionStatus::Untestable,
615 });
616 }
617 for pa in &prior.restrictions {
618 assumptions.push(AssumptionRecord {
619 assumption: Assumption::PriorRestriction(pa.clone()),
620 source: AssumptionSource::Artifact,
621 scope: AssumptionScope::Estimation,
622 status: AssumptionStatus::Untestable,
623 });
624 }
625
626 let likelihood = match self.backend {
627 BayesianBackendKind::ConjugateGaussian => BayesLikelihood::GaussianIdentity,
628 BayesianBackendKind::Laplace | BayesianBackendKind::Hmc => self.likelihood,
629 };
630 let max_draws = match self.backend {
634 BayesianBackendKind::Hmc => self.n_draws.max(HMC_MIN_DRAWS),
635 _ => self.n_draws.max(1),
636 };
637 let adaptive = ctx.adaptive_draws;
638 let laplace_adaptive = adaptive.enabled
639 && matches!(self.backend, BayesianBackendKind::Laplace)
640 && max_draws > adaptive.min_draws.max(2);
641 let initial_draws =
642 if laplace_adaptive { adaptive.min_draws.max(2).min(max_draws) } else { max_draws };
643 let opts = BayesFitOptions {
644 n_draws: initial_draws,
645 seed: self.seed,
646 ..BayesFitOptions::default()
647 };
648 let design_ref = BayesDesignRef {
649 x_colmajor: &problem.design.matrix,
650 nrows: problem.design.nrows,
651 ncols: problem.design.ncols,
652 y: &problem.design.outcome,
653 weights: None,
654 offsets: None,
655 };
656
657 let mut fit = match self.backend {
658 BayesianBackendKind::ConjugateGaussian => ConjugateGaussianBackend.fit(
659 likelihood,
660 design_ref,
661 &prior,
662 &opts,
663 &mut workspace.laplace,
664 ctx,
665 ),
666 BayesianBackendKind::Laplace => LaplaceGlmBackend.fit(
667 likelihood,
668 design_ref,
669 &prior,
670 &opts,
671 &mut workspace.laplace,
672 ctx,
673 ),
674 BayesianBackendKind::Hmc => {
675 let n_warmup = max_draws.max(1_500);
678 HmcGlmBackend::new()
679 .with_options(HmcOptions {
680 n_chains: 4,
681 n_warmup,
682 leapfrog_steps: 16,
683 step_size: 0.04,
684 target_accept: 0.85,
685 ..HmcOptions::default()
686 })
687 .fit(likelihood, design_ref, &prior, &opts, &mut workspace.laplace, ctx)
688 }
689 }
690 .map_err(prob_err)?;
691
692 if !fit.diagnostics.allows_posterior() {
693 return Err(EstimationError::stats_msg("Bayesian fit refused without diagnostics"));
694 }
695
696 let t_col = problem
697 .design
698 .treatment_column()
699 .ok_or_else(|| EstimationError::stats_msg("missing treatment column"))?;
700
701 let glm_family = likelihood_to_glm_family(likelihood);
702 let evaluator = GCompAteEvaluator {
703 family: glm_family,
704 treatment_col: t_col,
705 active: problem.active,
706 control: problem.control,
707 nrows: problem.design.nrows,
708 ncols: problem.design.ncols,
709 matrix: Arc::clone(&problem.design.matrix),
710 };
711 let compiled = evaluator.compile()?;
712
713 let mut early_stopped = false;
714 let mut n_draws = fit.draws.n_draws;
715 let mut coef_draws = coefficient_only_draws(&fit.draws)?;
718
719 if laplace_adaptive {
720 let cov = fit.cov.as_ref().ok_or_else(|| {
721 EstimationError::stats_msg("Laplace adaptive draws require posterior covariance")
722 })?;
723 let map = fit.map.clone();
724 let batch = 32usize;
725 let mut effect_acc: Vec<f64> = Vec::with_capacity(max_draws);
726 let mut width_prev: Option<f64> = None;
727
728 {
730 workspace.eval.prepare(n_draws, problem.design.ncols);
731 let mut effect_out = EffectBatch::default();
732 effect_out.prepare(n_draws);
733 let batch_view = coef_draws.batch(0, n_draws).map_err(EstimationError::from)?;
734 evaluator.evaluate_batch(
735 &compiled,
736 batch_view,
737 &mut effect_out,
738 &mut workspace.eval,
739 ctx,
740 )?;
741 effect_acc.extend_from_slice(&effect_out.values[..n_draws]);
742 }
743
744 loop {
745 let width = quantile_width_95(&effect_acc);
746 let ess = effect_acc.len() as f64; if effect_acc.len() >= adaptive.min_draws.max(2) {
748 let width_ok = width_prev.is_some_and(|prev| {
749 let rel = (width - prev).abs() / prev.abs().max(1e-12);
750 rel < adaptive.quantile_width_rel_epsilon
751 });
752 if width_ok || ess >= adaptive.ess_target {
753 early_stopped = n_draws < max_draws;
754 break;
755 }
756 }
757 width_prev = Some(width);
758 if n_draws >= max_draws {
759 break;
760 }
761 let next = (n_draws + batch).min(max_draws);
762 let add = next - n_draws;
763 let extra = sample_gaussian_mvn(
764 &map,
765 cov,
766 add,
767 self.seed.wrapping_add(n_draws as u64),
768 &mut workspace.laplace,
769 )
770 .map_err(EstimationError::from)?;
771 let extra_draws = PosteriorDraws::from_column_major(
772 PosteriorSchema::coefficients(problem.design.ncols),
773 add,
774 extra,
775 )
776 .map_err(EstimationError::from)?;
777 workspace.eval.prepare(add, problem.design.ncols);
778 let mut effect_out = EffectBatch::default();
779 effect_out.prepare(add);
780 let batch_view = extra_draws.batch(0, add).map_err(EstimationError::from)?;
781 evaluator.evaluate_batch(
782 &compiled,
783 batch_view,
784 &mut effect_out,
785 &mut workspace.eval,
786 ctx,
787 )?;
788 effect_acc.extend_from_slice(&effect_out.values[..add]);
789 coef_draws = merge_coefficient_draws(&coef_draws, &extra_draws)?;
790 n_draws = next;
791 fit.draws = coef_draws.clone();
792 }
793
794 let mechanism_draws = coef_draws;
796 let mut quantities = mechanism_draws.schema.quantities.to_vec();
797 quantities.retain(|q| !matches!(q, PosteriorQuantityKind::ResidualVariance));
798 let effect_idx = quantities.len();
799 quantities.push(PosteriorQuantityKind::Effect { name: Arc::from("ate") });
800 let n_q = quantities.len();
801 let mut values = vec![0.0; n_draws * n_q];
802 for (qi, q) in mechanism_draws.schema.quantities.iter().enumerate() {
803 if matches!(q, PosteriorQuantityKind::ResidualVariance) {
804 continue;
805 }
806 let dest = quantities.iter().position(|qq| qq == q).ok_or_else(|| {
807 EstimationError::stats_msg(format!(
808 "posterior quantity missing from schema: {q:?}"
809 ))
810 })?;
811 let coef_col = mechanism_draws.column(qi).map_err(EstimationError::from)?;
812 values[dest * n_draws..(dest + 1) * n_draws].copy_from_slice(coef_col);
813 }
814 values[effect_idx * n_draws..(effect_idx + 1) * n_draws]
815 .copy_from_slice(&effect_acc[..n_draws]);
816 if let Some(names) = problem.coef_names.as_ref() {
817 apply_coefficient_names(&mut quantities, names);
818 }
819 let draws = PosteriorDraws::from_column_major(
820 PosteriorSchema { quantities: Arc::from(quantities) },
821 n_draws,
822 values,
823 )
824 .map_err(EstimationError::from)?;
825 let summaries = draws.summarize();
826 return Ok(CausalPosterior {
827 draws,
828 summaries,
829 identification,
830 prior_sensitivity: None,
831 conflict_summary: None,
832 diagnostics: fit.diagnostics,
833 assumptions,
834 unidentified_mass: 0.0,
835 early_stopped,
836 });
837 }
838
839 let mechanism = BayesianGlmMechanism {
840 coefficient_draws: coef_draws,
841 map: fit.map,
842 likelihood,
843 diagnostics: fit.diagnostics.clone(),
844 design: problem.design.clone(),
845 treatment_col: t_col,
846 active: problem.active,
847 control: problem.control,
848 };
849
850 workspace.eval.prepare(n_draws, problem.design.ncols);
851 let mut effect_out = EffectBatch::default();
852 effect_out.prepare(n_draws);
853 let batch = mechanism.coefficient_draws.batch(0, n_draws).map_err(EstimationError::from)?;
854 evaluator.evaluate_batch(&compiled, batch, &mut effect_out, &mut workspace.eval, ctx)?;
855
856 let mut quantities = mechanism.coefficient_draws.schema.quantities.to_vec();
857 quantities.retain(|q| !matches!(q, PosteriorQuantityKind::ResidualVariance));
859 let effect_idx = quantities.len();
860 quantities.push(PosteriorQuantityKind::Effect { name: Arc::from("ate") });
861 let n_q = quantities.len();
862 let mut values = vec![0.0; n_draws * n_q];
863 for (qi, q) in mechanism.coefficient_draws.schema.quantities.iter().enumerate() {
864 if matches!(q, PosteriorQuantityKind::ResidualVariance) {
865 continue;
866 }
867 let dest = quantities.iter().position(|qq| qq == q).ok_or_else(|| {
868 EstimationError::stats_msg(format!("posterior quantity missing from schema: {q:?}"))
869 })?;
870 let col = mechanism.coefficient_draws.column(qi).map_err(EstimationError::from)?;
871 values[dest * n_draws..(dest + 1) * n_draws].copy_from_slice(col);
872 }
873 values[effect_idx * n_draws..(effect_idx + 1) * n_draws]
874 .copy_from_slice(&effect_out.values[..n_draws]);
875
876 if let Some(names) = problem.coef_names.as_ref() {
877 apply_coefficient_names(&mut quantities, names);
878 }
879
880 let draws = PosteriorDraws::from_column_major(
881 PosteriorSchema { quantities: Arc::from(quantities) },
882 n_draws,
883 values,
884 )
885 .map_err(EstimationError::from)?;
886 let summaries = draws.summarize();
887
888 let _ = mechanism;
889 Ok(CausalPosterior {
890 draws,
891 summaries,
892 identification,
893 prior_sensitivity: None,
894 conflict_summary: None,
895 diagnostics: fit.diagnostics,
896 assumptions,
897 unidentified_mass: 0.0,
898 early_stopped: false,
899 })
900 }
901}
902
903#[derive(Clone, Debug, Default)]
909pub struct BayesianTemporalGcomp {
910 pub inner: BayesianGComputationAte,
912}
913
914impl BayesianTemporalGcomp {
915 #[must_use]
917 pub fn new() -> Self {
918 Self { inner: BayesianGComputationAte::new() }
919 }
920
921 #[must_use]
923 pub fn conjugate() -> Self {
924 Self { inner: BayesianGComputationAte::conjugate() }
925 }
926
927 #[must_use]
929 pub fn from_prepared_estimation(prep: &PreparedEstimationProblem) -> PreparedBayesianProblem {
930 BayesianGComputationAte::from_prepared_estimation(prep)
931 }
932
933 pub fn fit(
939 &self,
940 problem: &PreparedBayesianProblem,
941 identification: IdentificationStatus,
942 workspace: &mut BayesianGCompWorkspace,
943 ctx: &ExecutionContext,
944 ) -> Result<CausalPosterior, EstimationError> {
945 self.inner.fit(problem, identification, workspace, ctx)
946 }
947}
948
949#[must_use]
953pub fn coefficient_names_from_design(
954 design: &CompiledDesign,
955 treatment_name: &str,
956 covariate_name: impl Fn(VariableId) -> Option<Arc<str>>,
957) -> Arc<[Arc<str>]> {
958 let names: Vec<Arc<str>> = design
959 .columns
960 .iter()
961 .map(|col| match col.role {
962 DesignColumnRole::Intercept => Arc::from("intercept"),
963 DesignColumnRole::Treatment => Arc::from(format!("coef_{treatment_name}")),
964 DesignColumnRole::Covariate(id) => covariate_name(id).map_or_else(
965 || Arc::from(format!("coef_var_{}", id.raw())),
966 |n| Arc::from(format!("coef_{n}")),
967 ),
968 })
969 .collect();
970 Arc::from(names)
971}
972
973fn apply_coefficient_names(quantities: &mut [PosteriorQuantityKind], names: &[Arc<str>]) {
975 for q in quantities {
976 if let PosteriorQuantityKind::Coefficient { index, name } = q {
977 if let Some(n) = names.get(*index) {
978 *name = Some(Arc::clone(n));
979 }
980 }
981 }
982}
983
984#[derive(Clone, Debug)]
986pub struct PreparedBayesianProblem {
987 pub design: CompiledDesign,
989 pub method: Arc<str>,
991 pub adjustment_set: Arc<[VariableId]>,
993 pub active: f64,
995 pub control: f64,
997 pub overlap: OverlapPolicy,
999 pub coef_names: Option<Arc<[Arc<str>]>>,
1001}
1002
1003#[derive(Clone, Debug, Default)]
1005pub struct BayesianGCompWorkspace {
1006 pub laplace: LaplaceWorkspace,
1008 pub eval: PosteriorEvalWorkspace,
1010}
1011
1012pub trait PosteriorFunctionalEvaluator {
1014 type Compiled;
1016
1017 fn compile(&self) -> Result<Self::Compiled, EstimationError>;
1023
1024 fn evaluate_batch(
1030 &self,
1031 compiled: &Self::Compiled,
1032 posterior: PosteriorBatch<'_>,
1033 output: &mut EffectBatch,
1034 workspace: &mut PosteriorEvalWorkspace,
1035 ctx: &ExecutionContext,
1036 ) -> Result<(), EstimationError>;
1037}
1038
1039#[derive(Clone, Debug)]
1041pub struct GCompAteEvaluator {
1042 pub family: GlmFamily,
1044 pub treatment_col: usize,
1046 pub active: f64,
1048 pub control: f64,
1050 pub nrows: usize,
1052 pub ncols: usize,
1054 pub matrix: Arc<[f64]>,
1056}
1057
1058#[derive(Clone, Copy, Debug, Default)]
1060pub struct CompiledGCompAte;
1061
1062impl PosteriorFunctionalEvaluator for GCompAteEvaluator {
1063 type Compiled = CompiledGCompAte;
1064
1065 fn compile(&self) -> Result<Self::Compiled, EstimationError> {
1066 if self.treatment_col >= self.ncols {
1067 return Err(EstimationError::stats_msg("treatment column out of range"));
1068 }
1069 Ok(CompiledGCompAte)
1070 }
1071
1072 fn evaluate_batch(
1073 &self,
1074 _compiled: &Self::Compiled,
1075 posterior: PosteriorBatch<'_>,
1076 output: &mut EffectBatch,
1077 workspace: &mut PosteriorEvalWorkspace,
1078 _ctx: &ExecutionContext,
1079 ) -> Result<(), EstimationError> {
1080 let n_draws = posterior.len;
1081 workspace.prepare(n_draws, self.ncols);
1082 output.prepare(n_draws);
1083
1084 let mut coef_cols: Vec<&[f64]> = Vec::with_capacity(self.ncols);
1086 for c in 0..self.ncols {
1087 let col = posterior.column(c).map_err(EstimationError::from)?;
1088 coef_cols.push(col);
1089 }
1090
1091 for d in 0..n_draws {
1092 for c in 0..self.ncols {
1093 workspace.row[c] = coef_cols[c][d];
1094 }
1095 let beta = &workspace.row[..self.ncols];
1096 let mut sum = 0.0;
1097 for r in 0..self.nrows {
1098 let mu_a = predict_row(
1099 self.family,
1100 &self.matrix,
1101 self.nrows,
1102 self.ncols,
1103 self.treatment_col,
1104 beta,
1105 r,
1106 self.active,
1107 );
1108 let mu_c = predict_row(
1109 self.family,
1110 &self.matrix,
1111 self.nrows,
1112 self.ncols,
1113 self.treatment_col,
1114 beta,
1115 r,
1116 self.control,
1117 );
1118 sum += mu_a - mu_c;
1119 }
1120 output.values[d] = sum / self.nrows as f64;
1121 }
1122 Ok(())
1123 }
1124}
1125
1126fn predict_row(
1127 family: GlmFamily,
1128 matrix: &[f64],
1129 nrows: usize,
1130 ncols: usize,
1131 t_col: usize,
1132 beta: &[f64],
1133 row: usize,
1134 t_value: f64,
1135) -> f64 {
1136 let mut eta = 0.0;
1137 for c in 0..ncols {
1138 let x = if c == t_col { t_value } else { matrix[c * nrows + row] };
1139 eta += x * beta[c];
1140 }
1141 family.mean_from_eta(eta)
1142}
1143
1144fn likelihood_to_glm_family(l: BayesLikelihood) -> GlmFamily {
1145 match l {
1146 BayesLikelihood::GaussianIdentity => GlmFamily::GaussianIdentity,
1147 BayesLikelihood::BernoulliLogit => GlmFamily::BinomialLogit,
1148 BayesLikelihood::BernoulliProbit => GlmFamily::BinomialProbit,
1149 BayesLikelihood::PoissonLog => GlmFamily::PoissonLog,
1150 }
1151}
1152
1153fn prob_err(e: antecedent_prob::ProbError) -> EstimationError {
1154 EstimationError::from(e)
1155}
1156
1157fn quantile_width_95(values: &[f64]) -> f64 {
1159 if values.len() < 2 {
1160 return f64::NAN;
1161 }
1162 let schema = PosteriorSchema {
1164 quantities: Arc::from([PosteriorQuantityKind::Effect { name: Arc::from("w") }]),
1165 };
1166 let Ok(draws) = PosteriorDraws::from_column_major(schema, values.len(), values.to_vec()) else {
1167 return f64::NAN;
1168 };
1169 let s = draws.summarize();
1170 s.q975[0] - s.q025[0]
1171}
1172
1173fn merge_coefficient_draws(
1175 a: &PosteriorDraws,
1176 b: &PosteriorDraws,
1177) -> Result<PosteriorDraws, EstimationError> {
1178 if a.schema != b.schema {
1179 return Err(EstimationError::stats_msg("merge_coefficient_draws: schema mismatch"));
1180 }
1181 let n_q = a.schema.quantities.len();
1182 let n = a.n_draws + b.n_draws;
1183 let mut values = vec![0.0; n * n_q];
1184 for q in 0..n_q {
1185 let col_a = a.column(q).map_err(EstimationError::from)?;
1186 let col_b = b.column(q).map_err(EstimationError::from)?;
1187 values[q * n..q * n + a.n_draws].copy_from_slice(col_a);
1188 values[q * n + a.n_draws..(q + 1) * n].copy_from_slice(col_b);
1189 }
1190 PosteriorDraws::from_column_major(a.schema.clone(), n, values).map_err(EstimationError::from)
1191}
1192
1193fn coefficient_only_draws(draws: &PosteriorDraws) -> Result<PosteriorDraws, EstimationError> {
1195 let coef_idx: Vec<usize> = draws
1196 .schema
1197 .quantities
1198 .iter()
1199 .enumerate()
1200 .filter_map(|(i, q)| matches!(q, PosteriorQuantityKind::Coefficient { .. }).then_some(i))
1201 .collect();
1202 if coef_idx.is_empty() {
1203 return Err(EstimationError::stats_msg(
1204 "coefficient_only_draws: no coefficient quantities",
1205 ));
1206 }
1207 if coef_idx.len() == draws.schema.quantities.len() {
1208 return Ok(draws.clone());
1209 }
1210 let n = draws.n_draws;
1211 let n_q = coef_idx.len();
1212 let mut quantities = Vec::with_capacity(n_q);
1213 let mut values = vec![0.0; n * n_q];
1214 for (dest, &src) in coef_idx.iter().enumerate() {
1215 quantities.push(draws.schema.quantities[src].clone());
1216 let col = draws.column(src).map_err(EstimationError::from)?;
1217 values[dest * n..(dest + 1) * n].copy_from_slice(col);
1218 }
1219 PosteriorDraws::from_column_major(
1220 PosteriorSchema { quantities: Arc::from(quantities) },
1221 n,
1222 values,
1223 )
1224 .map_err(EstimationError::from)
1225}
1226
1227#[must_use]
1233pub fn nonidentified_with_prior(
1234 prior: &PriorSet,
1235 diagnostics: InferenceDiagnostics,
1236 n_draws: usize,
1237 seed: u64,
1238) -> CausalPosterior {
1239 let mut assumptions = AssumptionSet::new();
1240 for spec in &prior.specs {
1241 assumptions.push(AssumptionRecord {
1242 assumption: Assumption::PriorRestriction(spec.as_assumption()),
1243 source: AssumptionSource::UserDeclared,
1244 scope: AssumptionScope::Estimation,
1245 status: AssumptionStatus::Untestable,
1246 });
1247 }
1248 let schema = PosteriorSchema {
1249 quantities: Arc::from([PosteriorQuantityKind::Effect { name: Arc::from("ate") }]),
1250 };
1251 let (mean, scale) = prior_predictive_effect_params(prior);
1252 let n = n_draws.max(1);
1253 let mut values = vec![0.0; n];
1254 let mut rng = ExecutionContext::for_tests(seed).rng.stream(0xBA7E_u64);
1255 for v in &mut values {
1256 *v = mean + scale * antecedent_kernels::standard_normal(&mut rng);
1257 }
1258 let draws = PosteriorDraws::from_column_major(schema, n, Arc::<[f64]>::from(values))
1259 .unwrap_or_else(|_| PosteriorDraws {
1260 schema: PosteriorSchema {
1261 quantities: Arc::from([PosteriorQuantityKind::Effect { name: Arc::from("ate") }]),
1262 },
1263 n_draws: 0,
1264 values: Arc::from([]),
1265 });
1266 let summaries = draws.summarize();
1267 CausalPosterior {
1268 draws,
1269 summaries,
1270 identification: IdentificationStatus::NotIdentified,
1271 prior_sensitivity: None,
1272 conflict_summary: None,
1273 diagnostics,
1274 assumptions,
1275 unidentified_mass: 1.0,
1276 early_stopped: false,
1277 }
1278}
1279
1280fn prior_predictive_effect_params(prior: &PriorSet) -> (f64, f64) {
1281 if let Some(g) = prior.gaussian_coefficients() {
1282 let mean = g.mean.first().copied().unwrap_or(0.0);
1283 let var = g.variance.first().copied().unwrap_or(100.0).max(1e-12);
1284 return (mean, var.sqrt());
1285 }
1286 (0.0, 10.0)
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291 use super::*;
1292 use antecedent_core::{
1293 CausalSchemaBuilder, MeasurementSpec, RoleHint, SmallRoleSet, ValueType, VariableId,
1294 };
1295 use antecedent_data::column::{Float64Column, ValidityBitmap};
1296 use antecedent_data::{OwnedColumn, OwnedColumnarStorage, TabularData};
1297 use antecedent_expr::{ExprId, IdentifiedEstimand};
1298 use antecedent_prob::InferenceDiagnostics;
1299
1300 fn linear_scm_table(n: usize) -> (TabularData, VariableId, VariableId, VariableId) {
1301 let mut b = CausalSchemaBuilder::new();
1302 b.add_variable(
1303 "Z",
1304 ValueType::Continuous,
1305 SmallRoleSet::from_hint(RoleHint::Context),
1306 None,
1307 None,
1308 MeasurementSpec::default(),
1309 )
1310 .unwrap();
1311 b.add_variable(
1312 "T",
1313 ValueType::Continuous,
1314 SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
1315 None,
1316 None,
1317 MeasurementSpec::default(),
1318 )
1319 .unwrap();
1320 b.add_variable(
1321 "Y",
1322 ValueType::Continuous,
1323 SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
1324 None,
1325 None,
1326 MeasurementSpec::default(),
1327 )
1328 .unwrap();
1329 let schema = b.build().unwrap();
1330 let z = VariableId::from_raw(0);
1331 let t = VariableId::from_raw(1);
1332 let y = VariableId::from_raw(2);
1333 let mut zv = vec![0.0; n];
1334 let mut tv = vec![0.0; n];
1335 let mut yv = vec![0.0; n];
1336 for i in 0..n {
1337 zv[i] = (i as f64) * 0.1;
1338 tv[i] = if i % 2 == 0 { 1.0 } else { 0.0 };
1339 yv[i] = 2.0 * tv[i] + 0.5 * zv[i];
1340 }
1341 let validity = ValidityBitmap::all_valid(n);
1342 let cols = vec![
1343 OwnedColumn::Float64(Float64Column::new(z, Arc::from(zv), validity.clone()).unwrap()),
1344 OwnedColumn::Float64(Float64Column::new(t, Arc::from(tv), validity.clone()).unwrap()),
1345 OwnedColumn::Float64(Float64Column::new(y, Arc::from(yv), validity).unwrap()),
1346 ];
1347 let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1348 (TabularData::new(storage), t, y, z)
1349 }
1350
1351 #[test]
1352 fn bayesian_and_frequentist_share_ate() {
1353 let n = 80;
1354 let (data, t, y, z) = linear_scm_table(n);
1355 let estimand = IdentifiedEstimand::backdoor(
1356 "backdoor.adjustment",
1357 Arc::from(vec![z]),
1358 ExprId::from_raw(0),
1359 );
1360 let query = AverageEffectQuery::binary_ate(t, y);
1361
1362 let freq = crate::adjustment::LinearAdjustmentAte {
1363 bootstrap_replicates: 0,
1364 ..crate::adjustment::LinearAdjustmentAte::new()
1365 };
1366 let prep = freq.prepare(&data, &estimand, &query).unwrap();
1367 let mut ws = crate::adjustment::EstimationWorkspace::default();
1368 let freq_est = freq
1369 .fit(&prep, &mut ws, &ExecutionContext::for_tests(1), AssumptionSet::new())
1370 .unwrap();
1371
1372 let bayes = BayesianGComputationAte {
1373 backend: BayesianBackendKind::ConjugateGaussian,
1374 n_draws: 400,
1375 seed: 5,
1376 prior_scale: 100.0,
1377 ..BayesianGComputationAte::new()
1378 };
1379 let bprep = bayes.prepare(&data, &estimand, &query).unwrap();
1380 let mut bws = BayesianGCompWorkspace::default();
1381 let post = bayes
1382 .fit(
1383 &bprep,
1384 IdentificationStatus::NonparametricallyIdentified,
1385 &mut bws,
1386 &ExecutionContext::for_tests(1),
1387 )
1388 .unwrap();
1389 let eq = post.effect_column().unwrap();
1390 let mean = post.summaries.mean[eq];
1391 assert!((freq_est.ate - 2.0).abs() < 1e-6, "frequentist ate={}", freq_est.ate);
1392 assert!((mean - freq_est.ate).abs() < 0.05, "bayes={mean} freq={}", freq_est.ate);
1393 assert_eq!(post.identification, IdentificationStatus::NonparametricallyIdentified);
1394 let coef_names: Vec<_> = post
1395 .draws
1396 .schema
1397 .quantities
1398 .iter()
1399 .filter_map(|q| match q {
1400 PosteriorQuantityKind::Coefficient { name, .. } => name.as_ref().map(AsRef::as_ref),
1401 _ => None,
1402 })
1403 .collect();
1404 assert!(coef_names.contains(&"intercept"), "{coef_names:?}");
1405 assert!(coef_names.iter().any(|n| n.starts_with("coef_")), "{coef_names:?}");
1406 }
1407
1408 #[test]
1409 fn prior_does_not_create_identification() {
1410 let prior = PriorSet::weakly_informative(3);
1411 let post = nonidentified_with_prior(&prior, InferenceDiagnostics::analytic("none"), 64, 1);
1412 assert_eq!(post.identification, IdentificationStatus::NotIdentified);
1413 assert!(!post.assumptions.is_empty());
1414 assert!((post.unidentified_mass - 1.0).abs() < 1e-12);
1415 assert!(post.draws.n_draws > 0, "prior-predictive draws required");
1416 }
1417
1418 #[test]
1419 fn temporal_prepared_design_conjugate_recovers_pulse() {
1420 use antecedent_core::{
1421 CausalSchemaBuilder, Lag, MeasurementSpec, RoleHint, SmallRoleSet, TemporalEffectQuery,
1422 TemporalPolicy, ValueType,
1423 };
1424 use antecedent_data::{
1425 Float64Column, OwnedColumn, OwnedColumnarStorage, SamplingRegularity, TimeIndex,
1426 TimeSeriesData, ValidityBitmap,
1427 };
1428 use antecedent_graph::{TemporalDag, ensure_lagged};
1429 use antecedent_identify::TemporalBackdoorIdentifier;
1430
1431 use crate::temporal_adjustment::TemporalLinearAdjustment;
1432
1433 let n = 300usize;
1434 let mut b = CausalSchemaBuilder::new();
1435 b.add_variable(
1436 "x",
1437 ValueType::Continuous,
1438 SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
1439 None,
1440 None,
1441 MeasurementSpec::default(),
1442 )
1443 .unwrap();
1444 b.add_variable(
1445 "y",
1446 ValueType::Continuous,
1447 SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
1448 None,
1449 None,
1450 MeasurementSpec::default(),
1451 )
1452 .unwrap();
1453 let schema = b.build().unwrap();
1454 let mut x = vec![0.0; n];
1455 let mut y = vec![0.0; n];
1456 for t in 1..n {
1457 x[t] = ((t as f64) * 0.07).sin();
1458 y[t] = 0.8 * x[t - 1];
1459 }
1460 let cols = vec![
1461 OwnedColumn::Float64(
1462 Float64Column::new(
1463 VariableId::from_raw(0),
1464 Arc::from(x),
1465 ValidityBitmap::all_valid(n),
1466 )
1467 .unwrap(),
1468 ),
1469 OwnedColumn::Float64(
1470 Float64Column::new(
1471 VariableId::from_raw(1),
1472 Arc::from(y),
1473 ValidityBitmap::all_valid(n),
1474 )
1475 .unwrap(),
1476 ),
1477 ];
1478 let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1479 let data = TimeSeriesData::try_new(
1480 storage,
1481 TimeIndex { regularity: SamplingRegularity::Regular { interval_ns: 1 }, length: n },
1482 )
1483 .unwrap();
1484 let mut g = TemporalDag::empty();
1485 let x1 = ensure_lagged(&mut g, VariableId::from_raw(0), Lag::from_raw(1)).unwrap();
1486 let y0 = ensure_lagged(&mut g, VariableId::from_raw(1), Lag::CONTEMPORANEOUS).unwrap();
1487 g.insert_directed(x1, y0).unwrap();
1488
1489 let q = TemporalEffectQuery::pulse(VariableId::from_raw(0), VariableId::from_raw(1), 1.0)
1490 .with_policy(TemporalPolicy::pulse(-1))
1491 .with_horizon_steps(1)
1492 .with_max_history_lag(Some(1));
1493 let id_res = TemporalBackdoorIdentifier::new().identify_temporal(&g, &q).unwrap();
1494 let estimand = id_res.result.estimands.first().unwrap();
1495 let temporal = TemporalLinearAdjustment::new();
1496 let prep = temporal
1497 .prepare(
1498 &data,
1499 estimand,
1500 &q,
1501 &id_res.indexer,
1502 None,
1503 &ExecutionContext::for_tests(1).kernel_policy,
1504 )
1505 .unwrap();
1506 let bayes = BayesianTemporalGcomp {
1507 inner: BayesianGComputationAte {
1508 backend: BayesianBackendKind::ConjugateGaussian,
1509 n_draws: 200,
1510 seed: 7,
1511 prior_scale: 100.0,
1512 ..BayesianGComputationAte::new()
1513 },
1514 };
1515 let bprep = BayesianTemporalGcomp::from_prepared_estimation(&prep);
1516 let mut ws = BayesianGCompWorkspace::default();
1517 let post = bayes
1518 .fit(
1519 &bprep,
1520 IdentificationStatus::NonparametricallyIdentified,
1521 &mut ws,
1522 &ExecutionContext::for_tests(1),
1523 )
1524 .unwrap();
1525 let eq = post.effect_column().unwrap();
1526 let mean = post.summaries.mean[eq];
1527 assert!((mean - 0.8).abs() < 0.05, "bayesian temporal pulse mean={mean}");
1528 assert!(post.probability_below(0.0).unwrap().is_finite());
1529 }
1530
1531 #[test]
1532 fn hydrate_prior_from_posterior_and_refit() {
1533 let n = 60;
1534 let (data, t, y, z) = linear_scm_table(n);
1535 let estimand = IdentifiedEstimand::backdoor(
1536 "backdoor.adjustment",
1537 Arc::from(vec![z]),
1538 ExprId::from_raw(0),
1539 );
1540 let query = AverageEffectQuery::binary_ate(t, y);
1541 let bayes = BayesianGComputationAte {
1542 backend: BayesianBackendKind::ConjugateGaussian,
1543 n_draws: 200,
1544 seed: 3,
1545 prior_scale: 10.0,
1546 ..BayesianGComputationAte::new()
1547 };
1548 let prep = bayes.prepare(&data, &estimand, &query).unwrap();
1549 let mut ws = BayesianGCompWorkspace::default();
1550 let post = bayes
1551 .fit(
1552 &prep,
1553 IdentificationStatus::NonparametricallyIdentified,
1554 &mut ws,
1555 &ExecutionContext::for_tests(1),
1556 )
1557 .unwrap();
1558 let prior = hydrate_prior_from_posterior(&post, Some(prep.design.ncols)).unwrap();
1559 assert_eq!(prior.gaussian_coefficients().unwrap().len(), prep.design.ncols);
1560 assert!(hydrate_prior_from_posterior(&post, Some(prep.design.ncols + 1)).is_err());
1561
1562 let sequential = BayesianGComputationAte { prior: Some(prior), ..bayes };
1563 let post2 = sequential
1564 .fit(
1565 &prep,
1566 IdentificationStatus::NonparametricallyIdentified,
1567 &mut ws,
1568 &ExecutionContext::for_tests(1),
1569 )
1570 .unwrap();
1571 assert!(post2.assumptions.entries.iter().any(|a| {
1572 matches!(a.source, AssumptionSource::Artifact)
1573 && matches!(&a.assumption, Assumption::PriorRestriction(pa) if pa.description.contains("sequential"))
1574 }));
1575 let eq = post2.effect_column().unwrap();
1576 assert!(post2.summaries.mean[eq].is_finite());
1577 }
1578
1579 #[test]
1580 fn hydrate_effect_functional_maps_treatment_coef() {
1581 let quantities = vec![
1582 PosteriorQuantityKind::Coefficient { index: 0, name: Some(Arc::from("intercept")) },
1583 PosteriorQuantityKind::Coefficient { index: 1, name: Some(Arc::from("coef_t")) },
1584 PosteriorQuantityKind::Effect { name: Arc::from("ate") },
1585 ];
1586 let mean = vec![0.1, 0.5, 2.0];
1587 let sd = vec![1.0, 1.0, 0.4];
1588 let names: Vec<Arc<str>> =
1589 vec![Arc::from("intercept"), Arc::from("coef_t"), Arc::from("coef_z")];
1590 let baseline = PriorSet::weakly_informative(3);
1591 let prior = hydrate_prior(
1592 &HydrateMapping::EffectFunctional { source_quantity: "ate".into() },
1593 &quantities,
1594 &mean,
1595 &sd,
1596 &baseline,
1597 &names,
1598 Some(1),
1599 )
1600 .unwrap();
1601 let coef = prior.gaussian_coefficients().unwrap();
1602 assert!((coef.mean[1] - 2.0).abs() < 1e-12);
1603 assert!((coef.variance[1] - 0.16).abs() < 1e-12);
1604 assert!((coef.mean[0] - 0.0).abs() < 1e-12);
1606 assert!((coef.variance[0] - 100.0).abs() < 1e-12);
1607 assert!((coef.variance[2] - 100.0).abs() < 1e-12);
1608 assert!(prior.restrictions.iter().any(|r| r.id.as_ref() == "external_effect_prior"));
1609 }
1610
1611 #[test]
1612 fn hydrate_mapping_hard_errors() {
1613 let quantities = vec![
1614 PosteriorQuantityKind::Coefficient { index: 0, name: Some(Arc::from("intercept")) },
1615 PosteriorQuantityKind::Coefficient { index: 1, name: Some(Arc::from("coef_t")) },
1616 PosteriorQuantityKind::Effect { name: Arc::from("ate") },
1617 ];
1618 let mean = vec![0.0, 1.0, 2.0];
1619 let sd = vec![1.0, 1.0, 0.5];
1620 let names2: Vec<Arc<str>> = vec![Arc::from("intercept"), Arc::from("coef_t")];
1621 let baseline2 = PriorSet::weakly_informative(2);
1622 let names3: Vec<Arc<str>> =
1624 vec![Arc::from("intercept"), Arc::from("coef_t"), Arc::from("coef_w")];
1625 let baseline3 = PriorSet::weakly_informative(3);
1626 assert!(
1627 hydrate_prior(
1628 &HydrateMapping::IdenticalCoefficientSubspace,
1629 &quantities,
1630 &mean,
1631 &sd,
1632 &baseline3,
1633 &names3,
1634 None,
1635 )
1636 .is_err()
1637 );
1638
1639 assert!(
1640 hydrate_prior(
1641 &HydrateMapping::EffectFunctional { source_quantity: "missing".into() },
1642 &quantities,
1643 &mean,
1644 &sd,
1645 &baseline2,
1646 &names2,
1647 Some(1),
1648 )
1649 .is_err()
1650 );
1651
1652 assert!(
1653 hydrate_prior(
1654 &HydrateMapping::NamedParameters {
1655 pairs: vec![("ate".into(), "no_such_coef".into())],
1656 },
1657 &quantities,
1658 &mean,
1659 &sd,
1660 &baseline2,
1661 &names2,
1662 None,
1663 )
1664 .is_err()
1665 );
1666
1667 assert!(
1668 hydrate_prior(
1669 &HydrateMapping::NamedParameters {
1670 pairs: vec![("no_src".into(), "coef_t".into())],
1671 },
1672 &quantities,
1673 &mean,
1674 &sd,
1675 &baseline2,
1676 &names2,
1677 None,
1678 )
1679 .is_err()
1680 );
1681 }
1682
1683 #[test]
1684 fn hydrate_named_parameters_overwrites_target() {
1685 let quantities = vec![
1686 PosteriorQuantityKind::Coefficient { index: 0, name: Some(Arc::from("intercept")) },
1687 PosteriorQuantityKind::Coefficient { index: 1, name: Some(Arc::from("coef_t")) },
1688 PosteriorQuantityKind::Effect { name: Arc::from("ate") },
1689 ];
1690 let mean = vec![0.0, 0.0, 1.5];
1691 let sd = vec![1.0, 1.0, 0.2];
1692 let names: Vec<Arc<str>> = vec![Arc::from("intercept"), Arc::from("coef_t")];
1693 let baseline = PriorSet::weakly_informative(2);
1694 let prior = hydrate_prior(
1695 &HydrateMapping::NamedParameters { pairs: vec![("ate".into(), "coef_t".into())] },
1696 &quantities,
1697 &mean,
1698 &sd,
1699 &baseline,
1700 &names,
1701 None,
1702 )
1703 .unwrap();
1704 let coef = prior.gaussian_coefficients().unwrap();
1705 assert!((coef.mean[1] - 1.5).abs() < 1e-12);
1706 assert!(prior.restrictions.iter().any(|r| r.id.as_ref() == "external_named_prior"));
1707 }
1708}