1#![allow(
6 clippy::cast_precision_loss,
7 clippy::cast_possible_truncation,
8 clippy::cast_sign_loss,
9 clippy::needless_range_loop
10)]
11
12use std::borrow::Cow;
13use std::collections::HashSet;
14use std::sync::Arc;
15
16use antecedent_core::{CausalRng, ExecutionContext, VariableId};
17use antecedent_data::{TableView, TabularData};
18use antecedent_graph::DenseNodeId;
19use antecedent_stats::ci::{
20 CiBatchRequest, CiQuery, CiWorkspace, ConditionalIndependenceTest, ConfidenceMethod,
21 PartialCorrelation, SignificanceMethod,
22};
23
24use crate::batch::{MechanismWorkspace, ParentBatch};
25use crate::compile::{CompiledCausalModel, MechanismSlot};
26use crate::error::ModelError;
27use crate::mechanism::{infer_noise_column, log_prob_column};
28
29#[derive(Clone, Debug)]
31pub struct ModelEvaluationReport {
32 pub in_sample_loglik: f64,
34 pub mean_abs_residual: f64,
36 pub residual_independence_p: Arc<[f64]>,
38 pub local_markov_p: Arc<[f64]>,
40 pub permutation_loglik: f64,
42 pub falsified: bool,
44 pub alpha: f64,
46 pub notes: Vec<Arc<str>>,
48}
49
50#[derive(Clone, Debug)]
52pub struct ModelEvaluator {
53 pub alpha: f64,
55 pub n_permutations: usize,
57 pub seed: u64,
59}
60
61impl Default for ModelEvaluator {
62 fn default() -> Self {
63 Self { alpha: 0.05, n_permutations: 20, seed: 0 }
64 }
65}
66
67impl ModelEvaluator {
68 pub fn evaluate(
74 &self,
75 model: &CompiledCausalModel,
76 data: &TabularData,
77 ctx: &ExecutionContext,
78 ) -> Result<ModelEvaluationReport, ModelError> {
79 let n = data.row_count();
80 if n == 0 {
81 return Err(ModelError::Shape { message: "empty data for evaluation".into() });
82 }
83 let mut notes = Vec::new();
84 let in_sample_loglik = mean_loglik(model, data)?;
85 let (mean_abs_residual, residuals_by_node) = residual_summary(model, data)?;
86 let residual_independence_p =
87 residual_independence_tests(model, data, &residuals_by_node, self.alpha, ctx)?;
88 let local_markov_p = local_markov_tests(model, data, self.alpha, ctx)?;
89 let perm_seed = if self.seed == 0 { ctx.rng.master_seed() } else { self.seed };
91 let permutation_loglik = permutation_baseline(model, data, self.n_permutations, perm_seed)?;
92
93 let mut falsified = false;
94 for &p in &residual_independence_p {
95 if p < self.alpha {
96 falsified = true;
97 notes.push(Arc::from("residual independence rejected at alpha"));
98 break;
99 }
100 }
101 for &p in &local_markov_p {
102 if p < self.alpha {
103 falsified = true;
104 notes.push(Arc::from("local Markov condition rejected at alpha"));
105 break;
106 }
107 }
108 if in_sample_loglik + 1.0 < permutation_loglik {
109 notes.push(Arc::from("in-sample loglik near or below permutation baseline"));
111 }
112
113 Ok(ModelEvaluationReport {
114 in_sample_loglik,
115 mean_abs_residual,
116 residual_independence_p: Arc::from(residual_independence_p),
117 local_markov_p: Arc::from(local_markov_p),
118 permutation_loglik,
119 falsified,
120 alpha: self.alpha,
121 notes,
122 })
123 }
124}
125
126fn mean_loglik(model: &CompiledCausalModel, data: &TabularData) -> Result<f64, ModelError> {
127 let n = data.row_count();
128 let mut total = 0.0;
129 let mut count = 0usize;
130 for gather in model.parent_gathers.iter() {
131 let node = gather.child;
132 let var = model.output_layout.variables[node.as_usize()];
133 let y = data.float64_cow(var).map_err(ModelError::from)?;
134 let mut parent_mat = vec![0.0; n * gather.n_parents().max(1)];
135 for (pi, &p) in gather.parents.iter().enumerate() {
136 let pv = model.output_layout.variables[p.as_usize()];
137 let col = data.float64_cow(pv).map_err(ModelError::from)?;
138 parent_mat[pi * n..(pi + 1) * n].copy_from_slice(&col[..n]);
139 }
140 let parents = ParentBatch {
141 n_rows: n,
142 n_parents: gather.n_parents(),
143 values: &parent_mat[..gather.n_parents().saturating_mul(n)],
144 };
145 let mut lp = vec![0.0; n];
146 log_prob_column(model.mechanisms.get(node), &y, parents, &mut lp)?;
147 for v in lp {
148 if v.is_finite() {
149 total += v;
150 count += 1;
151 }
152 }
153 }
154 Ok(total / count.max(1) as f64)
155}
156
157type ResidualByNode = Vec<Option<Vec<f64>>>;
158
159fn residual_summary(
160 model: &CompiledCausalModel,
161 data: &TabularData,
162) -> Result<(f64, ResidualByNode), ModelError> {
163 let n = data.row_count();
164 let mut residuals_by_node = vec![None; model.n_nodes()];
165 let mut abs_sum = 0.0;
166 let mut abs_count = 0usize;
167 let mut ws = MechanismWorkspace::default();
168 for gather in model.parent_gathers.iter() {
169 let node = gather.child;
170 let slot = model.mechanisms.get(node);
171 if !matches!(
172 slot,
173 MechanismSlot::LinearGaussian { .. }
174 | MechanismSlot::HierarchicalLinear { .. }
175 | MechanismSlot::Bvar { .. }
176 ) {
177 continue;
178 }
179 let var = model.output_layout.variables[node.as_usize()];
180 let y = data.float64_cow(var).map_err(ModelError::from)?;
181 ws.prepare(n, gather.n_parents().max(1));
182 let mut parent_mat = vec![0.0; n * gather.n_parents().max(1)];
183 for (pi, &p) in gather.parents.iter().enumerate() {
184 let pv = model.output_layout.variables[p.as_usize()];
185 let col = data.float64_cow(pv).map_err(ModelError::from)?;
186 parent_mat[pi * n..(pi + 1) * n].copy_from_slice(&col[..n]);
187 }
188 let parents = ParentBatch {
189 n_rows: n,
190 n_parents: gather.n_parents(),
191 values: &parent_mat[..gather.n_parents().saturating_mul(n)],
192 };
193 let mut noise = vec![0.0; n];
194 infer_noise_column(slot, &y, parents, &mut noise)?;
195 if gather.n_parents() > 0 {
205 for &e in &noise {
206 abs_sum += e.abs();
207 abs_count += 1;
208 }
209 }
210 residuals_by_node[node.as_usize()] = Some(noise);
211 }
212 Ok((abs_sum / abs_count.max(1) as f64, residuals_by_node))
213}
214
215fn residual_independence_tests(
216 model: &CompiledCausalModel,
217 data: &TabularData,
218 residuals: &[Option<Vec<f64>>],
219 _alpha: f64,
220 ctx: &ExecutionContext,
221) -> Result<Vec<f64>, ModelError> {
222 let test = PartialCorrelation::new();
223 let mut ws = CiWorkspace::default();
224 let n_nodes = model.n_nodes();
225 let children = child_adjacency(model);
226
227 let mut obs_store: Vec<Cow<'_, [f64]>> = Vec::with_capacity(n_nodes);
228 for i in 0..n_nodes {
229 let var = model.output_layout.variables[i];
230 obs_store.push(data.float64_cow(var).map_err(ModelError::from)?);
231 }
232 let mut cols: Vec<&[f64]> = obs_store.iter().map(std::convert::AsRef::as_ref).collect();
233 let mut resid_col = vec![None; n_nodes];
234 for (i, r) in residuals.iter().enumerate() {
235 if let Some(v) = r {
236 resid_col[i] = Some(cols.len());
237 cols.push(v.as_slice());
238 }
239 }
240
241 let mut queries = Vec::new();
242 for (node_i, resid_opt) in residuals.iter().enumerate() {
243 let Some(_) = resid_opt else { continue };
244 let Some(rx) = resid_col[node_i] else { continue };
245 let gather = model.gather_for(DenseNodeId::from_raw(node_i as u32)).unwrap();
246 let parent_set: HashSet<usize> = gather.parents.iter().map(|p| p.as_usize()).collect();
247 let descendants = descendants_of(&children, node_i);
248 for other in 0..n_nodes {
249 if other == node_i || parent_set.contains(&other) || descendants.contains(&other) {
252 continue;
253 }
254 queries.push(CiQuery { x: rx, y: other, z_start: 0, z_len: 0 });
255 }
256 }
257 ci_pvalues(&test, &cols, &queries, &[], &mut ws, ctx)
258}
259
260fn child_adjacency(model: &CompiledCausalModel) -> Vec<Vec<usize>> {
261 let mut children = vec![Vec::new(); model.n_nodes()];
262 for gather in model.parent_gathers.iter() {
263 let child = gather.child.as_usize();
264 for &p in gather.parents.iter() {
265 children[p.as_usize()].push(child);
266 }
267 }
268 children
269}
270
271fn descendants_of(children: &[Vec<usize>], node: usize) -> HashSet<usize> {
272 let mut out = HashSet::new();
273 let mut stack = children.get(node).cloned().unwrap_or_default();
274 while let Some(v) = stack.pop() {
275 if out.insert(v) {
276 stack.extend(children.get(v).into_iter().flatten().copied());
277 }
278 }
279 out
280}
281
282fn local_markov_tests(
283 model: &CompiledCausalModel,
284 data: &TabularData,
285 _alpha: f64,
286 ctx: &ExecutionContext,
287) -> Result<Vec<f64>, ModelError> {
288 let test = PartialCorrelation::new();
289 let mut ws = CiWorkspace::default();
290 let n_nodes = model.n_nodes();
291 let mut storage: Vec<Cow<'_, [f64]>> = Vec::with_capacity(n_nodes);
292 for i in 0..n_nodes {
293 let var = model.output_layout.variables[i];
294 storage.push(data.float64_cow(var).map_err(ModelError::from)?);
295 }
296 let cols: Vec<&[f64]> = storage.iter().map(std::convert::AsRef::as_ref).collect();
297
298 let mut queries = Vec::new();
299 let mut z_flat = Vec::new();
300 for gather in model.parent_gathers.iter() {
301 let node = gather.child;
302 let parent_ids: Vec<usize> = gather.parents.iter().map(|p| p.as_usize()).collect();
303 let others = local_markov_others(model, node, &parent_ids);
304 if others.is_empty() {
305 continue;
306 }
307 let z_start = z_flat.len();
308 z_flat.extend_from_slice(&parent_ids);
309 let z_len = parent_ids.len();
310 for other in others {
311 queries.push(CiQuery { x: node.as_usize(), y: other, z_start, z_len });
312 }
313 }
314 ci_pvalues(&test, &cols, &queries, &z_flat, &mut ws, ctx)
315}
316
317fn ci_pvalues(
318 test: &PartialCorrelation,
319 columns: &[&[f64]],
320 queries: &[CiQuery],
321 z_flat: &[usize],
322 ws: &mut CiWorkspace,
323 ctx: &ExecutionContext,
324) -> Result<Vec<f64>, ModelError> {
325 if queries.is_empty() {
326 return Ok(Vec::new());
327 }
328 let req = CiBatchRequest {
329 columns,
330 queries,
331 z_flat,
332 significance: SignificanceMethod::Analytic,
333 confidence: ConfidenceMethod::default(),
334 };
335 let out = test.test_batch_adhoc(&req, ws, ctx)?;
336 Ok(out.results.into_iter().map(|r| r.p_value).collect())
337}
338
339fn local_markov_others(
347 model: &CompiledCausalModel,
348 node: antecedent_graph::DenseNodeId,
349 parent_ids: &[usize],
350) -> Vec<usize> {
351 let mut out = Vec::new();
352 for &other in model.node_order.iter() {
353 if other == node {
354 break; }
356 let od = other.as_usize();
357 if !parent_ids.contains(&od) {
358 out.push(od);
359 }
360 }
361 out
362}
363
364fn permutation_baseline(
365 model: &CompiledCausalModel,
366 data: &TabularData,
367 n_perm: usize,
368 seed: u64,
369) -> Result<f64, ModelError> {
370 if n_perm == 0 {
371 return Ok(f64::NEG_INFINITY);
372 }
373 let mut rng = CausalRng::from_seed(seed);
374 let last = *model
377 .node_order
378 .last()
379 .ok_or_else(|| ModelError::Shape { message: "empty model".into() })?;
380 let var = model.output_layout.variables[last.as_usize()];
381 let mut y = data.float64_values(var).map_err(ModelError::from)?;
382 let mut acc = 0.0;
383 let gather = model.gather_for(last).unwrap();
386 let n = y.len();
387 let mut parent_mat = vec![0.0; n * gather.n_parents().max(1)];
388 for (pi, &p) in gather.parents.iter().enumerate() {
389 let pv = model.output_layout.variables[p.as_usize()];
390 let col = data.float64_cow(pv).map_err(ModelError::from)?;
391 parent_mat[pi * n..(pi + 1) * n].copy_from_slice(&col[..n]);
392 }
393 let mut lp = vec![0.0; n];
394 for _ in 0..n_perm {
395 for i in (1..y.len()).rev() {
397 let j = (rng.next_f64() * (i as f64 + 1.0)) as usize;
398 y.swap(i, j.min(i));
399 }
400 let parents = ParentBatch {
402 n_rows: n,
403 n_parents: gather.n_parents(),
404 values: &parent_mat[..gather.n_parents().saturating_mul(n)],
405 };
406 log_prob_column(model.mechanisms.get(last), &y, parents, &mut lp)?;
407 acc += lp.iter().filter(|v| v.is_finite()).sum::<f64>() / n.max(1) as f64;
408 }
409 Ok(acc / n_perm as f64)
410}
411
412#[derive(Clone, Debug)]
414pub struct MechanismPredictiveCheck {
415 pub n_sims: usize,
417 pub seed: u64,
419}
420
421impl Default for MechanismPredictiveCheck {
422 fn default() -> Self {
423 Self { n_sims: 50, seed: 1 }
424 }
425}
426
427impl MechanismPredictiveCheck {
428 pub fn check_mean(
434 &self,
435 model: &CompiledCausalModel,
436 data: &TabularData,
437 var: VariableId,
438 ) -> Result<(f64, f64, f64), ModelError> {
439 use crate::sample::sample_observational;
440 use antecedent_core::ExecutionContext;
441
442 let observed = data.float64_values(var).map_err(ModelError::from)?;
443 let obs_mean = observed.iter().sum::<f64>() / observed.len().max(1) as f64;
444 let dense = model
445 .dense_of(var)
446 .ok_or_else(|| ModelError::Shape { message: "variable not in model".into() })?;
447 let mut rng = CausalRng::from_seed(self.seed);
448 let mut ws = MechanismWorkspace::default();
449 let ctx = ExecutionContext::for_tests(1);
450 let mut means = Vec::with_capacity(self.n_sims);
451 for _ in 0..self.n_sims {
452 let batch = sample_observational(model, observed.len(), &mut rng, &mut ws, &ctx)?;
453 let col = batch.column(dense.as_usize())?;
454 means.push(col.iter().sum::<f64>() / col.len().max(1) as f64);
455 }
456 let pred_mean = means.iter().sum::<f64>() / means.len().max(1) as f64;
457 let n_sims = means.len() as f64;
461 let below = means.iter().filter(|&&m| m <= obs_mean).count() as f64;
462 let above = means.iter().filter(|&&m| m >= obs_mean).count() as f64;
463 let p_lower = (1.0 + below) / (1.0 + n_sims);
464 let p_upper = (1.0 + above) / (1.0 + n_sims);
465 let p = (2.0 * p_lower.min(p_upper)).min(1.0);
466 Ok((obs_mean, pred_mean, p))
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use crate::registry::{MechanismFamily, MechanismRegistry, SelectionPolicy};
474 use antecedent_core::{
475 CausalSchemaBuilder, MeasurementSpec, RoleHint, SmallRoleSet, ValueType,
476 };
477 use antecedent_data::column::{Float64Column, ValidityBitmap};
478 use antecedent_data::{OwnedColumn, OwnedColumnarStorage};
479 use antecedent_graph::Dag;
480
481 #[test]
482 fn local_markov_pairs_use_dense_ids_not_topo_positions() {
483 let mut g = Dag::with_variables(3);
488 g.insert_directed(DenseNodeId::from_raw(1), DenseNodeId::from_raw(0)).unwrap();
489 g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(2)).unwrap();
490 let compiled = CompiledCausalModel::compile(g).unwrap();
491 assert_eq!(
492 compiled.node_order.iter().map(|d| d.as_usize()).collect::<Vec<_>>(),
493 vec![1, 0, 2],
494 "fixture requires a non-identity topological order"
495 );
496 assert!(local_markov_others(&compiled, DenseNodeId::from_raw(0), &[1]).is_empty());
499 assert_eq!(local_markov_others(&compiled, DenseNodeId::from_raw(2), &[0]), vec![1]);
501 assert!(local_markov_others(&compiled, DenseNodeId::from_raw(1), &[]).is_empty());
503 }
504
505 #[test]
506 fn evaluation_runs_on_linear_scm() {
507 let n = 40usize;
508 let mut b = CausalSchemaBuilder::new();
509 b.add_variable(
510 "x",
511 ValueType::Continuous,
512 SmallRoleSet::from_hint(RoleHint::Context),
513 None,
514 None,
515 MeasurementSpec::default(),
516 )
517 .unwrap();
518 b.add_variable(
519 "y",
520 ValueType::Continuous,
521 SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
522 None,
523 None,
524 MeasurementSpec::default(),
525 )
526 .unwrap();
527 let schema = b.build().unwrap();
528 let xv: Vec<f64> = (0..n).map(|i| i as f64 * 0.1).collect();
529 let yv: Vec<f64> = xv.iter().map(|x| 1.0 + 2.0 * x).collect();
530 let validity = ValidityBitmap::all_valid(n);
531 let cols = vec![
532 OwnedColumn::Float64(
533 Float64Column::new(VariableId::from_raw(0), Arc::from(xv), validity.clone())
534 .unwrap(),
535 ),
536 OwnedColumn::Float64(
537 Float64Column::new(VariableId::from_raw(1), Arc::from(yv), validity).unwrap(),
538 ),
539 ];
540 let data =
541 TabularData::new(OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap());
542 let mut g = Dag::with_variables(2);
543 g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
544 let compiled = CompiledCausalModel::compile(g).unwrap();
545 let (store, _) = MechanismRegistry::standard()
546 .assign_and_fit(&compiled, &data, SelectionPolicy::BestScore)
547 .unwrap();
548 let model = compiled.with_mechanisms(store);
549 let rep = ModelEvaluator::default()
550 .evaluate(&model, &data, &ExecutionContext::for_tests(1))
551 .unwrap();
552 assert!(rep.in_sample_loglik.is_finite());
553 assert!(rep.mean_abs_residual < 1e-6, "resid={}", rep.mean_abs_residual);
554 }
555
556 #[test]
563 #[allow(clippy::many_single_char_names)]
564 fn evaluate_lgssm_model_loglik_uses_predictive_mean_not_raw_value() {
565 use antecedent_core::CausalRng;
566 use antecedent_kernels::standard_normal;
567
568 let n = 60usize;
569 let a = 0.95_f64;
570 let process_std = 0.05_f64;
571 let obs_std = 0.05_f64;
572 let initial_mean = 5.0_f64;
573 let mut rng = CausalRng::from_seed(3);
574 let mut yv = vec![0.0; n];
575 let mut x = initial_mean;
576 for i in 0..n {
577 x = if i == 0 {
578 initial_mean + process_std * standard_normal(&mut rng)
579 } else {
580 a * x + process_std * standard_normal(&mut rng)
581 };
582 yv[i] = x + obs_std * standard_normal(&mut rng);
583 }
584
585 let mut b = CausalSchemaBuilder::new();
586 b.add_variable(
587 "y",
588 ValueType::Continuous,
589 SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
590 None,
591 None,
592 MeasurementSpec::default(),
593 )
594 .unwrap();
595 let schema = b.build().unwrap();
596 let validity = ValidityBitmap::all_valid(n);
597 let cols = vec![OwnedColumn::Float64(
598 Float64Column::new(VariableId::from_raw(0), Arc::from(yv), validity).unwrap(),
599 )];
600 let data =
601 TabularData::new(OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap());
602 let compiled = CompiledCausalModel::compile(Dag::with_variables(1)).unwrap();
603 let (store, _) = MechanismRegistry::with_bayesian_families()
604 .assign_and_fit(
605 &compiled,
606 &data,
607 SelectionPolicy::RequireFamily(MechanismFamily::LinearGaussianStateSpace),
608 )
609 .unwrap();
610 let model = compiled.with_mechanisms(store);
611 let rep = ModelEvaluator::default()
612 .evaluate(&model, &data, &ExecutionContext::for_tests(1))
613 .unwrap();
614 assert!(rep.in_sample_loglik.is_finite());
615
616 let MechanismSlot::LinearGaussianStateSpace { obs_std: fitted_obs_std, .. } =
617 model.mechanisms.get(DenseNodeId::from_raw(0))
618 else {
619 panic!("expected LGSSM slot");
620 };
621 let y = data.float64_values(VariableId::from_raw(0)).unwrap();
622 let inv_s = 1.0 / fitted_obs_std;
623 let log_norm = -0.5 * (2.0 * std::f64::consts::PI).ln() - fitted_obs_std.ln();
624 let raw_value_loglik: f64 = y
625 .iter()
626 .map(|yi| {
627 let z = yi * inv_s;
628 log_norm - 0.5 * z * z
629 })
630 .sum::<f64>()
631 / y.len() as f64;
632 assert!(
633 rep.in_sample_loglik > raw_value_loglik + 10.0,
634 "fixed loglik={} did not clearly beat raw-value (pre-fix) loglik={}",
635 rep.in_sample_loglik,
636 raw_value_loglik
637 );
638 }
639
640 #[test]
645 fn mechanism_predictive_check_p_value_never_zero_for_extreme_outlier() {
646 let n = 30usize;
647 let mut b = CausalSchemaBuilder::new();
648 b.add_variable(
649 "y",
650 ValueType::Continuous,
651 SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
652 None,
653 None,
654 MeasurementSpec::default(),
655 )
656 .unwrap();
657 let schema = b.build().unwrap();
658 let yv: Vec<f64> = (0..n).map(|i| 0.01 * (i as f64 - n as f64 / 2.0)).collect();
659 let validity = ValidityBitmap::all_valid(n);
660 let cols = vec![OwnedColumn::Float64(
661 Float64Column::new(VariableId::from_raw(0), Arc::from(yv), validity.clone()).unwrap(),
662 )];
663 let storage = OwnedColumnarStorage::try_new(schema.clone(), cols, None, None).unwrap();
664 let data = TabularData::new(storage);
665 let compiled = CompiledCausalModel::compile(Dag::with_variables(1)).unwrap();
666 let (store, _) = MechanismRegistry::standard()
667 .assign_and_fit(
668 &compiled,
669 &data,
670 SelectionPolicy::RequireFamily(MechanismFamily::LinearGaussian),
671 )
672 .unwrap();
673 let model = compiled.with_mechanisms(store);
674
675 let outlier: Vec<f64> = vec![1000.0; n];
676 let outlier_cols = vec![OwnedColumn::Float64(
677 Float64Column::new(VariableId::from_raw(0), Arc::from(outlier), validity).unwrap(),
678 )];
679 let outlier_storage =
680 OwnedColumnarStorage::try_new(schema, outlier_cols, None, None).unwrap();
681 let outlier_data = TabularData::new(outlier_storage);
682
683 let check = MechanismPredictiveCheck::default();
684 let (obs_mean, _pred_mean, p) =
685 check.check_mean(&model, &outlier_data, VariableId::from_raw(0)).unwrap();
686 assert!((obs_mean - 1000.0).abs() < 1e-9);
687 assert!(p > 0.0, "p must never be exactly 0 (finite-sample bound 2/(n_sims+1)); got {p}");
688 }
689}