Skip to main content

antecedent_model/
do_sampler.rs

1//! Do-samplers: weighting, KDE, and MCMC.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5#![allow(
6    clippy::cast_possible_truncation,
7    clippy::cast_precision_loss,
8    clippy::cast_sign_loss,
9    clippy::many_single_char_names,
10    clippy::needless_range_loop,
11    clippy::too_many_arguments
12)]
13
14use std::sync::Arc;
15
16use antecedent_core::{CausalRng, ExecutionContext, Intervention, VariableId};
17use antecedent_data::{TableView, TabularData};
18use antecedent_kernels::standard_normal;
19
20use crate::batch::{MechanismWorkspace, ParentBatch};
21use crate::compile::CompiledCausalModel;
22use crate::error::ModelError;
23use crate::mechanism::log_prob_column;
24use crate::sample::sample_interventional;
25
26/// Result of a do-sampler run.
27#[derive(Clone, Debug)]
28pub struct DoSampleResult {
29    /// Sampled (or reweighted observational) outcomes for the target variable.
30    pub values: Arc<[f64]>,
31    /// Optional importance weights (weighting sampler); empty if not used.
32    pub weights: Arc<[f64]>,
33    /// Sampler id.
34    pub method: Arc<str>,
35    /// Diagnostics notes.
36    pub notes: Vec<Arc<str>>,
37    /// MCMC accept rate when applicable.
38    pub accept_rate: Option<f64>,
39    /// Gaussian KDE bandwidth when the result carries a density estimate.
40    pub bandwidth: Option<f64>,
41}
42
43/// Weighting do-sampler for hard `do(T=t)`.
44///
45/// - **Root treatment:** empirical outcomes among units with `T ≈ t`.
46/// - **Confounded continuous treatment:** Horvitz–Thompson with a **shrinking** Gaussian
47///   kernel on the treatment margin (Silverman bandwidth) over the fitted conditional
48///   density `f(T∣parents)`: `wᵢ ∝ Kₕ(Tᵢ − t) / f(Tᵢ∣parents)`. Hard interventions have a
49///   Dirac interventional law, so the kernel is the localization numerator (there is no
50///   separate `lp_do` term).
51/// - **Non-density / discrete mechanisms:** kernel localization alone (exact match when the
52///   bandwidth collapses on tied support).
53#[derive(Clone, Debug)]
54pub struct WeightingDoSampler {
55    /// Treatment variable.
56    pub treatment: VariableId,
57    /// Outcome variable.
58    pub outcome: VariableId,
59}
60
61impl WeightingDoSampler {
62    /// Construct.
63    #[must_use]
64    pub fn new(treatment: VariableId, outcome: VariableId) -> Self {
65        Self { treatment, outcome }
66    }
67
68    /// Estimate E[Y | do(T=t)] via matching / Horvitz–Thompson on fitted model densities.
69    ///
70    /// # Errors
71    ///
72    /// Missing columns or unfitted treatment mechanism.
73    pub fn estimate(
74        &self,
75        model: &CompiledCausalModel,
76        data: &TabularData,
77        treatment_value: f64,
78        _ctx: &ExecutionContext,
79    ) -> Result<DoSampleResult, ModelError> {
80        let t_dense = model
81            .dense_of(self.treatment)
82            .ok_or_else(|| ModelError::Shape { message: "treatment not in model".into() })?;
83        let y = data.float64_values(self.outcome).map_err(ModelError::from)?;
84        let t = data.float64_values(self.treatment).map_err(ModelError::from)?;
85        let n = y.len();
86        let gather = model
87            .gather_for(t_dense)
88            .ok_or_else(|| ModelError::Shape { message: "missing gather for treatment".into() })?;
89
90        let mut weights = vec![0.0; n];
91        let mut values = Vec::with_capacity(n);
92        let mut notes = Vec::new();
93
94        if gather.n_parents() == 0 {
95            // Root treatment: empirical outcomes among units with T ≈ t.
96            let mut selected = 0usize;
97            for i in 0..n {
98                if (t[i] - treatment_value).abs() < 1e-9 {
99                    values.push(y[i]);
100                    weights[selected] = 1.0;
101                    selected += 1;
102                }
103            }
104            weights.truncate(selected);
105            if selected == 0 {
106                return Err(ModelError::Numerical {
107                    message: "weighting sampler: no observational units match do-value".into(),
108                });
109            }
110            notes.push(Arc::from("root treatment: exact match reweighting"));
111            return Ok(DoSampleResult {
112                values: Arc::from(values),
113                weights: Arc::from(weights),
114                method: Arc::from("do_weighting"),
115                notes,
116                accept_rate: None,
117                bandwidth: None,
118            });
119        }
120
121        // Confounded: IPW using Gaussian propensity from fitted treatment mechanism.
122        let slot = model.mechanisms.get(t_dense);
123        let mut parent_cols = Vec::new();
124        for &p in gather.parents.iter() {
125            let var = model.output_layout.variables[p.as_usize()];
126            parent_cols.push(data.float64_values(var).map_err(ModelError::from)?);
127        }
128        let n_par = gather.n_parents();
129        let mut parent_mat = vec![0.0; n * n_par];
130        for (pi, col) in parent_cols.iter().enumerate() {
131            for r in 0..n {
132                parent_mat[pi * n + r] = col[r];
133            }
134        }
135        let parents = ParentBatch { n_rows: n, n_parents: n_par, values: &parent_mat };
136        let mut lp_obs = vec![0.0; n];
137        let has_density = log_prob_column(slot, &t, parents, &mut lp_obs).is_ok();
138        let bw = silverman_bandwidth(&t).max(1e-8);
139        let inv_norm = 1.0 / (bw * (2.0 * std::f64::consts::PI).sqrt());
140        for i in 0..n {
141            let z = (t[i] - treatment_value) / bw;
142            let kernel = inv_norm * (-0.5 * z * z).exp();
143            let w = if has_density && lp_obs[i].is_finite() {
144                let dens = lp_obs[i].exp().max(1e-300);
145                (kernel / dens).min(1e6)
146            } else {
147                kernel
148            };
149            weights[i] = w;
150            values.push(y[i]);
151        }
152        let wsum: f64 = weights.iter().sum();
153        if wsum.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) {
154            return Err(ModelError::Numerical {
155                message: "weighting sampler: zero weight mass".into(),
156            });
157        }
158        for w in &mut weights {
159            *w /= wsum;
160        }
161        notes.push(Arc::from(format!("IPW / Silverman-kernel weighting (bandwidth={bw:.6})")));
162        Ok(DoSampleResult {
163            values: Arc::from(values),
164            weights: Arc::from(weights),
165            method: Arc::from("do_weighting"),
166            notes,
167            accept_rate: None,
168            bandwidth: Some(bw),
169        })
170    }
171
172    /// Weighted mean of the sampler result.
173    #[must_use]
174    pub fn weighted_mean(result: &DoSampleResult) -> f64 {
175        if result.values.is_empty() {
176            return f64::NAN;
177        }
178        if result.weights.is_empty() {
179            return result.values.iter().sum::<f64>() / result.values.len() as f64;
180        }
181        let wsum: f64 = result.weights.iter().sum();
182        if wsum.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) {
183            return f64::NAN;
184        }
185        result.values.iter().zip(result.weights.iter()).map(|(v, w)| v * w).sum::<f64>() / wsum
186    }
187}
188
189/// KDE do-sampler: sample from interventional SCM then smooth the outcome with a Gaussian KDE.
190#[derive(Clone, Debug)]
191pub struct KdeDoSampler {
192    /// Outcome variable.
193    pub outcome: VariableId,
194    /// Bandwidth (Silverman's rule if None).
195    pub bandwidth: Option<f64>,
196}
197
198impl KdeDoSampler {
199    /// Construct.
200    #[must_use]
201    pub fn new(outcome: VariableId) -> Self {
202        Self { outcome, bandwidth: None }
203    }
204
205    /// Draw interventional samples and return KDE-ready values (+ bandwidth note).
206    ///
207    /// # Errors
208    ///
209    /// Sampling failures.
210    pub fn sample(
211        &self,
212        model: &CompiledCausalModel,
213        interventions: &[Intervention],
214        n_draws: usize,
215        rng: &mut CausalRng,
216        ws: &mut MechanismWorkspace,
217        ctx: &ExecutionContext,
218    ) -> Result<DoSampleResult, ModelError> {
219        let batch = sample_interventional(model, interventions, n_draws, rng, ws, ctx)?;
220        let dense = model
221            .dense_of(self.outcome)
222            .ok_or_else(|| ModelError::Shape { message: "outcome not in model".into() })?;
223        let col = batch.column(dense.as_usize())?;
224        let bw = self.bandwidth.unwrap_or_else(|| silverman_bandwidth(col));
225        Ok(DoSampleResult {
226            values: Arc::from(col.to_vec()),
227            weights: Arc::from(vec![1.0 / n_draws as f64; n_draws]),
228            method: Arc::from("do_kde"),
229            notes: Vec::new(),
230            accept_rate: None,
231            bandwidth: Some(bw),
232        })
233    }
234
235    /// Evaluate KDE density at `x` given sampler values.
236    #[must_use]
237    pub fn density(result: &DoSampleResult, x: f64) -> f64 {
238        let bw = result.bandwidth.unwrap_or(1.0).max(1e-8);
239        let n = result.values.len() as f64;
240        let inv = 1.0 / (bw * (2.0 * std::f64::consts::PI).sqrt());
241        let mut dens = 0.0;
242        for &v in result.values.iter() {
243            let z = (x - v) / bw;
244            dens += inv * (-0.5 * z * z).exp();
245        }
246        dens / n.max(1.0)
247    }
248}
249
250fn silverman_bandwidth(x: &[f64]) -> f64 {
251    let n = x.len() as f64;
252    if n < 2.0 {
253        return 1.0;
254    }
255    let mean = x.iter().sum::<f64>() / n;
256    let var = x.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
257    let sd = var.sqrt().max(1e-8);
258    1.06 * sd * n.powf(-0.2)
259}
260
261/// Random-walk Metropolis–Hastings on the **outcome margin**.
262///
263/// The chain targets a Silverman Gaussian KDE fitted to a pilot batch of interventional
264/// ancestral draws — a smoothed proxy of the interventional law of `outcome`, not the
265/// joint mechanism density. Proposals are Gaussian random walks (`proposal_sd`); this is
266/// **not** independent MH, and is exact for the interventional law only in the large-pilot
267/// / vanishing-bandwidth limit of that KDE proxy.
268#[derive(Clone, Debug)]
269pub struct McmcDoSampler {
270    /// Outcome variable to record.
271    pub outcome: VariableId,
272    /// Proposal standard deviation.
273    pub proposal_sd: f64,
274    /// Burn-in iterations.
275    pub burn_in: usize,
276    /// Thinning.
277    pub thin: usize,
278}
279
280impl Default for McmcDoSampler {
281    fn default() -> Self {
282        Self { outcome: VariableId::from_raw(0), proposal_sd: 0.5, burn_in: 100, thin: 2 }
283    }
284}
285
286impl McmcDoSampler {
287    /// Construct targeting `outcome`.
288    #[must_use]
289    pub fn new(outcome: VariableId) -> Self {
290        Self { outcome, ..Self::default() }
291    }
292
293    /// Run random-walk MH against a KDE of interventional pilot draws (see type docs).
294    ///
295    /// # Errors
296    ///
297    /// Sampling / density failures.
298    pub fn sample(
299        &self,
300        model: &CompiledCausalModel,
301        interventions: &[Intervention],
302        n_samples: usize,
303        rng: &mut CausalRng,
304        ws: &mut MechanismWorkspace,
305        ctx: &ExecutionContext,
306    ) -> Result<DoSampleResult, ModelError> {
307        let pilot = sample_interventional(model, interventions, n_samples.max(64), rng, ws, ctx)?;
308        let dense = model
309            .dense_of(self.outcome)
310            .ok_or_else(|| ModelError::Shape { message: "outcome not in model".into() })?;
311        let pilot_col = pilot.column(dense.as_usize())?;
312        let pilot_bw = silverman_bandwidth(pilot_col);
313        let kde = DoSampleResult {
314            values: Arc::from(pilot_col.to_vec()),
315            weights: Arc::from([]),
316            method: Arc::from("pilot"),
317            notes: Vec::new(),
318            accept_rate: None,
319            bandwidth: Some(pilot_bw),
320        };
321
322        let mut current = pilot_col[0];
323        let mut accepted = 0usize;
324        let mut total = 0usize;
325        let mut out = Vec::with_capacity(n_samples);
326        let iters = self.burn_in + n_samples * self.thin.max(1);
327        // Degenerate pilot (near-zero bandwidth) → independent draws from the pilot
328        // empirical measure (random-walk MH cannot move).
329        let degenerate =
330            pilot_bw < 1e-6 || pilot_col.iter().all(|&v| (v - pilot_col[0]).abs() < 1e-12);
331
332        for i in 0..iters {
333            if degenerate {
334                let idx = (rng.next_f64() * pilot_col.len() as f64).floor() as usize
335                    % pilot_col.len().max(1);
336                current = pilot_col[idx];
337                accepted += 1;
338                total += 1;
339            } else {
340                let z = standard_normal(rng);
341                let prop = current + self.proposal_sd * z;
342                let p_cur = KdeDoSampler::density(&kde, current).max(1e-300);
343                let p_prop = KdeDoSampler::density(&kde, prop).max(1e-300);
344                let accept = (p_prop / p_cur).min(1.0);
345                total += 1;
346                if rng.next_f64() < accept {
347                    current = prop;
348                    accepted += 1;
349                }
350            }
351            if i >= self.burn_in && (i - self.burn_in) % self.thin.max(1) == 0 {
352                out.push(current);
353                if out.len() == n_samples {
354                    break;
355                }
356            }
357        }
358        let rate = accepted as f64 / total.max(1) as f64;
359        Ok(DoSampleResult {
360            values: Arc::from(out),
361            weights: Arc::from([]),
362            method: Arc::from("do_mcmc"),
363            notes: vec![Arc::from(format!("mh_accept_rate={rate}"))],
364            accept_rate: Some(rate),
365            bandwidth: kde.bandwidth,
366        })
367    }
368}
369
370/// Convenience: interventional mean of a variable from ancestral sampling.
371///
372/// # Errors
373///
374/// Sampling failures.
375pub fn interventional_mean(
376    model: &CompiledCausalModel,
377    interventions: &[Intervention],
378    outcome: VariableId,
379    n_draws: usize,
380    rng: &mut CausalRng,
381    ws: &mut MechanismWorkspace,
382    ctx: &ExecutionContext,
383) -> Result<f64, ModelError> {
384    let batch = sample_interventional(model, interventions, n_draws, rng, ws, ctx)?;
385    let dense = model
386        .dense_of(outcome)
387        .ok_or_else(|| ModelError::Shape { message: "outcome not in model".into() })?;
388    let col = batch.column(dense.as_usize())?;
389    Ok(col.iter().sum::<f64>() / col.len().max(1) as f64)
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use crate::registry::{MechanismRegistry, SelectionPolicy};
396    use antecedent_core::{
397        CausalSchemaBuilder, MeasurementSpec, RoleHint, SmallRoleSet, Value, ValueType,
398    };
399    use antecedent_data::column::{Float64Column, ValidityBitmap};
400    use antecedent_data::{OwnedColumn, OwnedColumnarStorage};
401    use antecedent_graph::{Dag, DenseNodeId};
402
403    fn binary_treatment_scm() -> (CompiledCausalModel, TabularData) {
404        let n = 80usize;
405        let mut b = CausalSchemaBuilder::new();
406        b.add_variable(
407            "t",
408            ValueType::Continuous,
409            SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
410            None,
411            None,
412            MeasurementSpec::default(),
413        )
414        .unwrap();
415        b.add_variable(
416            "y",
417            ValueType::Continuous,
418            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
419            None,
420            None,
421            MeasurementSpec::default(),
422        )
423        .unwrap();
424        let schema = b.build().unwrap();
425        let mut t = vec![0.0; n];
426        let mut y = vec![0.0; n];
427        for i in 0..n {
428            t[i] = if i % 2 == 0 { 1.0 } else { 0.0 };
429            y[i] = 2.0 * t[i];
430        }
431        let validity = ValidityBitmap::all_valid(n);
432        let cols = vec![
433            OwnedColumn::Float64(
434                Float64Column::new(VariableId::from_raw(0), Arc::from(t), validity.clone())
435                    .unwrap(),
436            ),
437            OwnedColumn::Float64(
438                Float64Column::new(VariableId::from_raw(1), Arc::from(y), validity).unwrap(),
439            ),
440        ];
441        let data =
442            TabularData::new(OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap());
443        let mut g = Dag::with_variables(2);
444        g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
445        let compiled = CompiledCausalModel::compile(g).unwrap();
446        let (store, _) = MechanismRegistry::standard()
447            .assign_and_fit(&compiled, &data, SelectionPolicy::BestScore)
448            .unwrap();
449        (compiled.with_mechanisms(store), data)
450    }
451
452    #[test]
453    fn weighting_recovers_treated_mean() {
454        let (model, data) = binary_treatment_scm();
455        let ctx = ExecutionContext::for_tests(1);
456        let sampler = WeightingDoSampler::new(VariableId::from_raw(0), VariableId::from_raw(1));
457        let res = sampler.estimate(&model, &data, 1.0, &ctx).unwrap();
458        let mean = WeightingDoSampler::weighted_mean(&res);
459        assert!((mean - 2.0).abs() < 1e-9, "mean={mean}");
460    }
461
462    #[test]
463    fn kde_and_mcmc_run() {
464        let n = 40;
465        let mut b = CausalSchemaBuilder::new();
466        b.add_variable(
467            "t",
468            ValueType::Continuous,
469            SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
470            None,
471            None,
472            MeasurementSpec::default(),
473        )
474        .unwrap();
475        b.add_variable(
476            "y",
477            ValueType::Continuous,
478            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
479            None,
480            None,
481            MeasurementSpec::default(),
482        )
483        .unwrap();
484        let schema = b.build().unwrap();
485        let mut t = vec![0.0; n];
486        let mut y = vec![0.0; n];
487        for i in 0..n {
488            t[i] = if i % 2 == 0 { 1.0 } else { 0.0 };
489            // Continuous noise keeps Y off the discrete auto-path and gives KDE spread for MH.
490            y[i] = 2.0 * t[i] + 0.05 * ((i as f64) - 20.0);
491        }
492        let validity = ValidityBitmap::all_valid(n);
493        let cols = vec![
494            OwnedColumn::Float64(
495                Float64Column::new(VariableId::from_raw(0), Arc::from(t), validity.clone())
496                    .unwrap(),
497            ),
498            OwnedColumn::Float64(
499                Float64Column::new(VariableId::from_raw(1), Arc::from(y), validity).unwrap(),
500            ),
501        ];
502        let data =
503            TabularData::new(OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap());
504        let mut g = Dag::with_variables(2);
505        g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
506        let compiled = CompiledCausalModel::compile(g).unwrap();
507        let (store, _) = MechanismRegistry::standard()
508            .assign_and_fit(&compiled, &data, SelectionPolicy::BestScore)
509            .unwrap();
510        let model = compiled.with_mechanisms(store);
511
512        let ctx = ExecutionContext::for_tests(1);
513        let mut rng = CausalRng::from_seed(3);
514        let mut ws = MechanismWorkspace::default();
515        let iv = [Intervention::set(VariableId::from_raw(0), Value::f64(1.0))];
516        let kde = KdeDoSampler::new(VariableId::from_raw(1))
517            .sample(&model, &iv, 40, &mut rng, &mut ws, &ctx)
518            .unwrap();
519        assert_eq!(kde.values.len(), 40);
520        let mcmc = McmcDoSampler::new(VariableId::from_raw(1))
521            .sample(&model, &iv, 30, &mut rng, &mut ws, &ctx)
522            .unwrap();
523        assert_eq!(mcmc.values.len(), 30);
524        assert!(mcmc.accept_rate.unwrap() > 0.0);
525    }
526}