1#![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#[derive(Clone, Debug)]
28pub struct DoSampleResult {
29 pub values: Arc<[f64]>,
31 pub weights: Arc<[f64]>,
33 pub method: Arc<str>,
35 pub notes: Vec<Arc<str>>,
37 pub accept_rate: Option<f64>,
39 pub bandwidth: Option<f64>,
41}
42
43#[derive(Clone, Debug)]
54pub struct WeightingDoSampler {
55 pub treatment: VariableId,
57 pub outcome: VariableId,
59}
60
61impl WeightingDoSampler {
62 #[must_use]
64 pub fn new(treatment: VariableId, outcome: VariableId) -> Self {
65 Self { treatment, outcome }
66 }
67
68 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 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 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 #[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#[derive(Clone, Debug)]
191pub struct KdeDoSampler {
192 pub outcome: VariableId,
194 pub bandwidth: Option<f64>,
196}
197
198impl KdeDoSampler {
199 #[must_use]
201 pub fn new(outcome: VariableId) -> Self {
202 Self { outcome, bandwidth: None }
203 }
204
205 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 #[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#[derive(Clone, Debug)]
269pub struct McmcDoSampler {
270 pub outcome: VariableId,
272 pub proposal_sd: f64,
274 pub burn_in: usize,
276 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 #[must_use]
289 pub fn new(outcome: VariableId) -> Self {
290 Self { outcome, ..Self::default() }
291 }
292
293 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 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
370pub 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 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}