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::sync::Arc;
13
14use antecedent_core::{CausalRng, ExecutionContext, VariableId};
15use antecedent_data::{TableView, TabularData};
16use antecedent_graph::DenseNodeId;
17use antecedent_stats::ci::{CiWorkspace, PartialCorrelation, SignificanceMethod};
18
19use crate::batch::{MechanismWorkspace, ParentBatch};
20use crate::compile::{CompiledCausalModel, MechanismSlot};
21use crate::error::ModelError;
22use crate::mechanism::{infer_noise_column, log_prob_column};
23
24#[derive(Clone, Debug)]
26pub struct ModelEvaluationReport {
27 pub in_sample_loglik: f64,
29 pub mean_abs_residual: f64,
31 pub residual_independence_p: Arc<[f64]>,
33 pub local_markov_p: Arc<[f64]>,
35 pub permutation_loglik: f64,
37 pub falsified: bool,
39 pub alpha: f64,
41 pub notes: Vec<Arc<str>>,
43}
44
45#[derive(Clone, Debug)]
47pub struct ModelEvaluator {
48 pub alpha: f64,
50 pub n_permutations: usize,
52 pub seed: u64,
54}
55
56impl Default for ModelEvaluator {
57 fn default() -> Self {
58 Self { alpha: 0.05, n_permutations: 20, seed: 0 }
59 }
60}
61
62impl ModelEvaluator {
63 pub fn evaluate(
69 &self,
70 model: &CompiledCausalModel,
71 data: &TabularData,
72 ctx: &ExecutionContext,
73 ) -> Result<ModelEvaluationReport, ModelError> {
74 let n = data.row_count();
75 if n == 0 {
76 return Err(ModelError::Shape { message: "empty data for evaluation".into() });
77 }
78 let mut notes = Vec::new();
79 let in_sample_loglik = mean_loglik(model, data)?;
80 let (mean_abs_residual, residuals_by_node) = residual_summary(model, data)?;
81 let residual_independence_p =
82 residual_independence_tests(model, data, &residuals_by_node, self.alpha, ctx)?;
83 let local_markov_p = local_markov_tests(model, data, self.alpha, ctx)?;
84 let perm_seed = if self.seed == 0 { ctx.rng.master_seed() } else { self.seed };
86 let permutation_loglik = permutation_baseline(model, data, self.n_permutations, perm_seed)?;
87
88 let mut falsified = false;
89 for &p in &residual_independence_p {
90 if p < self.alpha {
91 falsified = true;
92 notes.push(Arc::from("residual independence rejected at alpha"));
93 break;
94 }
95 }
96 for &p in &local_markov_p {
97 if p < self.alpha {
98 falsified = true;
99 notes.push(Arc::from("local Markov condition rejected at alpha"));
100 break;
101 }
102 }
103 if in_sample_loglik + 1.0 < permutation_loglik {
104 notes.push(Arc::from("in-sample loglik near or below permutation baseline"));
106 }
107
108 Ok(ModelEvaluationReport {
109 in_sample_loglik,
110 mean_abs_residual,
111 residual_independence_p: Arc::from(residual_independence_p),
112 local_markov_p: Arc::from(local_markov_p),
113 permutation_loglik,
114 falsified,
115 alpha: self.alpha,
116 notes,
117 })
118 }
119}
120
121fn mean_loglik(model: &CompiledCausalModel, data: &TabularData) -> Result<f64, ModelError> {
122 let n = data.row_count();
123 let mut total = 0.0;
124 let mut count = 0usize;
125 for gather in model.parent_gathers.iter() {
126 let node = gather.child;
127 let var = model.output_layout.variables[node.as_usize()];
128 let y = data.float64_values(var).map_err(ModelError::from)?;
129 let mut parent_mat = vec![0.0; n * gather.n_parents().max(1)];
130 for (pi, &p) in gather.parents.iter().enumerate() {
131 let pv = model.output_layout.variables[p.as_usize()];
132 let col = data.float64_values(pv).map_err(ModelError::from)?;
133 parent_mat[pi * n..(pi + 1) * n].copy_from_slice(&col[..n]);
134 }
135 let parents = ParentBatch {
136 n_rows: n,
137 n_parents: gather.n_parents(),
138 values: &parent_mat[..gather.n_parents().saturating_mul(n)],
139 };
140 let mut lp = vec![0.0; n];
141 log_prob_column(model.mechanisms.get(node), &y, parents, &mut lp)?;
142 for v in lp {
143 if v.is_finite() {
144 total += v;
145 count += 1;
146 }
147 }
148 }
149 Ok(total / count.max(1) as f64)
150}
151
152type ResidualByNode = Vec<Option<Vec<f64>>>;
153
154fn residual_summary(
155 model: &CompiledCausalModel,
156 data: &TabularData,
157) -> Result<(f64, ResidualByNode), ModelError> {
158 let n = data.row_count();
159 let mut residuals_by_node = vec![None; model.n_nodes()];
160 let mut abs_sum = 0.0;
161 let mut abs_count = 0usize;
162 let mut ws = MechanismWorkspace::default();
163 for gather in model.parent_gathers.iter() {
164 let node = gather.child;
165 let slot = model.mechanisms.get(node);
166 if !matches!(
167 slot,
168 MechanismSlot::LinearGaussian { .. }
169 | MechanismSlot::HierarchicalLinear { .. }
170 | MechanismSlot::Bvar { .. }
171 ) {
172 continue;
173 }
174 let var = model.output_layout.variables[node.as_usize()];
175 let y = data.float64_values(var).map_err(ModelError::from)?;
176 ws.prepare(n, gather.n_parents().max(1));
177 let mut parent_mat = vec![0.0; n * gather.n_parents().max(1)];
178 for (pi, &p) in gather.parents.iter().enumerate() {
179 let pv = model.output_layout.variables[p.as_usize()];
180 let col = data.float64_values(pv).map_err(ModelError::from)?;
181 parent_mat[pi * n..(pi + 1) * n].copy_from_slice(&col[..n]);
182 }
183 let parents = ParentBatch {
184 n_rows: n,
185 n_parents: gather.n_parents(),
186 values: &parent_mat[..gather.n_parents().saturating_mul(n)],
187 };
188 let mut noise = vec![0.0; n];
189 infer_noise_column(slot, &y, parents, &mut noise)?;
190 for &e in &noise {
191 abs_sum += e.abs();
192 abs_count += 1;
193 }
194 residuals_by_node[node.as_usize()] = Some(noise);
195 }
196 Ok((abs_sum / abs_count.max(1) as f64, residuals_by_node))
197}
198
199fn residual_independence_tests(
200 model: &CompiledCausalModel,
201 data: &TabularData,
202 residuals: &[Option<Vec<f64>>],
203 _alpha: f64,
204 ctx: &ExecutionContext,
205) -> Result<Vec<f64>, ModelError> {
206 let mut ps = Vec::new();
207 let test = PartialCorrelation::new();
208 let mut ws = CiWorkspace::default();
209 let children = child_adjacency(model);
210 for (node_i, resid_opt) in residuals.iter().enumerate() {
211 let Some(resid) = resid_opt else { continue };
212 let gather = model.gather_for(DenseNodeId::from_raw(node_i as u32)).unwrap();
213 let parent_set: std::collections::HashSet<usize> =
214 gather.parents.iter().map(|p| p.as_usize()).collect();
215 let descendants = descendants_of(&children, node_i);
216 for other in 0..model.n_nodes() {
217 if other == node_i || parent_set.contains(&other) || descendants.contains(&other) {
220 continue;
221 }
222 let ovar = model.output_layout.variables[other];
223 let x = data.float64_values(ovar).map_err(ModelError::from)?;
224 let cols: [&[f64]; 2] = [resid.as_slice(), x.as_slice()];
225 let res = test
226 .test_one(&cols, &[], SignificanceMethod::Analytic, &mut ws, ctx)
227 .map_err(ModelError::from)?;
228 ps.push(res.p_value);
229 }
230 }
231 Ok(ps)
232}
233
234fn child_adjacency(model: &CompiledCausalModel) -> Vec<Vec<usize>> {
235 let mut children = vec![Vec::new(); model.n_nodes()];
236 for gather in model.parent_gathers.iter() {
237 let child = gather.child.as_usize();
238 for &p in gather.parents.iter() {
239 children[p.as_usize()].push(child);
240 }
241 }
242 children
243}
244
245fn descendants_of(children: &[Vec<usize>], node: usize) -> std::collections::HashSet<usize> {
246 let mut out = std::collections::HashSet::new();
247 let mut stack = children.get(node).cloned().unwrap_or_default();
248 while let Some(v) = stack.pop() {
249 if out.insert(v) {
250 stack.extend(children.get(v).into_iter().flatten().copied());
251 }
252 }
253 out
254}
255
256fn local_markov_tests(
257 model: &CompiledCausalModel,
258 data: &TabularData,
259 _alpha: f64,
260 ctx: &ExecutionContext,
261) -> Result<Vec<f64>, ModelError> {
262 let test = PartialCorrelation::new();
263 let mut ws = CiWorkspace::default();
264 let mut ps = Vec::new();
265 for gather in model.parent_gathers.iter() {
266 let node = gather.child;
267 let var = model.output_layout.variables[node.as_usize()];
268 let y = data.float64_values(var).map_err(ModelError::from)?;
269 let parent_ids: Vec<usize> = gather.parents.iter().map(|p| p.as_usize()).collect();
270 let node_pos = model.node_order.iter().position(|d| *d == node).unwrap_or(0);
271 for (oi, _) in model.node_order.iter().enumerate() {
272 if oi >= node_pos || parent_ids.contains(&oi) {
273 continue;
274 }
275 let ovar = model.output_layout.variables[oi];
276 let x = data.float64_values(ovar).map_err(ModelError::from)?;
277 let mut cols: Vec<&[f64]> = vec![y.as_slice(), x.as_slice()];
278 let mut cond_storage: Vec<Vec<f64>> = Vec::new();
279 for &p in &parent_ids {
280 let pv = model.output_layout.variables[p];
281 cond_storage.push(data.float64_values(pv).map_err(ModelError::from)?);
282 }
283 for c in &cond_storage {
284 cols.push(c.as_slice());
285 }
286 let z: Vec<usize> = (2..cols.len()).collect();
287 let res = test
288 .test_one(&cols, &z, SignificanceMethod::Analytic, &mut ws, ctx)
289 .map_err(ModelError::from)?;
290 ps.push(res.p_value);
291 }
292 let _ = var;
293 }
294 Ok(ps)
295}
296
297fn permutation_baseline(
298 model: &CompiledCausalModel,
299 data: &TabularData,
300 n_perm: usize,
301 seed: u64,
302) -> Result<f64, ModelError> {
303 if n_perm == 0 {
304 return Ok(f64::NEG_INFINITY);
305 }
306 let mut rng = CausalRng::from_seed(seed);
307 let last = *model
310 .node_order
311 .last()
312 .ok_or_else(|| ModelError::Shape { message: "empty model".into() })?;
313 let var = model.output_layout.variables[last.as_usize()];
314 let mut y = data.float64_values(var).map_err(ModelError::from)?;
315 let mut acc = 0.0;
316 for _ in 0..n_perm {
317 for i in (1..y.len()).rev() {
319 let j = (rng.next_f64() * (i as f64 + 1.0)) as usize;
320 y.swap(i, j.min(i));
321 }
322 let gather = model.gather_for(last).unwrap();
324 let n = y.len();
325 let mut parent_mat = vec![0.0; n * gather.n_parents().max(1)];
326 for (pi, &p) in gather.parents.iter().enumerate() {
327 let pv = model.output_layout.variables[p.as_usize()];
328 let col = data.float64_values(pv).map_err(ModelError::from)?;
329 parent_mat[pi * n..(pi + 1) * n].copy_from_slice(&col[..n]);
330 }
331 let parents = ParentBatch {
332 n_rows: n,
333 n_parents: gather.n_parents(),
334 values: &parent_mat[..gather.n_parents().saturating_mul(n)],
335 };
336 let mut lp = vec![0.0; n];
337 log_prob_column(model.mechanisms.get(last), &y, parents, &mut lp)?;
338 acc += lp.iter().filter(|v| v.is_finite()).sum::<f64>() / n.max(1) as f64;
339 }
340 Ok(acc / n_perm as f64)
341}
342
343#[derive(Clone, Debug)]
345pub struct MechanismPredictiveCheck {
346 pub n_sims: usize,
348 pub seed: u64,
350}
351
352impl Default for MechanismPredictiveCheck {
353 fn default() -> Self {
354 Self { n_sims: 50, seed: 1 }
355 }
356}
357
358impl MechanismPredictiveCheck {
359 pub fn check_mean(
365 &self,
366 model: &CompiledCausalModel,
367 data: &TabularData,
368 var: VariableId,
369 ) -> Result<(f64, f64, f64), ModelError> {
370 use crate::sample::sample_observational;
371 use antecedent_core::ExecutionContext;
372
373 let observed = data.float64_values(var).map_err(ModelError::from)?;
374 let obs_mean = observed.iter().sum::<f64>() / observed.len().max(1) as f64;
375 let dense = model
376 .dense_of(var)
377 .ok_or_else(|| ModelError::Shape { message: "variable not in model".into() })?;
378 let mut rng = CausalRng::from_seed(self.seed);
379 let mut ws = MechanismWorkspace::default();
380 let ctx = ExecutionContext::for_tests(1);
381 let mut means = Vec::with_capacity(self.n_sims);
382 for _ in 0..self.n_sims {
383 let batch = sample_observational(model, observed.len(), &mut rng, &mut ws, &ctx)?;
384 let col = batch.column(dense.as_usize())?;
385 means.push(col.iter().sum::<f64>() / col.len().max(1) as f64);
386 }
387 let pred_mean = means.iter().sum::<f64>() / means.len().max(1) as f64;
388 let below = means.iter().filter(|&&m| m <= obs_mean).count() as f64;
389 let p = (2.0
390 * (below / means.len().max(1) as f64).min(1.0 - below / means.len().max(1) as f64))
391 .min(1.0);
392 Ok((obs_mean, pred_mean, p))
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use crate::registry::{MechanismRegistry, SelectionPolicy};
400 use antecedent_core::{
401 CausalSchemaBuilder, MeasurementSpec, RoleHint, SmallRoleSet, ValueType,
402 };
403 use antecedent_data::column::{Float64Column, ValidityBitmap};
404 use antecedent_data::{OwnedColumn, OwnedColumnarStorage};
405 use antecedent_graph::Dag;
406
407 #[test]
408 fn evaluation_runs_on_linear_scm() {
409 let n = 40usize;
410 let mut b = CausalSchemaBuilder::new();
411 b.add_variable(
412 "x",
413 ValueType::Continuous,
414 SmallRoleSet::from_hint(RoleHint::Context),
415 None,
416 None,
417 MeasurementSpec::default(),
418 )
419 .unwrap();
420 b.add_variable(
421 "y",
422 ValueType::Continuous,
423 SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
424 None,
425 None,
426 MeasurementSpec::default(),
427 )
428 .unwrap();
429 let schema = b.build().unwrap();
430 let xv: Vec<f64> = (0..n).map(|i| i as f64 * 0.1).collect();
431 let yv: Vec<f64> = xv.iter().map(|x| 1.0 + 2.0 * x).collect();
432 let validity = ValidityBitmap::all_valid(n);
433 let cols = vec![
434 OwnedColumn::Float64(
435 Float64Column::new(VariableId::from_raw(0), Arc::from(xv), validity.clone())
436 .unwrap(),
437 ),
438 OwnedColumn::Float64(
439 Float64Column::new(VariableId::from_raw(1), Arc::from(yv), validity).unwrap(),
440 ),
441 ];
442 let data =
443 TabularData::new(OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap());
444 let mut g = Dag::with_variables(2);
445 g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
446 let compiled = CompiledCausalModel::compile(g).unwrap();
447 let (store, _) = MechanismRegistry::standard()
448 .assign_and_fit(&compiled, &data, SelectionPolicy::BestScore)
449 .unwrap();
450 let model = compiled.with_mechanisms(store);
451 let rep = ModelEvaluator::default()
452 .evaluate(&model, &data, &ExecutionContext::for_tests(1))
453 .unwrap();
454 assert!(rep.in_sample_loglik.is_finite());
455 assert!(rep.mean_abs_residual < 1e-6, "resid={}", rep.mean_abs_residual);
456 }
457}