1#![allow(
8 clippy::cast_possible_truncation,
9 clippy::cast_precision_loss,
10 clippy::cast_sign_loss,
11 clippy::field_reassign_with_default,
12 clippy::float_cmp,
13 clippy::manual_let_else,
14 clippy::many_single_char_names,
15 clippy::needless_range_loop,
16 clippy::too_many_lines
17)]
18
19use std::sync::Arc;
20
21use antecedent_core::{RoleHint, VariableId};
22use antecedent_data::{TableView, TabularData};
23use antecedent_graph::DenseNodeId;
24use antecedent_stats::{
25 DenseLinearAlgebra, FaerBackend, GlmDesignRef, GlmFamily, GlmOptions, LeastSquaresWorkspace,
26 MultinomialDesignRef, fit_glm_ridge, fit_multinomial_logit,
27};
28#[cfg(feature = "gaussian-process")]
29use antecedent_stats::{chol_log_det, chol_solve, cholesky_spd};
30
31use crate::batch::ParentBatch;
32use crate::compile::{
33 CompiledCausalModel, CompiledMechanismStore, MechanismSlot, ParentGatherPlan,
34};
35use crate::error::ModelError;
36use crate::mechanism::{gp_predictive_mean_column, log_prob_column};
37
38#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
40pub enum MechanismFamily {
41 LinearGaussian,
43 Constant,
45 Discrete,
47 HierarchicalLinear,
49 HierarchicalGlm,
51 Bvar,
53 LinearGaussianStateSpace,
55 GaussianProcess,
57}
58
59impl MechanismFamily {
60 #[must_use]
62 pub const fn id(self) -> &'static str {
63 mechanism_family_id(self)
64 }
65}
66
67#[derive(Clone, Debug)]
69pub struct MechanismCandidate {
70 pub family: MechanismFamily,
72 pub score: f64,
74 pub fit_cost: f64,
76 pub eval_cost: f64,
78}
79
80#[derive(Clone, Debug)]
82pub struct MechanismAssignment {
83 pub node: DenseNodeId,
85 pub variable: VariableId,
87 pub candidates: Arc<[MechanismCandidate]>,
89 pub selected: MechanismFamily,
91 pub fitted: MechanismSlot,
93 pub failed_families: Arc<[(MechanismFamily, String)]>,
95}
96
97#[derive(Clone, Debug)]
99pub struct MechanismRegistry {
100 pub continuous: Arc<[MechanismFamily]>,
102 pub discrete: Arc<[MechanismFamily]>,
104}
105
106impl Default for MechanismRegistry {
107 fn default() -> Self {
108 Self::standard()
109 }
110}
111
112const fn mechanism_family_id(family: MechanismFamily) -> &'static str {
119 match family {
120 MechanismFamily::LinearGaussian => "linear_gaussian",
121 MechanismFamily::Constant => "constant",
122 MechanismFamily::Discrete => "discrete",
123 MechanismFamily::HierarchicalLinear => "hierarchical_linear",
124 MechanismFamily::HierarchicalGlm => "hierarchical_glm",
125 MechanismFamily::Bvar => "bvar",
126 MechanismFamily::LinearGaussianStateSpace => "lgssm",
127 MechanismFamily::GaussianProcess => "gaussian_process",
128 }
129}
130
131impl MechanismRegistry {
132 #[must_use]
134 pub fn standard() -> Self {
135 Self {
136 continuous: Arc::from(vec![MechanismFamily::LinearGaussian, MechanismFamily::Constant]),
137 discrete: Arc::from(vec![MechanismFamily::Discrete, MechanismFamily::Constant]),
138 }
139 }
140
141 #[must_use]
143 pub fn with_bayesian_families() -> Self {
144 #[cfg(feature = "gaussian-process")]
145 let continuous = {
146 let mut continuous = vec![
147 MechanismFamily::LinearGaussian,
148 MechanismFamily::HierarchicalLinear,
149 MechanismFamily::Bvar,
150 MechanismFamily::LinearGaussianStateSpace,
151 MechanismFamily::Constant,
152 ];
153 continuous.insert(continuous.len() - 1, MechanismFamily::GaussianProcess);
154 continuous
155 };
156 #[cfg(not(feature = "gaussian-process"))]
157 let continuous = vec![
158 MechanismFamily::LinearGaussian,
159 MechanismFamily::HierarchicalLinear,
160 MechanismFamily::Bvar,
161 MechanismFamily::LinearGaussianStateSpace,
162 MechanismFamily::Constant,
163 ];
164 let discrete = vec![
165 MechanismFamily::Discrete,
166 MechanismFamily::HierarchicalGlm,
167 MechanismFamily::Constant,
168 ];
169 Self { continuous: Arc::from(continuous), discrete: Arc::from(discrete) }
170 }
171
172 pub fn assign_and_fit(
178 &self,
179 model: &CompiledCausalModel,
180 data: &TabularData,
181 policy: SelectionPolicy,
182 ) -> Result<(CompiledMechanismStore, Vec<MechanismAssignment>), ModelError> {
183 let n = model.n_nodes();
184 let nrows = data.row_count();
185 if nrows == 0 {
186 return Err(ModelError::Shape { message: "empty data for mechanism fit".into() });
187 }
188 let mut slots = vec![MechanismSlot::Vacant; n];
189 let mut assignments = Vec::with_capacity(n);
190 let backend = FaerBackend;
191 let mut ls_ws = LeastSquaresWorkspace::default();
192
193 for gather in model.parent_gathers.iter() {
194 let node = gather.child;
195 let var = model.output_layout.variables[node.as_usize()];
196 let y = data.float64_cow(var).map_err(ModelError::from)?;
197 let is_discrete = is_low_cardinality(&y, 8);
198 let families: &[MechanismFamily] =
199 if is_discrete { &self.discrete } else { &self.continuous };
200
201 let mut candidates = Vec::new();
202 let mut fits: Vec<(MechanismFamily, MechanismSlot)> = Vec::new();
203 let mut failed = Vec::new();
204 for &family in families {
205 match score_family(family, gather, model, data, &y, backend, &mut ls_ws) {
206 Ok((c, slot)) => {
207 candidates.push(c);
208 fits.push((family, slot));
209 }
210 Err(e) => failed.push((family, e.to_string())),
211 }
212 }
213 if candidates.is_empty() {
214 let detail = failed
215 .iter()
216 .map(|(f, e)| format!("{f:?}: {e}"))
217 .collect::<Vec<_>>()
218 .join("; ");
219 return Err(ModelError::Unsupported {
220 message: format!(
221 "no mechanism candidates for variable {var} (failures: {detail})"
222 ),
223 });
224 }
225 candidates
226 .sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
227 let selected = policy.select(&candidates).ok_or_else(|| ModelError::Unsupported {
228 message: "selection policy produced no family".into(),
229 })?;
230 let fitted = fits
233 .into_iter()
234 .find_map(|(family, slot)| (family == selected).then_some(slot))
235 .ok_or_else(|| ModelError::Unsupported {
236 message: "selected family missing a retained fit".into(),
237 })?;
238 slots[node.as_usize()] = fitted.clone();
239 assignments.push(MechanismAssignment {
240 node,
241 variable: var,
242 candidates: Arc::from(candidates),
243 selected,
244 fitted,
245 failed_families: Arc::from(failed),
246 });
247 }
248
249 Ok((CompiledMechanismStore { slots: Arc::from(slots) }, assignments))
250 }
251}
252
253#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
255pub enum SelectionPolicy {
256 BestScore,
258 RequireFamily(MechanismFamily),
260}
261
262impl SelectionPolicy {
263 #[must_use]
265 pub fn select(self, candidates: &[MechanismCandidate]) -> Option<MechanismFamily> {
266 match self {
267 Self::BestScore => candidates.first().map(|c| c.family),
268 Self::RequireFamily(fam) => {
269 candidates.iter().find(|c| c.family == fam).map(|c| c.family)
270 }
271 }
272 }
273}
274
275fn is_low_cardinality(y: &[f64], max_levels: usize) -> bool {
276 let mut seen: Vec<i64> = Vec::with_capacity(max_levels.min(64));
281 for v in y.iter().filter(|v| v.is_finite()) {
282 let key = (v * 1e6).round() as i64;
283 if !seen.contains(&key) {
284 if seen.len() == max_levels {
285 return false;
286 }
287 seen.push(key);
288 }
289 }
290 !seen.is_empty()
291}
292
293const CONSTANT_FAMILY_MAX_VARIANCE: f64 = 1e-12;
300
301fn score_family(
302 family: MechanismFamily,
303 gather: &ParentGatherPlan,
304 model: &CompiledCausalModel,
305 data: &TabularData,
306 y: &[f64],
307 backend: FaerBackend,
308 ls_ws: &mut LeastSquaresWorkspace,
309) -> Result<(MechanismCandidate, MechanismSlot), ModelError> {
310 let fitted = fit_family(family, gather, model, data, y, backend, ls_ws)?;
311 let score = match &fitted {
312 MechanismSlot::LinearGaussian { intercept, coeffs, sigma }
313 | MechanismSlot::HierarchicalLinear { intercept, coeffs, sigma, .. }
314 | MechanismSlot::Bvar { intercept, coeffs, sigma } => {
315 let mse = residual_mse(gather, model, data, y, *intercept, coeffs)?;
316 -mse - sigma.ln().abs() * 0.01
317 }
318 MechanismSlot::Constant { value } => {
319 let mse = y.iter().map(|yi| (yi - value).powi(2)).sum::<f64>() / y.len().max(1) as f64;
320 if mse > CONSTANT_FAMILY_MAX_VARIANCE { f64::NEG_INFINITY } else { -mse }
336 }
337 MechanismSlot::Discrete { support, probs, logit_coeffs } => match logit_coeffs {
338 None => {
339 let ent: f64 = probs.iter().map(|p| if *p > 0.0 { -p * p.ln() } else { 0.0 }).sum();
340 -ent
341 }
342 Some(logits) => discrete_mean_loglik(gather, model, data, y, support, logits)?,
343 },
344 MechanismSlot::LinearGaussianStateSpace { a, process_std, obs_std, initial_mean } => {
345 let q = (process_std * process_std).max(1e-16);
349 let r_var = (obs_std * obs_std).max(1e-16);
350 let (_, _, x_pred, _) = crate::lgssm::kalman_filter(y, *a, q, r_var, *initial_mean, q);
351 let mse = y.iter().zip(x_pred.iter()).map(|(yi, xp)| (yi - xp).powi(2)).sum::<f64>()
352 / y.len().max(1) as f64;
353 -mse - (process_std + obs_std).ln().abs() * 0.01
354 }
355 MechanismSlot::GaussianProcess {
356 length_scale,
357 variance,
358 noise_std,
359 x_train,
360 n_train,
361 n_parents,
362 alpha,
363 } => {
364 let mse = gp_residual_mse(
365 gather,
366 model,
367 data,
368 y,
369 *length_scale,
370 *variance,
371 x_train,
372 *n_train,
373 *n_parents,
374 alpha,
375 )?;
376 -mse - noise_std.ln().abs() * 0.01
377 }
378 _ => f64::NEG_INFINITY,
379 };
380 Ok((
381 MechanismCandidate {
382 family,
383 score,
384 fit_cost: 1.0 + gather.n_parents() as f64,
385 eval_cost: 1.0 + gather.n_parents() as f64,
386 },
387 fitted,
388 ))
389}
390
391fn fit_family(
392 family: MechanismFamily,
393 gather: &ParentGatherPlan,
394 model: &CompiledCausalModel,
395 data: &TabularData,
396 y: &[f64],
397 backend: FaerBackend,
398 ls_ws: &mut LeastSquaresWorkspace,
399) -> Result<MechanismSlot, ModelError> {
400 let n = y.len();
401 match family {
402 MechanismFamily::Constant => {
403 let mean = y.iter().sum::<f64>() / n.max(1) as f64;
404 Ok(MechanismSlot::Constant { value: mean })
405 }
406 MechanismFamily::Discrete => {
407 let mut pairs: Vec<(i64, f64, usize)> = Vec::new();
408 for &yi in y {
409 if !yi.is_finite() {
410 continue;
411 }
412 let key = (yi * 1e6).round() as i64;
413 if let Some(e) = pairs.iter_mut().find(|(k, _, _)| *k == key) {
414 e.2 += 1;
415 } else {
416 pairs.push((key, yi, 1));
417 }
418 }
419 if pairs.is_empty() {
420 return Err(ModelError::Shape {
421 message: "no finite values for discrete fit".into(),
422 });
423 }
424 pairs.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
426 let total = pairs.iter().map(|(_, _, c)| *c).sum::<usize>() as f64;
427 let support: Vec<f64> = pairs.iter().map(|(_, v, _)| *v).collect();
428 let probs: Vec<f64> = pairs.iter().map(|(_, _, c)| *c as f64 / total).collect();
429 let k = support.len();
430 let p = gather.n_parents();
431 if p == 0 {
432 return Ok(MechanismSlot::Discrete {
433 support: Arc::from(support),
434 probs: Arc::from(probs),
435 logit_coeffs: None,
436 });
437 }
438 let ncols = 1 + p;
441 let mut x = vec![0.0; n * ncols];
442 for r in 0..n {
443 x[r] = 1.0;
444 }
445 for (pi, &parent) in gather.parents.iter().enumerate() {
446 let var = model.output_layout.variables[parent.as_usize()];
447 let col = data.float64_cow(var).map_err(ModelError::from)?;
448 let base = (1 + pi) * n;
449 x[base..base + n].copy_from_slice(&col[..n]);
450 }
451 let mut y_cat = vec![0u32; n];
452 for (r, &yi) in y.iter().enumerate() {
453 let Some(idx) = support.iter().position(|&s| (s - yi).abs() < 1e-12) else {
454 return Err(ModelError::Shape {
455 message: "discrete outcome not in fitted support".into(),
456 });
457 };
458 y_cat[r] = u32::try_from(idx).map_err(|_| ModelError::Shape {
459 message: "too many discrete categories".into(),
460 })?;
461 }
462 let fit = fit_multinomial_logit(
463 MultinomialDesignRef {
464 x_colmajor: &x,
465 nrows: n,
466 ncols,
467 y_category: &y_cat,
468 n_categories: k,
469 },
470 &backend,
471 ls_ws,
472 &GlmOptions::default(),
473 )?;
474 if !fit.converged {
477 return Err(ModelError::Numerical {
478 message: format!(
479 "multinomial logit did not converge (iters={}, deviance={})",
480 fit.iterations, fit.deviance
481 ),
482 });
483 }
484 Ok(MechanismSlot::Discrete {
485 support: Arc::from(support),
486 probs: Arc::from(probs),
487 logit_coeffs: Some(Arc::from(fit.coefficients)),
488 })
489 }
490 MechanismFamily::LinearGaussian => {
491 fit_linear_gaussian(gather, model, data, y, backend, ls_ws, 0.0)
492 }
493 MechanismFamily::HierarchicalLinear => {
494 fit_hierarchical_linear(gather, model, data, y, backend, ls_ws)
495 }
496 MechanismFamily::HierarchicalGlm => {
497 fit_hierarchical_glm(gather, model, data, y, backend, ls_ws)
498 }
499 MechanismFamily::Bvar => fit_bvar_minnesota(gather, model, data, y, backend, ls_ws),
500 MechanismFamily::LinearGaussianStateSpace => {
501 fit_lgssm_kalman_em(gather, model, data, y, backend, ls_ws)
502 }
503 MechanismFamily::GaussianProcess => {
504 #[cfg(feature = "gaussian-process")]
505 {
506 fit_gaussian_process(gather, model, data, y)
507 }
508 #[cfg(not(feature = "gaussian-process"))]
509 {
510 let _ = (gather, model, data, y, backend, ls_ws);
511 Err(ModelError::Unsupported {
512 message: "GaussianProcess requires feature `gaussian-process`".into(),
513 })
514 }
515 }
516 }
517}
518
519fn gather_parent_cols<'d>(
520 gather: &ParentGatherPlan,
521 model: &CompiledCausalModel,
522 data: &'d TabularData,
523) -> Result<Vec<std::borrow::Cow<'d, [f64]>>, ModelError> {
524 let mut parent_cols = Vec::with_capacity(gather.n_parents());
525 for &parent in gather.parents.iter() {
526 let var = model.output_layout.variables[parent.as_usize()];
527 parent_cols.push(data.float64_cow(var).map_err(ModelError::from)?);
528 }
529 Ok(parent_cols)
530}
531
532fn fit_hierarchical_linear(
535 gather: &ParentGatherPlan,
536 model: &CompiledCausalModel,
537 data: &TabularData,
538 y: &[f64],
539 backend: FaerBackend,
540 ls_ws: &mut LeastSquaresWorkspace,
541) -> Result<MechanismSlot, ModelError> {
542 let n = y.len();
543 let ols = fit_linear_gaussian(gather, model, data, y, backend, ls_ws, 0.0)?;
544 let MechanismSlot::LinearGaussian { intercept: ols_int, coeffs: ols_coeffs, sigma: ols_sigma } =
545 ols
546 else {
547 return Err(ModelError::Unsupported { message: "hierarchical base fit failed".into() });
548 };
549 let p = ols_coeffs.len();
550 let mean_b2 =
553 if p == 0 { 0.0 } else { ols_coeffs.iter().map(|b| b * b).sum::<f64>() / p as f64 };
554 let tau2 = (mean_b2 - ols_sigma * ols_sigma / n.max(1) as f64).max(1e-8);
555 let mut lambda = (ols_sigma * ols_sigma / tau2).clamp(1e-6, 1e6);
556
557 let mut y_work = y.to_vec();
559 let mut group_tau = 0.0;
560 if let Some(groups) = unit_id_groups(data, y.len()) {
561 let (demeaned, tau) = demean_by_group(&y_work, &groups);
562 y_work = demeaned;
563 group_tau = tau;
564 let ols2 = fit_linear_gaussian(gather, model, data, &y_work, backend, ls_ws, 0.0)?;
566 if let MechanismSlot::LinearGaussian { coeffs, sigma, .. } = ols2 {
567 let mean_b2 = if coeffs.is_empty() {
568 0.0
569 } else {
570 coeffs.iter().map(|b| b * b).sum::<f64>() / coeffs.len() as f64
571 };
572 let tau2 = (mean_b2 - sigma * sigma / n.max(1) as f64).max(1e-8);
573 lambda = (sigma * sigma / tau2).clamp(1e-6, 1e6);
574 }
575 }
576
577 let slot = fit_linear_gaussian(gather, model, data, &y_work, backend, ls_ws, lambda)?;
578 match slot {
579 MechanismSlot::LinearGaussian { intercept, coeffs, sigma } => {
580 let intercept = if group_tau > 0.0 {
582 y.iter().sum::<f64>() / n.max(1) as f64
583 - coeffs.iter().enumerate().try_fold(0.0, |acc, (i, c)| {
584 let var = model.output_layout.variables[gather.parents[i].as_usize()];
585 let col = data.float64_cow(var).map_err(ModelError::from)?;
586 let mean = col.iter().sum::<f64>() / n.max(1) as f64;
587 Ok::<_, ModelError>(acc + c * mean)
588 })?
589 } else {
590 intercept
591 };
592 let _ = (ols_int, group_tau);
593 Ok(MechanismSlot::HierarchicalLinear { intercept, coeffs, sigma, shrinkage: lambda })
594 }
595 other => Ok(other),
596 }
597}
598
599fn fit_hierarchical_glm(
605 gather: &ParentGatherPlan,
606 model: &CompiledCausalModel,
607 data: &TabularData,
608 y: &[f64],
609 backend: FaerBackend,
610 ls_ws: &mut LeastSquaresWorkspace,
611) -> Result<MechanismSlot, ModelError> {
612 let n = y.len();
613 let binary = y.iter().all(|&yi| yi == 0.0 || yi == 1.0);
614 if !binary {
615 return Err(ModelError::Unsupported {
616 message: "HierarchicalGlm requires binary {0,1} outcomes".into(),
617 });
618 }
619 let ols = fit_linear_gaussian(gather, model, data, y, backend, ls_ws, 0.0)?;
621 let lambda = match &ols {
622 MechanismSlot::LinearGaussian { coeffs, sigma, .. } => {
623 let p = coeffs.len().max(1);
624 let mean_b2 = coeffs.iter().map(|b| b * b).sum::<f64>() / p as f64;
625 let tau2 = (mean_b2 - sigma * sigma / n.max(1) as f64).max(1e-8);
626 (sigma * sigma / tau2).clamp(1e-4, 1e3)
627 }
628 _ => 1.0,
629 };
630 let p = gather.n_parents();
631 let ncols = 1 + p;
632 let mut x = vec![0.0; n * ncols];
633 for r in 0..n {
634 x[r] = 1.0;
635 }
636 for (pi, &parent) in gather.parents.iter().enumerate() {
637 let var = model.output_layout.variables[parent.as_usize()];
638 let col = data.float64_cow(var).map_err(ModelError::from)?;
639 let base = (1 + pi) * n;
640 x[base..base + n].copy_from_slice(&col[..n]);
641 }
642 let opts = GlmOptions::default();
643 let fit = fit_glm_ridge(
644 GlmFamily::BinomialLogit,
645 GlmDesignRef { x_colmajor: &x, nrows: n, ncols, y },
646 &backend,
647 ls_ws,
648 &opts,
649 lambda,
650 )
651 .map_err(|e| ModelError::Numerical { message: e.to_string() })?;
652 if !fit.converged {
653 return Err(ModelError::Numerical {
654 message: "hierarchical GLM logit did not converge".into(),
655 });
656 }
657 let mut logit_coeffs = vec![0.0; 2 * ncols];
659 logit_coeffs[ncols..].copy_from_slice(&fit.coefficients[..ncols]);
660 let n1 = y.iter().filter(|&&yi| yi == 1.0).count() as f64;
661 let p1 = n1 / n.max(1) as f64;
662 Ok(MechanismSlot::Discrete {
663 support: Arc::from([0.0, 1.0]),
664 probs: Arc::from([1.0 - p1, p1]),
665 logit_coeffs: Some(Arc::from(logit_coeffs)),
666 })
667}
668
669fn fit_bvar_minnesota(
671 gather: &ParentGatherPlan,
672 model: &CompiledCausalModel,
673 data: &TabularData,
674 y: &[f64],
675 backend: FaerBackend,
676 ls_ws: &mut LeastSquaresWorkspace,
677) -> Result<MechanismSlot, ModelError> {
678 let n = y.len();
679 let p = gather.n_parents();
680 let ncols = 1 + p;
681 let phi: f64 = 0.2; let mut x = vec![0.0; n * ncols];
683 for r in 0..n {
684 x[r] = 1.0;
685 }
686 for (pi, &parent) in gather.parents.iter().enumerate() {
687 let var = model.output_layout.variables[parent.as_usize()];
688 let col = data.float64_cow(var).map_err(ModelError::from)?;
689 let base = (1 + pi) * n;
690 x[base..base + n].copy_from_slice(&col[..n]);
691 }
692 let extra = ncols; let mut x2 = vec![0.0; (n + extra) * ncols];
695 let mut y2 = vec![0.0; n + extra];
696 for c in 0..ncols {
697 for r in 0..n {
698 x2[c * (n + extra) + r] = x[c * n + r];
699 }
700 }
701 y2[..n].copy_from_slice(y);
702 let v0: f64 = (100.0 * phi).max(1e-6);
704 x2[n] = (1.0 / v0).sqrt();
705 for j in 0..p {
706 let lag = (j + 1) as f64;
707 let v: f64 = (phi / (lag * lag)).max(1e-8);
708 x2[(1 + j) * (n + extra) + (n + 1 + j)] = (1.0 / v).sqrt();
709 }
710 let fit = backend.least_squares(&x2, n + extra, ncols, &y2, ls_ws).map_err(ModelError::from)?;
711 let intercept = fit.coefficients[0];
712 let coeffs: Arc<[f64]> = Arc::from(fit.coefficients[1..].to_vec());
713 let sigma = (fit.rss / (n.saturating_sub(ncols)).max(1) as f64).sqrt().max(1e-8);
714 Ok(MechanismSlot::Bvar { intercept, coeffs, sigma })
715}
716
717fn fit_lgssm_kalman_em(
720 gather: &ParentGatherPlan,
721 model: &CompiledCausalModel,
722 data: &TabularData,
723 y: &[f64],
724 backend: FaerBackend,
725 ls_ws: &mut LeastSquaresWorkspace,
726) -> Result<MechanismSlot, ModelError> {
727 let lg = fit_linear_gaussian(gather, model, data, y, backend, ls_ws, 0.0)?;
728 let (intercept, coeffs, sigma) = match lg {
729 MechanismSlot::LinearGaussian { intercept, coeffs, sigma } => (intercept, coeffs, sigma),
730 _ => {
731 return Err(ModelError::Unsupported {
732 message: "lgssm fit requires linear base".into(),
733 });
734 }
735 };
736 let parent_cols = gather_parent_cols(gather, model, data)?;
737 let mut resid = vec![0.0; y.len()];
738 for r in 0..y.len() {
739 let mut pred = intercept;
740 for (p, col) in parent_cols.iter().enumerate() {
741 pred += coeffs[p] * col[r];
742 }
743 resid[r] = y[r] - pred;
744 }
745 let (a, process_std, obs_std, initial_mean) = lgssm_em(&resid, 25);
746 let _ = sigma;
747 Ok(MechanismSlot::LinearGaussianStateSpace {
748 a,
749 process_std: process_std.max(1e-8),
750 obs_std: obs_std.max(1e-8),
751 initial_mean,
752 })
753}
754
755fn lgssm_em(y: &[f64], max_iters: usize) -> (f64, f64, f64, f64) {
757 let n = y.len();
758 if n < 3 {
759 let (a, q) = fit_ar1(y);
760 return (a, q, q.max(1e-8), y.first().copied().unwrap_or(0.0));
761 }
762 let mut a = 0.8;
763 let mut q = 1.0; let mut r = 1.0; let mut x0 = y[0];
766 let p0 = 1.0;
767 for _ in 0..max_iters {
768 let (x_f, p_f, x_pred, p_pred) = crate::lgssm::kalman_filter(y, a, q, r, x0, p0);
769 let (x_s, p_s, p_lag) = crate::lgssm::rts_smooth(a, &x_f, &p_f, &x_pred, &p_pred);
770 let mut num = 0.0;
772 let mut den = 0.0;
773 for t in 1..n {
774 num += p_lag[t] + x_s[t] * x_s[t - 1];
775 den += p_s[t - 1] + x_s[t - 1] * x_s[t - 1];
776 }
777 a = if den > 1e-12 { (num / den).clamp(-0.999, 0.999) } else { a };
778 let mut q_acc = 0.0;
779 for t in 1..n {
780 q_acc += p_s[t] + x_s[t] * x_s[t] + a * a * (p_s[t - 1] + x_s[t - 1] * x_s[t - 1])
781 - 2.0 * a * (p_lag[t] + x_s[t] * x_s[t - 1]);
782 }
783 q = (q_acc / (n - 1) as f64).max(1e-8);
784 let mut r_acc = 0.0;
785 for t in 0..n {
786 r_acc += p_s[t] + (y[t] - x_s[t]).powi(2);
787 }
788 r = (r_acc / n as f64).max(1e-8);
789 x0 = x_s[0];
790 }
791 (a, q.sqrt(), r.sqrt(), x0)
792}
793
794fn unit_id_groups(data: &TabularData, n: usize) -> Option<Vec<u32>> {
795 let schema = data.schema();
796 for var in schema.variables() {
797 if !var.role_hints.contains(RoleHint::UnitId) {
798 continue;
799 }
800 let Ok(col) = data.float64_cow(var.id) else {
801 continue;
802 };
803 if col.len() != n {
804 continue;
805 }
806 let mut groups = Vec::with_capacity(n);
807 let mut ok = true;
808 for &v in col.iter() {
809 if !v.is_finite() {
810 ok = false;
811 break;
812 }
813 groups.push(v.round() as u32);
814 }
815 if ok {
816 let mut uniq = groups.clone();
817 uniq.sort_unstable();
818 uniq.dedup();
819 if uniq.len() >= 2 && uniq.len() < n {
820 return Some(groups);
821 }
822 }
823 }
824 None
825}
826
827fn demean_by_group(y: &[f64], groups: &[u32]) -> (Vec<f64>, f64) {
828 let mut sums = std::collections::HashMap::<u32, (f64, usize)>::new();
829 for (&g, &yi) in groups.iter().zip(y.iter()) {
830 let e = sums.entry(g).or_insert((0.0, 0));
831 e.0 += yi;
832 e.1 += 1;
833 }
834 let grand = y.iter().sum::<f64>() / y.len().max(1) as f64;
835 let mut tau2 = 0.0;
836 let mut gcount = 0usize;
837 for (s, c) in sums.values() {
838 let m = s / (*c).max(1) as f64;
839 tau2 += (m - grand).powi(2);
840 gcount += 1;
841 }
842 let tau = (tau2 / gcount.max(1) as f64).sqrt();
843 let out: Vec<f64> = groups
844 .iter()
845 .zip(y.iter())
846 .map(|(&g, &yi)| {
847 let (s, c) = sums[&g];
848 yi - s / c.max(1) as f64
849 })
850 .collect();
851 (out, tau)
852}
853
854fn fit_ar1(series: &[f64]) -> (f64, f64) {
855 let n = series.len();
856 if n < 3 {
857 return (0.0, series.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-8));
858 }
859 let mut num = 0.0;
860 let mut den = 0.0;
861 for t in 1..n {
862 num += series[t] * series[t - 1];
863 den += series[t - 1] * series[t - 1];
864 }
865 let a = if den > 1e-12 { (num / den).clamp(-0.999, 0.999) } else { 0.0 };
866 let mut rss = 0.0;
867 for t in 1..n {
868 let e = series[t] - a * series[t - 1];
869 rss += e * e;
870 }
871 let process_std = (rss / (n - 1) as f64).sqrt().max(1e-8);
872 (a, process_std)
873}
874
875fn fit_linear_gaussian(
876 gather: &ParentGatherPlan,
877 model: &CompiledCausalModel,
878 data: &TabularData,
879 y: &[f64],
880 backend: FaerBackend,
881 ls_ws: &mut LeastSquaresWorkspace,
882 ridge: f64,
883) -> Result<MechanismSlot, ModelError> {
884 let n = y.len();
885 let p = gather.n_parents();
886 let ncols = 1 + p;
887 let mut x = vec![0.0; n * ncols];
888 for r in 0..n {
889 x[r] = 1.0;
890 }
891 for (pi, &parent) in gather.parents.iter().enumerate() {
892 let var = model.output_layout.variables[parent.as_usize()];
893 let col = data.float64_cow(var).map_err(ModelError::from)?;
894 let base = (1 + pi) * n;
895 x[base..base + n].copy_from_slice(&col[..n]);
896 }
897 if ridge > 0.0 {
898 let extra = p;
900 let mut x2 = vec![0.0; (n + extra) * ncols];
901 let mut y2 = vec![0.0; n + extra];
902 for c in 0..ncols {
903 for r in 0..n {
904 x2[c * (n + extra) + r] = x[c * n + r];
905 }
906 }
907 y2[..n].copy_from_slice(y);
908 let sqrt_r = ridge.sqrt();
909 for j in 0..p {
910 x2[(1 + j) * (n + extra) + (n + j)] = sqrt_r;
911 }
912 let fit =
913 backend.least_squares(&x2, n + extra, ncols, &y2, ls_ws).map_err(ModelError::from)?;
914 let intercept = fit.coefficients[0];
915 let coeffs: Arc<[f64]> = Arc::from(fit.coefficients[1..].to_vec());
916 let sigma = (fit.rss / (n.saturating_sub(ncols)).max(1) as f64).sqrt().max(1e-8);
917 return Ok(MechanismSlot::LinearGaussian { intercept, coeffs, sigma });
918 }
919 let fit = backend.least_squares(&x, n, ncols, y, ls_ws).map_err(ModelError::from)?;
920 let intercept = fit.coefficients[0];
921 let coeffs: Arc<[f64]> = Arc::from(fit.coefficients[1..].to_vec());
922 let sigma = (fit.rss / (n.saturating_sub(ncols)).max(1) as f64).sqrt().max(1e-8);
923 Ok(MechanismSlot::LinearGaussian { intercept, coeffs, sigma })
924}
925
926#[cfg(feature = "gaussian-process")]
938pub const GP_FAMILY_MAX_ROWS: usize = 2_000;
939
940#[cfg(feature = "gaussian-process")]
941fn fit_gaussian_process(
948 gather: &ParentGatherPlan,
949 model: &CompiledCausalModel,
950 data: &TabularData,
951 y: &[f64],
952) -> Result<MechanismSlot, ModelError> {
953 let n = y.len();
954 let p = gather.n_parents();
955 if p == 0 {
956 return Err(ModelError::Unsupported {
957 message: "GaussianProcess requires at least one parent".into(),
958 });
959 }
960 if n > GP_FAMILY_MAX_ROWS {
961 return Err(ModelError::Unsupported {
962 message: format!(
963 "GaussianProcess is limited to {GP_FAMILY_MAX_ROWS} rows (got {n}): the exact \
964 Cholesky grid search is O(n³) per cell with an O(n²) Gram matrix; use another \
965 mechanism family (or subsample) for larger data"
966 ),
967 });
968 }
969 let parent_cols = gather_parent_cols(gather, model, data)?;
970 let mut x_train = vec![0.0; n * p];
971 for r in 0..n {
972 for c in 0..p {
973 x_train[r * p + c] = parent_cols[c][r];
974 }
975 }
976 let variance = 1.0;
978 let length_scales = [0.25, 0.5, 1.0, 2.0, 4.0];
979 let noise_stds = [0.05, 0.1, 0.2, 0.5];
980 let mut best = None::<(f64, f64, f64, Vec<f64>)>; for &length_scale in &length_scales {
982 for &noise_std in &noise_stds {
983 let mut k = vec![0.0; n * n];
984 let inv_l2 = 1.0 / (length_scale * length_scale);
985 for i in 0..n {
986 for j in i..n {
987 let mut d2 = 0.0;
988 for c in 0..p {
989 let d = x_train[i * p + c] - x_train[j * p + c];
990 d2 += d * d;
991 }
992 let kij = variance * (-0.5 * d2 * inv_l2).exp();
993 k[i * n + j] = kij;
994 k[j * n + i] = kij;
995 }
996 k[i * n + i] += noise_std * noise_std;
997 }
998 let Some(chol) = cholesky_spd(&k, n) else {
999 continue;
1000 };
1001 let Some(alpha) = chol_solve(&chol, n, y) else {
1002 continue;
1003 };
1004 let mut y_alpha = 0.0;
1005 for i in 0..n {
1006 y_alpha += y[i] * alpha[i];
1007 }
1008 let nlml = 0.5 * y_alpha
1009 + 0.5 * chol_log_det(&chol, n)
1010 + 0.5 * n as f64 * (2.0 * std::f64::consts::PI).ln();
1011 match &best {
1012 Some((best_nlml, ..)) if nlml >= *best_nlml => {}
1013 _ => best = Some((nlml, length_scale, noise_std, alpha)),
1014 }
1015 }
1016 }
1017 let (_nlml, length_scale, noise_std, alpha) = best.ok_or_else(|| ModelError::Numerical {
1018 message: "GP hyperparameter search failed".into(),
1019 })?;
1020 Ok(MechanismSlot::GaussianProcess {
1021 length_scale,
1022 variance,
1023 noise_std,
1024 x_train: Arc::from(x_train),
1025 n_train: n,
1026 n_parents: p,
1027 alpha: Arc::from(alpha),
1028 })
1029}
1030
1031fn residual_mse(
1034 gather: &ParentGatherPlan,
1035 model: &CompiledCausalModel,
1036 data: &TabularData,
1037 y: &[f64],
1038 intercept: f64,
1039 coeffs: &[f64],
1040) -> Result<f64, ModelError> {
1041 let n = y.len();
1042 let mut sse = 0.0;
1043 let parent_cols = gather_parent_cols(gather, model, data)?;
1044 for r in 0..n {
1045 let mut pred = intercept;
1046 for (p, col) in parent_cols.iter().enumerate() {
1047 pred += coeffs[p] * col[r];
1048 }
1049 let e = y[r] - pred;
1050 sse += e * e;
1051 }
1052 Ok(sse / n.max(1) as f64)
1053}
1054
1055#[allow(clippy::too_many_arguments)]
1059fn gp_residual_mse(
1060 gather: &ParentGatherPlan,
1061 model: &CompiledCausalModel,
1062 data: &TabularData,
1063 y: &[f64],
1064 length_scale: f64,
1065 variance: f64,
1066 x_train: &[f64],
1067 n_train: usize,
1068 n_parents: usize,
1069 alpha: &[f64],
1070) -> Result<f64, ModelError> {
1071 let n = y.len();
1072 let p = gather.n_parents();
1073 let parent_cols = gather_parent_cols(gather, model, data)?;
1074 let mut parent_mat = vec![0.0; n * p.max(1)];
1075 for (pi, col) in parent_cols.iter().enumerate() {
1076 parent_mat[pi * n..pi * n + n].copy_from_slice(&col[..n]);
1077 }
1078 let parents =
1079 ParentBatch { n_rows: n, n_parents: p, values: &parent_mat[..p.saturating_mul(n)] };
1080 let mut pred = vec![0.0; n];
1081 gp_predictive_mean_column(
1082 length_scale,
1083 variance,
1084 x_train,
1085 n_train,
1086 n_parents,
1087 alpha,
1088 parents,
1089 &mut pred,
1090 )?;
1091 Ok(y.iter().zip(pred.iter()).map(|(yi, m)| (yi - m).powi(2)).sum::<f64>() / n.max(1) as f64)
1092}
1093
1094fn discrete_mean_loglik(
1095 gather: &ParentGatherPlan,
1096 model: &CompiledCausalModel,
1097 data: &TabularData,
1098 y: &[f64],
1099 support: &[f64],
1100 logits: &[f64],
1101) -> Result<f64, ModelError> {
1102 let n = y.len();
1103 let p = gather.n_parents();
1104 let mut parent_mat = vec![0.0; n * p.max(1)];
1105 for (pi, &parent) in gather.parents.iter().enumerate() {
1106 let var = model.output_layout.variables[parent.as_usize()];
1107 let col = data.float64_cow(var).map_err(ModelError::from)?;
1108 let base = pi * n;
1109 parent_mat[base..base + n].copy_from_slice(&col[..n]);
1110 }
1111 let parents = ParentBatch { n_rows: n, n_parents: p, values: &parent_mat[..n * p] };
1112 let slot = MechanismSlot::Discrete {
1113 support: Arc::from(support.to_vec()),
1114 probs: Arc::from(vec![0.0; support.len()]),
1115 logit_coeffs: Some(Arc::from(logits.to_vec())),
1116 };
1117 let mut lp = vec![0.0; n];
1118 log_prob_column(&slot, y, parents, &mut lp)?;
1119 Ok(lp.iter().sum::<f64>() / n.max(1) as f64)
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124 use super::*;
1125 use antecedent_core::{
1126 CausalSchemaBuilder, MeasurementSpec, RoleHint, SmallRoleSet, ValueType, VariableId,
1127 };
1128 use antecedent_data::column::{Float64Column, ValidityBitmap};
1129 use antecedent_data::{OwnedColumn, OwnedColumnarStorage, TabularData};
1130 use antecedent_graph::{Dag, DenseNodeId};
1131
1132 fn toy_data() -> (TabularData, Dag) {
1133 let n = 40usize;
1134 let mut b = CausalSchemaBuilder::new();
1135 b.add_variable(
1136 "x",
1137 ValueType::Continuous,
1138 SmallRoleSet::from_hint(RoleHint::Context),
1139 None,
1140 None,
1141 MeasurementSpec::default(),
1142 )
1143 .unwrap();
1144 b.add_variable(
1145 "y",
1146 ValueType::Continuous,
1147 SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
1148 None,
1149 None,
1150 MeasurementSpec::default(),
1151 )
1152 .unwrap();
1153 let schema = b.build().unwrap();
1154 let mut xv = vec![0.0; n];
1155 let mut yv = vec![0.0; n];
1156 for i in 0..n {
1157 xv[i] = i as f64 * 0.1;
1158 yv[i] = 1.0 + 2.0 * xv[i];
1159 }
1160 let validity = ValidityBitmap::all_valid(n);
1161 let cols = vec![
1162 OwnedColumn::Float64(
1163 Float64Column::new(VariableId::from_raw(0), Arc::from(xv), validity.clone())
1164 .unwrap(),
1165 ),
1166 OwnedColumn::Float64(
1167 Float64Column::new(VariableId::from_raw(1), Arc::from(yv), validity).unwrap(),
1168 ),
1169 ];
1170 let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1171 let mut g = Dag::with_variables(2);
1172 g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
1173 (TabularData::new(storage), g)
1174 }
1175
1176 #[test]
1180 fn is_low_cardinality_matches_sort_dedup_reference() {
1181 fn reference(y: &[f64], max_levels: usize) -> bool {
1182 let mut vals: Vec<i64> =
1183 y.iter().filter(|v| v.is_finite()).map(|v| (v * 1e6).round() as i64).collect();
1184 vals.sort_unstable();
1185 vals.dedup();
1186 !vals.is_empty() && vals.len() <= max_levels
1187 }
1188 let cases: [&[f64]; 8] = [
1189 &[],
1190 &[f64::NAN, f64::INFINITY, f64::NEG_INFINITY],
1191 &[1.0, 1.0, 1.0, 1.0],
1192 &[-3.0, -3.0, 2.0, 2.0, f64::NAN, -3.0],
1193 &[0.0, -0.0, 1e-7, 2e-7, 5e-7], &[0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0],
1195 &[0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0],
1196 &[-1.5, 1.5, -1.5, 1.5, 0.25, f64::NAN, -8.75],
1197 ];
1198 for y in cases {
1199 for max_levels in [0usize, 1, 2, 8] {
1200 assert_eq!(
1201 is_low_cardinality(y, max_levels),
1202 reference(y, max_levels),
1203 "mismatch for y={y:?} max_levels={max_levels}"
1204 );
1205 }
1206 }
1207 }
1208
1209 #[cfg(feature = "gaussian-process")]
1213 #[test]
1214 fn gp_family_refuses_above_row_cap_and_selection_falls_back() {
1215 let n = GP_FAMILY_MAX_ROWS + 1;
1216 let mut b = CausalSchemaBuilder::new();
1217 for name in ["x", "y"] {
1218 b.add_variable(
1219 name,
1220 ValueType::Continuous,
1221 SmallRoleSet::from_hint(RoleHint::Context),
1222 None,
1223 None,
1224 MeasurementSpec::default(),
1225 )
1226 .unwrap();
1227 }
1228 let schema = b.build().unwrap();
1229 let xv: Vec<f64> = (0..n).map(|i| (i as f64 * 0.37).sin() * 3.0).collect();
1230 let yv: Vec<f64> = xv.iter().map(|x| 1.0 + 2.0 * x + (x * 0.5).cos() * 0.01).collect();
1231 let validity = ValidityBitmap::all_valid(n);
1232 let cols = vec![
1233 OwnedColumn::Float64(
1234 Float64Column::new(VariableId::from_raw(0), Arc::from(xv), validity.clone())
1235 .unwrap(),
1236 ),
1237 OwnedColumn::Float64(
1238 Float64Column::new(VariableId::from_raw(1), Arc::from(yv), validity).unwrap(),
1239 ),
1240 ];
1241 let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1242 let data = TabularData::new(storage);
1243 let mut g = Dag::with_variables(2);
1244 g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
1245 let compiled = CompiledCausalModel::compile(g).unwrap();
1246 let (_, assigns) = MechanismRegistry::with_bayesian_families()
1247 .assign_and_fit(&compiled, &data, SelectionPolicy::BestScore)
1248 .unwrap();
1249 let child = assigns.iter().find(|a| a.node == DenseNodeId::from_raw(1)).unwrap();
1250 let gp_failure = child
1251 .failed_families
1252 .iter()
1253 .find(|(f, _)| *f == MechanismFamily::GaussianProcess)
1254 .expect("GP refusal must be recorded, not silently absent");
1255 assert!(gp_failure.1.contains("limited to"), "message: {}", gp_failure.1);
1256 assert_ne!(child.selected, MechanismFamily::GaussianProcess);
1257 assert!(!child.candidates.iter().any(|c| c.family == MechanismFamily::GaussianProcess));
1258 }
1259
1260 #[test]
1261 fn auto_assign_linear_chain() {
1262 let (data, g) = toy_data();
1263 let compiled = CompiledCausalModel::compile(g).unwrap();
1264 let reg = MechanismRegistry::standard();
1265 let (store, assigns) =
1266 reg.assign_and_fit(&compiled, &data, SelectionPolicy::BestScore).unwrap();
1267 assert_eq!(assigns.len(), 2);
1268 assert!(matches!(
1269 store.get(DenseNodeId::from_raw(1)),
1270 MechanismSlot::LinearGaussian { .. }
1271 ));
1272 }
1273
1274 #[test]
1275 fn bayesian_families_fit_hierarchical_and_bvar() {
1276 let (data, g) = toy_data();
1277 let compiled = CompiledCausalModel::compile(g).unwrap();
1278 let reg = MechanismRegistry::with_bayesian_families();
1279 let (store, _) = reg
1280 .assign_and_fit(
1281 &compiled,
1282 &data,
1283 SelectionPolicy::RequireFamily(MechanismFamily::HierarchicalLinear),
1284 )
1285 .unwrap();
1286 assert!(matches!(
1287 store.get(DenseNodeId::from_raw(1)),
1288 MechanismSlot::HierarchicalLinear { .. }
1289 ));
1290 let (store2, _) = reg
1291 .assign_and_fit(&compiled, &data, SelectionPolicy::RequireFamily(MechanismFamily::Bvar))
1292 .unwrap();
1293 assert!(matches!(store2.get(DenseNodeId::from_raw(1)), MechanismSlot::Bvar { .. }));
1294 let (store3, _) = reg
1295 .assign_and_fit(
1296 &compiled,
1297 &data,
1298 SelectionPolicy::RequireFamily(MechanismFamily::LinearGaussianStateSpace),
1299 )
1300 .unwrap();
1301 assert!(matches!(
1302 store3.get(DenseNodeId::from_raw(1)),
1303 MechanismSlot::LinearGaussianStateSpace { .. }
1304 ));
1305 }
1306
1307 #[test]
1308 fn discrete_conditional_multinomial_logit_mle() {
1309 let n = 120usize;
1310 let mut b = CausalSchemaBuilder::new();
1311 b.add_variable(
1312 "x",
1313 ValueType::Continuous,
1314 SmallRoleSet::from_hint(RoleHint::Context),
1315 None,
1316 None,
1317 MeasurementSpec::default(),
1318 )
1319 .unwrap();
1320 b.add_variable(
1321 "y",
1322 ValueType::Continuous,
1323 SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
1324 None,
1325 None,
1326 MeasurementSpec::default(),
1327 )
1328 .unwrap();
1329 let schema = b.build().unwrap();
1330 let mut xv = vec![0.0; n];
1331 let mut yv = vec![0.0; n];
1332 for i in 0..n {
1333 let t = if i < n / 2 { 0.0 } else { 1.0 };
1334 xv[i] = t;
1335 yv[i] = if i % 8 == 0 { 1.0 - t } else { t };
1337 }
1338 let validity = ValidityBitmap::all_valid(n);
1339 let cols = vec![
1340 OwnedColumn::Float64(
1341 Float64Column::new(VariableId::from_raw(0), Arc::from(xv), validity.clone())
1342 .unwrap(),
1343 ),
1344 OwnedColumn::Float64(
1345 Float64Column::new(VariableId::from_raw(1), Arc::from(yv), validity).unwrap(),
1346 ),
1347 ];
1348 let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1349 let data = TabularData::new(storage);
1350 let mut g = Dag::with_variables(2);
1351 g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
1352 let compiled = CompiledCausalModel::compile(g).unwrap();
1353 let reg = MechanismRegistry::standard();
1354 let (store, _) = reg
1355 .assign_and_fit(
1356 &compiled,
1357 &data,
1358 SelectionPolicy::RequireFamily(MechanismFamily::Discrete),
1359 )
1360 .unwrap();
1361 let MechanismSlot::Discrete { support, logit_coeffs, .. } =
1362 store.get(DenseNodeId::from_raw(1))
1363 else {
1364 panic!("expected discrete mechanism");
1365 };
1366 let logits = logit_coeffs.as_ref().expect("parent-conditional logits");
1367 assert_eq!(support.len(), 2);
1368 assert_eq!(logits.len(), 2 * 2); assert!(logits[0].abs() < 1e-12 && logits[1].abs() < 1e-12);
1371 assert!(logits[3] > 0.5, "slope={}", logits[3]);
1373 }
1374
1375 #[cfg(feature = "gaussian-process")]
1376 #[test]
1377 fn gaussian_process_matches_exact_logdet_oracle() {
1378 let fixture: serde_json::Value = serde_json::from_str(include_str!(
1379 "../../../conformance/gcm/gaussian_process/expected.json"
1380 ))
1381 .unwrap();
1382 let n = fixture["data"]["n"].as_u64().unwrap() as usize;
1383 let mut builder = CausalSchemaBuilder::new();
1384 for name in ["x", "y"] {
1385 builder
1386 .add_variable(
1387 name,
1388 ValueType::Continuous,
1389 SmallRoleSet::from_hint(RoleHint::Context),
1390 None,
1391 None,
1392 MeasurementSpec::default(),
1393 )
1394 .unwrap();
1395 }
1396 let schema = builder.build().unwrap();
1397 let x: Vec<f64> = (0..n).map(|i| -2.4 + 4.8 * i as f64 / (n - 1) as f64).collect();
1398 let y: Vec<f64> =
1399 x.iter().map(|value| (1.3 * value).sin() + 0.18 * (3.1 * value).cos()).collect();
1400 let validity = ValidityBitmap::all_valid(n);
1401 let columns = vec![
1402 OwnedColumn::Float64(
1403 Float64Column::new(VariableId::from_raw(0), Arc::from(x), validity.clone())
1404 .unwrap(),
1405 ),
1406 OwnedColumn::Float64(
1407 Float64Column::new(VariableId::from_raw(1), Arc::from(y), validity).unwrap(),
1408 ),
1409 ];
1410 let data =
1411 TabularData::new(OwnedColumnarStorage::try_new(schema, columns, None, None).unwrap());
1412 let mut graph = Dag::with_variables(2);
1413 graph.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
1414 let compiled = CompiledCausalModel::compile(graph).unwrap();
1415 let gather = compiled.gather_for(DenseNodeId::from_raw(1)).unwrap();
1416 let outcome = data.float64_values(VariableId::from_raw(1)).unwrap();
1417 let mut workspace = LeastSquaresWorkspace::default();
1418 let slot = fit_family(
1419 MechanismFamily::GaussianProcess,
1420 gather,
1421 &compiled,
1422 &data,
1423 &outcome,
1424 FaerBackend,
1425 &mut workspace,
1426 )
1427 .unwrap();
1428 let MechanismSlot::GaussianProcess { length_scale, noise_std, .. } = slot else {
1429 panic!("GP slot");
1430 };
1431 assert_eq!(length_scale, fixture["reference"]["length_scale"].as_f64().unwrap());
1434 assert_eq!(noise_std, fixture["reference"]["noise_std"].as_f64().unwrap());
1435 assert_ne!(length_scale, 0.5, "must not select the diagonal-proxy length scale");
1436 }
1437
1438 #[test]
1459 fn root_with_variance_is_fit_as_a_distribution_not_a_constant() {
1460 fn fit_single_column(values: Vec<f64>) -> MechanismSlot {
1461 let n = values.len();
1462 let mut b = CausalSchemaBuilder::new();
1463 b.add_variable(
1464 "x",
1465 ValueType::Continuous,
1466 SmallRoleSet::from_hint(RoleHint::Context),
1467 None,
1468 None,
1469 MeasurementSpec::default(),
1470 )
1471 .unwrap();
1472 let schema = b.build().unwrap();
1473 let cols = vec![OwnedColumn::Float64(
1474 Float64Column::new(
1475 VariableId::from_raw(0),
1476 Arc::from(values),
1477 ValidityBitmap::all_valid(n),
1478 )
1479 .unwrap(),
1480 )];
1481 let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1482 let data = TabularData::new(storage);
1483 let compiled = CompiledCausalModel::compile(Dag::with_variables(1)).unwrap();
1484 let (store, _) = MechanismRegistry::standard()
1485 .assign_and_fit(&compiled, &data, SelectionPolicy::BestScore)
1486 .unwrap();
1487 store.slots[0].clone()
1488 }
1489
1490 let spread: Vec<f64> = (0..100).map(|i| f64::from(i) * 0.1).collect();
1492 let mean = spread.iter().sum::<f64>() / spread.len() as f64;
1493 let var =
1494 spread.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (spread.len() - 1) as f64;
1495 let sd = var.sqrt();
1496 match fit_single_column(spread) {
1497 MechanismSlot::LinearGaussian { intercept, ref coeffs, sigma } => {
1498 assert!(coeffs.is_empty(), "a root has no parents");
1499 assert!((intercept - mean).abs() < 1e-9, "intercept {intercept} != mean {mean}");
1500 assert!(
1501 sigma > 0.5 * sd,
1502 "root sigma {sigma} must carry the marginal spread (SD {sd}), not collapse"
1503 );
1504 }
1505 other => panic!("root with variance fit as {other:?}; expected a real marginal"),
1506 }
1507
1508 match fit_single_column(vec![7.0; 100]) {
1516 MechanismSlot::Constant { value } => assert!((value - 7.0).abs() < 1e-12),
1517 MechanismSlot::Discrete { ref support, ref probs, logit_coeffs: None } => {
1518 assert_eq!(support.len(), 1, "degenerate column must have a single support point");
1519 assert!((support[0] - 7.0).abs() < 1e-12);
1520 assert!((probs[0] - 1.0).abs() < 1e-12);
1521 }
1522 other => panic!("constant column fit as {other:?}; expected a deterministic mechanism"),
1523 }
1524 }
1525
1526 #[test]
1527 fn best_score_prefers_lgssm_over_linear_gaussian_on_lgssm_generated_data() {
1528 use antecedent_core::CausalRng;
1529 use antecedent_kernels::standard_normal;
1530
1531 let n = 60usize;
1532 let a = 0.95_f64;
1533 let process_std = 0.05_f64;
1534 let obs_std = 0.05_f64;
1535 let initial_mean = 5.0_f64;
1536 let mut rng = CausalRng::from_seed(11);
1537 let mut yv = vec![0.0; n];
1538 let mut x = initial_mean;
1539 for i in 0..n {
1540 x = if i == 0 {
1541 initial_mean + process_std * standard_normal(&mut rng)
1542 } else {
1543 a * x + process_std * standard_normal(&mut rng)
1544 };
1545 yv[i] = x + obs_std * standard_normal(&mut rng);
1546 }
1547
1548 let mut b = CausalSchemaBuilder::new();
1549 b.add_variable(
1550 "y",
1551 ValueType::Continuous,
1552 SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
1553 None,
1554 None,
1555 MeasurementSpec::default(),
1556 )
1557 .unwrap();
1558 let schema = b.build().unwrap();
1559 let validity = ValidityBitmap::all_valid(n);
1560 let cols = vec![OwnedColumn::Float64(
1561 Float64Column::new(VariableId::from_raw(0), Arc::from(yv), validity).unwrap(),
1562 )];
1563 let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1564 let data = TabularData::new(storage);
1565 let compiled = CompiledCausalModel::compile(Dag::with_variables(1)).unwrap();
1566
1567 let registry = MechanismRegistry::with_bayesian_families();
1568 let (_, assigns) =
1569 registry.assign_and_fit(&compiled, &data, SelectionPolicy::BestScore).unwrap();
1570 assert_eq!(assigns.len(), 1);
1571 let assignment = &assigns[0];
1572 let lg_candidate = assignment
1573 .candidates
1574 .iter()
1575 .find(|c| c.family == MechanismFamily::LinearGaussian)
1576 .expect("LinearGaussian candidate present");
1577 let lgssm_candidate = assignment
1578 .candidates
1579 .iter()
1580 .find(|c| c.family == MechanismFamily::LinearGaussianStateSpace)
1581 .expect("LGSSM candidate present");
1582 assert!(
1583 lgssm_candidate.score > lg_candidate.score,
1584 "lgssm={} linear={}",
1585 lgssm_candidate.score,
1586 lg_candidate.score
1587 );
1588 assert_eq!(assignment.selected, MechanismFamily::LinearGaussianStateSpace);
1589 }
1590}