Skip to main content

antecedent_model/
evaluate.rs

1//! Model evaluation and falsification.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5#![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/// Model falsification / evaluation report.
25#[derive(Clone, Debug)]
26pub struct ModelEvaluationReport {
27    /// In-sample mean log-likelihood (higher better). No holdout split is performed.
28    pub in_sample_loglik: f64,
29    /// Mean absolute residual for invertible nodes.
30    pub mean_abs_residual: f64,
31    /// Residual independence p-values vs non-parent covariates (empty if none).
32    pub residual_independence_p: Arc<[f64]>,
33    /// Local Markov check p-values (node ⊥ non-descendants | parents).
34    pub local_markov_p: Arc<[f64]>,
35    /// Permutation baseline mean log-lik under shuffled outcomes.
36    pub permutation_loglik: f64,
37    /// Whether the model is considered falsified under alpha.
38    pub falsified: bool,
39    /// Alpha used for independence tests.
40    pub alpha: f64,
41    /// Notes.
42    pub notes: Vec<Arc<str>>,
43}
44
45/// Evaluate a fitted model on data.
46#[derive(Clone, Debug)]
47pub struct ModelEvaluator {
48    /// Significance level for CI tests.
49    pub alpha: f64,
50    /// Permutation replicates for baseline.
51    pub n_permutations: usize,
52    /// RNG seed for permutations.
53    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    /// Run evaluation / falsification suite.
64    ///
65    /// # Errors
66    ///
67    /// Data / mechanism failures.
68    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        // Prefer the caller's execution seed when the evaluator still has the default seed.
85        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            // Model worse than noise baseline by a wide margin.
105            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            // ANM residuals are independent of non-descendants (parents already skipped).
218            // Dependence on descendants is expected and must not falsify a correct model.
219            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    // Permute a leaf outcome column and recompute mean loglik under original mechanisms
308    // as a crude noise baseline (same X, shuffled Y for last node).
309    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        // Fisher–Yates
318        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        // Score only the last node under shuffled y.
323        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/// Mechanism predictive check: compare observed mean to predictive mean under sampling.
344#[derive(Clone, Debug)]
345pub struct MechanismPredictiveCheck {
346    /// Sims.
347    pub n_sims: usize,
348    /// Seed.
349    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    /// Check one variable's mean.
360    ///
361    /// # Errors
362    ///
363    /// Sampling failures.
364    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}