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, fit_multinomial_logit,
27};
28
29use crate::batch::ParentBatch;
30use crate::compile::{
31 CompiledCausalModel, CompiledMechanismStore, MechanismSlot, ParentGatherPlan,
32};
33use crate::error::ModelError;
34use crate::mechanism::log_prob_column;
35
36#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
38pub enum MechanismFamily {
39 LinearGaussian,
41 Constant,
43 Discrete,
45 HierarchicalLinear,
47 HierarchicalGlm,
49 Bvar,
51 LinearGaussianStateSpace,
53 GaussianProcess,
55}
56
57impl MechanismFamily {
58 #[must_use]
60 pub const fn id(self) -> &'static str {
61 match self {
62 Self::LinearGaussian => "linear_gaussian",
63 Self::Constant => "constant",
64 Self::Discrete => "discrete",
65 Self::HierarchicalLinear => "hierarchical_linear",
66 Self::HierarchicalGlm => "hierarchical_glm",
67 Self::Bvar => "bvar",
68 Self::LinearGaussianStateSpace => "lgssm",
69 Self::GaussianProcess => "gaussian_process",
70 }
71 }
72}
73
74#[derive(Clone, Debug)]
76pub struct MechanismCandidate {
77 pub family: MechanismFamily,
79 pub score: f64,
81 pub fit_cost: f64,
83 pub eval_cost: f64,
85}
86
87#[derive(Clone, Debug)]
89pub struct MechanismAssignment {
90 pub node: DenseNodeId,
92 pub variable: VariableId,
94 pub candidates: Arc<[MechanismCandidate]>,
96 pub selected: MechanismFamily,
98 pub fitted: MechanismSlot,
100 pub failed_families: Arc<[(MechanismFamily, String)]>,
102}
103
104#[derive(Clone, Debug)]
106pub struct MechanismRegistry {
107 pub continuous: Arc<[MechanismFamily]>,
109 pub discrete: Arc<[MechanismFamily]>,
111}
112
113impl Default for MechanismRegistry {
114 fn default() -> Self {
115 Self::standard()
116 }
117}
118
119impl MechanismRegistry {
120 #[must_use]
122 pub fn standard() -> Self {
123 Self {
124 continuous: Arc::from(vec![MechanismFamily::LinearGaussian, MechanismFamily::Constant]),
125 discrete: Arc::from(vec![MechanismFamily::Discrete, MechanismFamily::Constant]),
126 }
127 }
128
129 #[must_use]
131 pub fn with_bayesian_families() -> Self {
132 #[cfg(feature = "gaussian-process")]
133 let continuous = {
134 let mut continuous = vec![
135 MechanismFamily::LinearGaussian,
136 MechanismFamily::HierarchicalLinear,
137 MechanismFamily::Bvar,
138 MechanismFamily::LinearGaussianStateSpace,
139 MechanismFamily::Constant,
140 ];
141 continuous.insert(continuous.len() - 1, MechanismFamily::GaussianProcess);
142 continuous
143 };
144 #[cfg(not(feature = "gaussian-process"))]
145 let continuous = vec![
146 MechanismFamily::LinearGaussian,
147 MechanismFamily::HierarchicalLinear,
148 MechanismFamily::Bvar,
149 MechanismFamily::LinearGaussianStateSpace,
150 MechanismFamily::Constant,
151 ];
152 let discrete = vec![
153 MechanismFamily::Discrete,
154 MechanismFamily::HierarchicalGlm,
155 MechanismFamily::Constant,
156 ];
157 Self { continuous: Arc::from(continuous), discrete: Arc::from(discrete) }
158 }
159
160 pub fn assign_and_fit(
166 &self,
167 model: &CompiledCausalModel,
168 data: &TabularData,
169 policy: SelectionPolicy,
170 ) -> Result<(CompiledMechanismStore, Vec<MechanismAssignment>), ModelError> {
171 let n = model.n_nodes();
172 let nrows = data.row_count();
173 if nrows == 0 {
174 return Err(ModelError::Shape { message: "empty data for mechanism fit".into() });
175 }
176 let mut slots = vec![MechanismSlot::Vacant; n];
177 let mut assignments = Vec::with_capacity(n);
178 let backend = FaerBackend;
179 let mut ls_ws = LeastSquaresWorkspace::default();
180
181 for gather in model.parent_gathers.iter() {
182 let node = gather.child;
183 let var = model.output_layout.variables[node.as_usize()];
184 let y = data.float64_values(var).map_err(ModelError::from)?;
185 let is_discrete = is_low_cardinality(&y, 8);
186 let families: &[MechanismFamily] =
187 if is_discrete { &self.discrete } else { &self.continuous };
188
189 let mut candidates = Vec::new();
190 let mut failed = Vec::new();
191 for &family in families {
192 match score_family(family, gather, model, data, &y, backend, &mut ls_ws) {
193 Ok(c) => candidates.push(c),
194 Err(e) => failed.push((family, e.to_string())),
195 }
196 }
197 if candidates.is_empty() {
198 let detail = failed
199 .iter()
200 .map(|(f, e)| format!("{f:?}: {e}"))
201 .collect::<Vec<_>>()
202 .join("; ");
203 return Err(ModelError::Unsupported {
204 message: format!(
205 "no mechanism candidates for variable {var} (failures: {detail})"
206 ),
207 });
208 }
209 candidates
210 .sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
211 let selected = policy.select(&candidates).ok_or_else(|| ModelError::Unsupported {
212 message: "selection policy produced no family".into(),
213 })?;
214 let fitted = fit_family(selected, gather, model, data, &y, backend, &mut ls_ws)?;
215 slots[node.as_usize()] = fitted.clone();
216 assignments.push(MechanismAssignment {
217 node,
218 variable: var,
219 candidates: Arc::from(candidates),
220 selected,
221 fitted,
222 failed_families: Arc::from(failed),
223 });
224 }
225
226 Ok((CompiledMechanismStore { slots: Arc::from(slots) }, assignments))
227 }
228}
229
230#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
232pub enum SelectionPolicy {
233 BestScore,
235 RequireFamily(MechanismFamily),
237}
238
239impl SelectionPolicy {
240 #[must_use]
242 pub fn select(self, candidates: &[MechanismCandidate]) -> Option<MechanismFamily> {
243 match self {
244 Self::BestScore => candidates.first().map(|c| c.family),
245 Self::RequireFamily(fam) => {
246 candidates.iter().find(|c| c.family == fam).map(|c| c.family)
247 }
248 }
249 }
250}
251
252fn is_low_cardinality(y: &[f64], max_levels: usize) -> bool {
253 let mut vals: Vec<i64> =
254 y.iter().filter(|v| v.is_finite()).map(|v| (v * 1e6).round() as i64).collect();
255 vals.sort_unstable();
256 vals.dedup();
257 !vals.is_empty() && vals.len() <= max_levels
258}
259
260fn score_family(
261 family: MechanismFamily,
262 gather: &ParentGatherPlan,
263 model: &CompiledCausalModel,
264 data: &TabularData,
265 y: &[f64],
266 backend: FaerBackend,
267 ls_ws: &mut LeastSquaresWorkspace,
268) -> Result<MechanismCandidate, ModelError> {
269 let fitted = fit_family(family, gather, model, data, y, backend, ls_ws)?;
270 let score = match &fitted {
271 MechanismSlot::LinearGaussian { intercept, coeffs, sigma }
272 | MechanismSlot::HierarchicalLinear { intercept, coeffs, sigma, .. }
273 | MechanismSlot::Bvar { intercept, coeffs, sigma } => {
274 let mse = residual_mse(gather, model, data, y, *intercept, coeffs)?;
275 -mse - sigma.ln().abs() * 0.01
276 }
277 MechanismSlot::Constant { value } => {
278 let mse = y.iter().map(|yi| (yi - value).powi(2)).sum::<f64>() / y.len().max(1) as f64;
279 -mse
280 }
281 MechanismSlot::Discrete { support, probs, logit_coeffs } => match logit_coeffs {
282 None => {
283 let ent: f64 = probs.iter().map(|p| if *p > 0.0 { -p * p.ln() } else { 0.0 }).sum();
284 -ent
285 }
286 Some(logits) => discrete_mean_loglik(gather, model, data, y, support, logits)?,
287 },
288 MechanismSlot::LinearGaussianStateSpace { process_std, obs_std, .. } => {
289 let mse = y.iter().map(|yi| yi.powi(2)).sum::<f64>() / y.len().max(1) as f64;
290 -mse - (process_std + obs_std).ln().abs() * 0.01
291 }
292 MechanismSlot::GaussianProcess { noise_std, .. } => {
293 let mse = y.iter().map(|yi| yi.powi(2)).sum::<f64>() / y.len().max(1) as f64;
294 -mse - noise_std.ln().abs() * 0.01
295 }
296 _ => f64::NEG_INFINITY,
297 };
298 Ok(MechanismCandidate {
299 family,
300 score,
301 fit_cost: 1.0 + gather.n_parents() as f64,
302 eval_cost: 1.0 + gather.n_parents() as f64,
303 })
304}
305
306fn fit_family(
307 family: MechanismFamily,
308 gather: &ParentGatherPlan,
309 model: &CompiledCausalModel,
310 data: &TabularData,
311 y: &[f64],
312 backend: FaerBackend,
313 ls_ws: &mut LeastSquaresWorkspace,
314) -> Result<MechanismSlot, ModelError> {
315 let n = y.len();
316 match family {
317 MechanismFamily::Constant => {
318 let mean = y.iter().sum::<f64>() / n.max(1) as f64;
319 Ok(MechanismSlot::Constant { value: mean })
320 }
321 MechanismFamily::Discrete => {
322 let mut pairs: Vec<(i64, f64, usize)> = Vec::new();
323 for &yi in y {
324 if !yi.is_finite() {
325 continue;
326 }
327 let key = (yi * 1e6).round() as i64;
328 if let Some(e) = pairs.iter_mut().find(|(k, _, _)| *k == key) {
329 e.2 += 1;
330 } else {
331 pairs.push((key, yi, 1));
332 }
333 }
334 if pairs.is_empty() {
335 return Err(ModelError::Shape {
336 message: "no finite values for discrete fit".into(),
337 });
338 }
339 pairs.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
341 let total = pairs.iter().map(|(_, _, c)| *c).sum::<usize>() as f64;
342 let support: Vec<f64> = pairs.iter().map(|(_, v, _)| *v).collect();
343 let probs: Vec<f64> = pairs.iter().map(|(_, _, c)| *c as f64 / total).collect();
344 let k = support.len();
345 let p = gather.n_parents();
346 if p == 0 {
347 return Ok(MechanismSlot::Discrete {
348 support: Arc::from(support),
349 probs: Arc::from(probs),
350 logit_coeffs: None,
351 });
352 }
353 let ncols = 1 + p;
356 let mut x = vec![0.0; n * ncols];
357 for r in 0..n {
358 x[r] = 1.0;
359 }
360 for (pi, &parent) in gather.parents.iter().enumerate() {
361 let var = model.output_layout.variables[parent.as_usize()];
362 let col = data.float64_values(var).map_err(ModelError::from)?;
363 let base = (1 + pi) * n;
364 x[base..base + n].copy_from_slice(&col[..n]);
365 }
366 let mut y_cat = vec![0u32; n];
367 for (r, &yi) in y.iter().enumerate() {
368 let Some(idx) = support.iter().position(|&s| (s - yi).abs() < 1e-12) else {
369 return Err(ModelError::Shape {
370 message: "discrete outcome not in fitted support".into(),
371 });
372 };
373 y_cat[r] = u32::try_from(idx).map_err(|_| ModelError::Shape {
374 message: "too many discrete categories".into(),
375 })?;
376 }
377 let fit = fit_multinomial_logit(
378 MultinomialDesignRef {
379 x_colmajor: &x,
380 nrows: n,
381 ncols,
382 y_category: &y_cat,
383 n_categories: k,
384 },
385 &backend,
386 ls_ws,
387 &GlmOptions::default(),
388 )?;
389 if !fit.converged {
392 return Err(ModelError::Numerical {
393 message: format!(
394 "multinomial logit did not converge (iters={}, deviance={})",
395 fit.iterations, fit.deviance
396 ),
397 });
398 }
399 Ok(MechanismSlot::Discrete {
400 support: Arc::from(support),
401 probs: Arc::from(probs),
402 logit_coeffs: Some(Arc::from(fit.coefficients)),
403 })
404 }
405 MechanismFamily::LinearGaussian => {
406 fit_linear_gaussian(gather, model, data, y, backend, ls_ws, 0.0)
407 }
408 MechanismFamily::HierarchicalLinear => {
409 fit_hierarchical_linear(gather, model, data, y, backend, ls_ws)
410 }
411 MechanismFamily::HierarchicalGlm => {
412 fit_hierarchical_glm(gather, model, data, y, backend, ls_ws)
413 }
414 MechanismFamily::Bvar => fit_bvar_minnesota(gather, model, data, y, backend, ls_ws),
415 MechanismFamily::LinearGaussianStateSpace => {
416 fit_lgssm_kalman_em(gather, model, data, y, backend, ls_ws)
417 }
418 MechanismFamily::GaussianProcess => {
419 #[cfg(feature = "gaussian-process")]
420 {
421 fit_gaussian_process(gather, model, data, y)
422 }
423 #[cfg(not(feature = "gaussian-process"))]
424 {
425 let _ = (gather, model, data, y, backend, ls_ws);
426 Err(ModelError::Unsupported {
427 message: "GaussianProcess requires feature `gaussian-process`".into(),
428 })
429 }
430 }
431 }
432}
433
434fn gather_parent_cols(
435 gather: &ParentGatherPlan,
436 model: &CompiledCausalModel,
437 data: &TabularData,
438) -> Result<Vec<Vec<f64>>, ModelError> {
439 let mut parent_cols: Vec<Vec<f64>> = Vec::with_capacity(gather.n_parents());
440 for &parent in gather.parents.iter() {
441 let var = model.output_layout.variables[parent.as_usize()];
442 parent_cols.push(data.float64_values(var).map_err(ModelError::from)?);
443 }
444 Ok(parent_cols)
445}
446
447fn fit_hierarchical_linear(
450 gather: &ParentGatherPlan,
451 model: &CompiledCausalModel,
452 data: &TabularData,
453 y: &[f64],
454 backend: FaerBackend,
455 ls_ws: &mut LeastSquaresWorkspace,
456) -> Result<MechanismSlot, ModelError> {
457 let n = y.len();
458 let ols = fit_linear_gaussian(gather, model, data, y, backend, ls_ws, 0.0)?;
459 let MechanismSlot::LinearGaussian { intercept: ols_int, coeffs: ols_coeffs, sigma: ols_sigma } =
460 ols
461 else {
462 return Err(ModelError::Unsupported { message: "hierarchical base fit failed".into() });
463 };
464 let p = ols_coeffs.len();
465 let mean_b2 =
468 if p == 0 { 0.0 } else { ols_coeffs.iter().map(|b| b * b).sum::<f64>() / p as f64 };
469 let tau2 = (mean_b2 - ols_sigma * ols_sigma / n.max(1) as f64).max(1e-8);
470 let mut lambda = (ols_sigma * ols_sigma / tau2).clamp(1e-6, 1e6);
471
472 let mut y_work = y.to_vec();
474 let mut group_tau = 0.0;
475 if let Some(groups) = unit_id_groups(data, y.len()) {
476 let (demeaned, tau) = demean_by_group(&y_work, &groups);
477 y_work = demeaned;
478 group_tau = tau;
479 let ols2 = fit_linear_gaussian(gather, model, data, &y_work, backend, ls_ws, 0.0)?;
481 if let MechanismSlot::LinearGaussian { coeffs, sigma, .. } = ols2 {
482 let mean_b2 = if coeffs.is_empty() {
483 0.0
484 } else {
485 coeffs.iter().map(|b| b * b).sum::<f64>() / coeffs.len() as f64
486 };
487 let tau2 = (mean_b2 - sigma * sigma / n.max(1) as f64).max(1e-8);
488 lambda = (sigma * sigma / tau2).clamp(1e-6, 1e6);
489 }
490 }
491
492 let slot = fit_linear_gaussian(gather, model, data, &y_work, backend, ls_ws, lambda)?;
493 match slot {
494 MechanismSlot::LinearGaussian { intercept, coeffs, sigma } => {
495 let intercept = if group_tau > 0.0 {
497 y.iter().sum::<f64>() / n.max(1) as f64
498 - coeffs.iter().enumerate().try_fold(0.0, |acc, (i, c)| {
499 let var = model.output_layout.variables[gather.parents[i].as_usize()];
500 let col = data.float64_values(var).map_err(ModelError::from)?;
501 let mean = col.iter().sum::<f64>() / n.max(1) as f64;
502 Ok::<_, ModelError>(acc + c * mean)
503 })?
504 } else {
505 intercept
506 };
507 let _ = (ols_int, group_tau);
508 Ok(MechanismSlot::HierarchicalLinear { intercept, coeffs, sigma, shrinkage: lambda })
509 }
510 other => Ok(other),
511 }
512}
513
514fn fit_hierarchical_glm(
518 gather: &ParentGatherPlan,
519 model: &CompiledCausalModel,
520 data: &TabularData,
521 y: &[f64],
522 backend: FaerBackend,
523 ls_ws: &mut LeastSquaresWorkspace,
524) -> Result<MechanismSlot, ModelError> {
525 let n = y.len();
526 let binary = y.iter().all(|&yi| yi == 0.0 || yi == 1.0);
527 if !binary {
528 return Err(ModelError::Unsupported {
529 message: "HierarchicalGlm requires binary {0,1} outcomes".into(),
530 });
531 }
532 let ols = fit_linear_gaussian(gather, model, data, y, backend, ls_ws, 0.0)?;
534 let lambda = match &ols {
535 MechanismSlot::LinearGaussian { coeffs, sigma, .. } => {
536 let p = coeffs.len().max(1);
537 let mean_b2 = coeffs.iter().map(|b| b * b).sum::<f64>() / p as f64;
538 let tau2 = (mean_b2 - sigma * sigma / n.max(1) as f64).max(1e-8);
539 (sigma * sigma / tau2).clamp(1e-4, 1e3)
540 }
541 _ => 1.0,
542 };
543 let p = gather.n_parents();
544 let ncols = 1 + p;
545 let mut x = vec![0.0; n * ncols];
546 for r in 0..n {
547 x[r] = 1.0;
548 }
549 for (pi, &parent) in gather.parents.iter().enumerate() {
550 let var = model.output_layout.variables[parent.as_usize()];
551 let col = data.float64_values(var).map_err(ModelError::from)?;
552 let base = (1 + pi) * n;
553 x[base..base + n].copy_from_slice(&col[..n]);
554 }
555 let opts = GlmOptions { ridge_on_separation: Some(lambda), ..Default::default() };
556 let fit = fit_glm(
557 GlmFamily::BinomialLogit,
558 GlmDesignRef { x_colmajor: &x, nrows: n, ncols, y },
559 &backend,
560 ls_ws,
561 &opts,
562 )
563 .map_err(|e| ModelError::Numerical { message: e.to_string() })?;
564 if !fit.converged {
565 return Err(ModelError::Numerical {
566 message: "hierarchical GLM logit did not converge".into(),
567 });
568 }
569 let mut logit_coeffs = vec![0.0; 2 * ncols];
571 logit_coeffs[ncols..].copy_from_slice(&fit.coefficients[..ncols]);
572 let n1 = y.iter().filter(|&&yi| yi == 1.0).count() as f64;
573 let p1 = n1 / n.max(1) as f64;
574 Ok(MechanismSlot::Discrete {
575 support: Arc::from([0.0, 1.0]),
576 probs: Arc::from([1.0 - p1, p1]),
577 logit_coeffs: Some(Arc::from(logit_coeffs)),
578 })
579}
580
581fn fit_bvar_minnesota(
583 gather: &ParentGatherPlan,
584 model: &CompiledCausalModel,
585 data: &TabularData,
586 y: &[f64],
587 backend: FaerBackend,
588 ls_ws: &mut LeastSquaresWorkspace,
589) -> Result<MechanismSlot, ModelError> {
590 let n = y.len();
591 let p = gather.n_parents();
592 let ncols = 1 + p;
593 let phi: f64 = 0.2; let mut x = vec![0.0; n * ncols];
595 for r in 0..n {
596 x[r] = 1.0;
597 }
598 for (pi, &parent) in gather.parents.iter().enumerate() {
599 let var = model.output_layout.variables[parent.as_usize()];
600 let col = data.float64_values(var).map_err(ModelError::from)?;
601 let base = (1 + pi) * n;
602 x[base..base + n].copy_from_slice(&col[..n]);
603 }
604 let extra = ncols; let mut x2 = vec![0.0; (n + extra) * ncols];
607 let mut y2 = vec![0.0; n + extra];
608 for c in 0..ncols {
609 for r in 0..n {
610 x2[c * (n + extra) + r] = x[c * n + r];
611 }
612 }
613 y2[..n].copy_from_slice(y);
614 let v0: f64 = (100.0 * phi).max(1e-6);
616 x2[n] = (1.0 / v0).sqrt();
617 for j in 0..p {
618 let lag = (j + 1) as f64;
619 let v: f64 = (phi / (lag * lag)).max(1e-8);
620 x2[(1 + j) * (n + extra) + (n + 1 + j)] = (1.0 / v).sqrt();
621 }
622 let fit = backend.least_squares(&x2, n + extra, ncols, &y2, ls_ws).map_err(ModelError::from)?;
623 let intercept = fit.coefficients[0];
624 let coeffs: Arc<[f64]> = Arc::from(fit.coefficients[1..].to_vec());
625 let sigma = (fit.rss / (n.saturating_sub(ncols)).max(1) as f64).sqrt().max(1e-8);
626 Ok(MechanismSlot::Bvar { intercept, coeffs, sigma })
627}
628
629fn fit_lgssm_kalman_em(
631 gather: &ParentGatherPlan,
632 model: &CompiledCausalModel,
633 data: &TabularData,
634 y: &[f64],
635 backend: FaerBackend,
636 ls_ws: &mut LeastSquaresWorkspace,
637) -> Result<MechanismSlot, ModelError> {
638 let lg = fit_linear_gaussian(gather, model, data, y, backend, ls_ws, 0.0)?;
639 let (intercept, coeffs, sigma) = match lg {
640 MechanismSlot::LinearGaussian { intercept, coeffs, sigma } => (intercept, coeffs, sigma),
641 _ => {
642 return Err(ModelError::Unsupported {
643 message: "lgssm fit requires linear base".into(),
644 });
645 }
646 };
647 let parent_cols = gather_parent_cols(gather, model, data)?;
648 let mut resid = vec![0.0; y.len()];
649 for r in 0..y.len() {
650 let mut pred = intercept;
651 for (p, col) in parent_cols.iter().enumerate() {
652 pred += coeffs[p] * col[r];
653 }
654 resid[r] = y[r] - pred;
655 }
656 let (a, process_std, obs_std, initial_mean) = lgssm_em(&resid, 25);
657 let _ = sigma;
658 Ok(MechanismSlot::LinearGaussianStateSpace {
659 a,
660 process_std: process_std.max(1e-8),
661 obs_std: obs_std.max(1e-8),
662 initial_mean,
663 })
664}
665
666fn lgssm_em(y: &[f64], max_iters: usize) -> (f64, f64, f64, f64) {
668 let n = y.len();
669 if n < 3 {
670 let (a, q) = fit_ar1(y);
671 return (a, q, q.max(1e-8), y.first().copied().unwrap_or(0.0));
672 }
673 let mut a = 0.8;
674 let mut q = 1.0; let mut r = 1.0; let mut x0 = y[0];
677 let p0 = 1.0;
678 for _ in 0..max_iters {
679 let (x_f, p_f, x_pred, p_pred) = crate::lgssm::kalman_filter(y, a, q, r, x0, p0);
680 let (x_s, p_s, p_lag) = crate::lgssm::rts_smooth(a, &x_f, &p_f, &x_pred, &p_pred);
681 let mut num = 0.0;
683 let mut den = 0.0;
684 for t in 1..n {
685 num += p_lag[t] + x_s[t] * x_s[t - 1];
686 den += p_s[t - 1] + x_s[t - 1] * x_s[t - 1];
687 }
688 a = if den > 1e-12 { (num / den).clamp(-0.999, 0.999) } else { a };
689 let mut q_acc = 0.0;
690 for t in 1..n {
691 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])
692 - 2.0 * a * (p_lag[t] + x_s[t] * x_s[t - 1]);
693 }
694 q = (q_acc / (n - 1) as f64).max(1e-8);
695 let mut r_acc = 0.0;
696 for t in 0..n {
697 r_acc += p_s[t] + (y[t] - x_s[t]).powi(2);
698 }
699 r = (r_acc / n as f64).max(1e-8);
700 x0 = x_s[0];
701 }
702 (a, q.sqrt(), r.sqrt(), x0)
703}
704
705fn unit_id_groups(data: &TabularData, n: usize) -> Option<Vec<u32>> {
706 let schema = data.schema();
707 for var in schema.variables() {
708 if !var.role_hints.contains(RoleHint::UnitId) {
709 continue;
710 }
711 let Ok(col) = data.float64_values(var.id) else {
712 continue;
713 };
714 if col.len() != n {
715 continue;
716 }
717 let mut groups = Vec::with_capacity(n);
718 let mut ok = true;
719 for &v in &col {
720 if !v.is_finite() {
721 ok = false;
722 break;
723 }
724 groups.push(v.round() as u32);
725 }
726 if ok {
727 let mut uniq = groups.clone();
728 uniq.sort_unstable();
729 uniq.dedup();
730 if uniq.len() >= 2 && uniq.len() < n {
731 return Some(groups);
732 }
733 }
734 }
735 None
736}
737
738fn demean_by_group(y: &[f64], groups: &[u32]) -> (Vec<f64>, f64) {
739 let mut sums = std::collections::HashMap::<u32, (f64, usize)>::new();
740 for (&g, &yi) in groups.iter().zip(y.iter()) {
741 let e = sums.entry(g).or_insert((0.0, 0));
742 e.0 += yi;
743 e.1 += 1;
744 }
745 let grand = y.iter().sum::<f64>() / y.len().max(1) as f64;
746 let mut tau2 = 0.0;
747 let mut gcount = 0usize;
748 for (s, c) in sums.values() {
749 let m = s / (*c).max(1) as f64;
750 tau2 += (m - grand).powi(2);
751 gcount += 1;
752 }
753 let tau = (tau2 / gcount.max(1) as f64).sqrt();
754 let out: Vec<f64> = groups
755 .iter()
756 .zip(y.iter())
757 .map(|(&g, &yi)| {
758 let (s, c) = sums[&g];
759 yi - s / c.max(1) as f64
760 })
761 .collect();
762 (out, tau)
763}
764
765fn fit_ar1(series: &[f64]) -> (f64, f64) {
766 let n = series.len();
767 if n < 3 {
768 return (0.0, series.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-8));
769 }
770 let mut num = 0.0;
771 let mut den = 0.0;
772 for t in 1..n {
773 num += series[t] * series[t - 1];
774 den += series[t - 1] * series[t - 1];
775 }
776 let a = if den > 1e-12 { (num / den).clamp(-0.999, 0.999) } else { 0.0 };
777 let mut rss = 0.0;
778 for t in 1..n {
779 let e = series[t] - a * series[t - 1];
780 rss += e * e;
781 }
782 let process_std = (rss / (n - 1) as f64).sqrt().max(1e-8);
783 (a, process_std)
784}
785
786fn fit_linear_gaussian(
787 gather: &ParentGatherPlan,
788 model: &CompiledCausalModel,
789 data: &TabularData,
790 y: &[f64],
791 backend: FaerBackend,
792 ls_ws: &mut LeastSquaresWorkspace,
793 ridge: f64,
794) -> Result<MechanismSlot, ModelError> {
795 let n = y.len();
796 let p = gather.n_parents();
797 let ncols = 1 + p;
798 let mut x = vec![0.0; n * ncols];
799 for r in 0..n {
800 x[r] = 1.0;
801 }
802 for (pi, &parent) in gather.parents.iter().enumerate() {
803 let var = model.output_layout.variables[parent.as_usize()];
804 let col = data.float64_values(var).map_err(ModelError::from)?;
805 let base = (1 + pi) * n;
806 x[base..base + n].copy_from_slice(&col[..n]);
807 }
808 if ridge > 0.0 {
809 let extra = p;
811 let mut x2 = vec![0.0; (n + extra) * ncols];
812 let mut y2 = vec![0.0; n + extra];
813 for c in 0..ncols {
814 for r in 0..n {
815 x2[c * (n + extra) + r] = x[c * n + r];
816 }
817 }
818 y2[..n].copy_from_slice(y);
819 let sqrt_r = ridge.sqrt();
820 for j in 0..p {
821 x2[(1 + j) * (n + extra) + (n + j)] = sqrt_r;
822 }
823 let fit =
824 backend.least_squares(&x2, n + extra, ncols, &y2, ls_ws).map_err(ModelError::from)?;
825 let intercept = fit.coefficients[0];
826 let coeffs: Arc<[f64]> = Arc::from(fit.coefficients[1..].to_vec());
827 let sigma = (fit.rss / (n.saturating_sub(ncols)).max(1) as f64).sqrt().max(1e-8);
828 return Ok(MechanismSlot::LinearGaussian { intercept, coeffs, sigma });
829 }
830 let fit = backend.least_squares(&x, n, ncols, y, ls_ws).map_err(ModelError::from)?;
831 let intercept = fit.coefficients[0];
832 let coeffs: Arc<[f64]> = Arc::from(fit.coefficients[1..].to_vec());
833 let sigma = (fit.rss / (n.saturating_sub(ncols)).max(1) as f64).sqrt().max(1e-8);
834 Ok(MechanismSlot::LinearGaussian { intercept, coeffs, sigma })
835}
836
837#[cfg(feature = "gaussian-process")]
838fn fit_gaussian_process(
839 gather: &ParentGatherPlan,
840 model: &CompiledCausalModel,
841 data: &TabularData,
842 y: &[f64],
843) -> Result<MechanismSlot, ModelError> {
844 let n = y.len();
845 let p = gather.n_parents();
846 if p == 0 {
847 return Err(ModelError::Unsupported {
848 message: "GaussianProcess requires at least one parent".into(),
849 });
850 }
851 let parent_cols = gather_parent_cols(gather, model, data)?;
852 let mut x_train = vec![0.0; n * p];
853 for r in 0..n {
854 for c in 0..p {
855 x_train[r * p + c] = parent_cols[c][r];
856 }
857 }
858 let variance = 1.0;
860 let length_scales = [0.25, 0.5, 1.0, 2.0, 4.0];
861 let noise_stds = [0.05, 0.1, 0.2, 0.5];
862 let mut best = None::<(f64, f64, f64, Vec<f64>)>; for &length_scale in &length_scales {
864 for &noise_std in &noise_stds {
865 let mut k = vec![0.0; n * n];
866 let inv_l2 = 1.0 / (length_scale * length_scale);
867 for i in 0..n {
868 for j in i..n {
869 let mut d2 = 0.0;
870 for c in 0..p {
871 let d = x_train[i * p + c] - x_train[j * p + c];
872 d2 += d * d;
873 }
874 let kij = variance * (-0.5 * d2 * inv_l2).exp();
875 k[i * n + j] = kij;
876 k[j * n + i] = kij;
877 }
878 k[i * n + i] += noise_std * noise_std;
879 }
880 let Ok(alpha) = solve_dense(&k, n, y) else {
881 continue;
882 };
883 let mut y_alpha = 0.0;
885 for i in 0..n {
886 y_alpha += y[i] * alpha[i];
887 }
888 let logdet_proxy: f64 = (0..n).map(|i| k[i * n + i].abs().max(1e-12).ln()).sum();
889 let nlml = 0.5 * y_alpha + 0.5 * logdet_proxy;
890 match &best {
891 Some((best_nlml, ..)) if nlml >= *best_nlml => {}
892 _ => best = Some((nlml, length_scale, noise_std, alpha)),
893 }
894 }
895 }
896 let (_nlml, length_scale, noise_std, alpha) = best.ok_or_else(|| ModelError::Numerical {
897 message: "GP hyperparameter search failed".into(),
898 })?;
899 Ok(MechanismSlot::GaussianProcess {
900 length_scale,
901 variance,
902 noise_std,
903 x_train: Arc::from(x_train),
904 n_train: n,
905 n_parents: p,
906 alpha: Arc::from(alpha),
907 })
908}
909
910#[cfg(feature = "gaussian-process")]
911fn solve_dense(a: &[f64], n: usize, b: &[f64]) -> Result<Vec<f64>, ModelError> {
912 let mut m = a.to_vec();
913 let mut x = b.to_vec();
914 for col in 0..n {
915 let mut piv = col;
916 for r in (col + 1)..n {
917 if m[r * n + col].abs() > m[piv * n + col].abs() {
918 piv = r;
919 }
920 }
921 if m[piv * n + col].abs() < 1e-12 {
922 return Err(ModelError::Numerical { message: "singular GP kernel".into() });
923 }
924 if piv != col {
925 for c in 0..n {
926 m.swap(col * n + c, piv * n + c);
927 }
928 x.swap(col, piv);
929 }
930 let diag = m[col * n + col];
931 for r in (col + 1)..n {
932 let f = m[r * n + col] / diag;
933 for c in col..n {
934 m[r * n + c] -= f * m[col * n + c];
935 }
936 x[r] -= f * x[col];
937 }
938 }
939 for col in (0..n).rev() {
940 let mut acc = x[col];
941 for c in (col + 1)..n {
942 acc -= m[col * n + c] * x[c];
943 }
944 x[col] = acc / m[col * n + col];
945 }
946 Ok(x)
947}
948
949fn residual_mse(
952 gather: &ParentGatherPlan,
953 model: &CompiledCausalModel,
954 data: &TabularData,
955 y: &[f64],
956 intercept: f64,
957 coeffs: &[f64],
958) -> Result<f64, ModelError> {
959 let n = y.len();
960 let mut sse = 0.0;
961 let mut parent_cols: Vec<Vec<f64>> = Vec::with_capacity(gather.n_parents());
962 for &parent in gather.parents.iter() {
963 let var = model.output_layout.variables[parent.as_usize()];
964 parent_cols.push(data.float64_values(var).map_err(ModelError::from)?);
965 }
966 for r in 0..n {
967 let mut pred = intercept;
968 for (p, col) in parent_cols.iter().enumerate() {
969 pred += coeffs[p] * col[r];
970 }
971 let e = y[r] - pred;
972 sse += e * e;
973 }
974 Ok(sse / n.max(1) as f64)
975}
976
977fn discrete_mean_loglik(
978 gather: &ParentGatherPlan,
979 model: &CompiledCausalModel,
980 data: &TabularData,
981 y: &[f64],
982 support: &[f64],
983 logits: &[f64],
984) -> Result<f64, ModelError> {
985 let n = y.len();
986 let p = gather.n_parents();
987 let mut parent_mat = vec![0.0; n * p.max(1)];
988 for (pi, &parent) in gather.parents.iter().enumerate() {
989 let var = model.output_layout.variables[parent.as_usize()];
990 let col = data.float64_values(var).map_err(ModelError::from)?;
991 let base = pi * n;
992 parent_mat[base..base + n].copy_from_slice(&col[..n]);
993 }
994 let parents = ParentBatch { n_rows: n, n_parents: p, values: &parent_mat[..n * p] };
995 let slot = MechanismSlot::Discrete {
996 support: Arc::from(support.to_vec()),
997 probs: Arc::from(vec![0.0; support.len()]),
998 logit_coeffs: Some(Arc::from(logits.to_vec())),
999 };
1000 let mut lp = vec![0.0; n];
1001 log_prob_column(&slot, y, parents, &mut lp)?;
1002 Ok(lp.iter().sum::<f64>() / n.max(1) as f64)
1003}
1004
1005#[derive(Clone, Debug)]
1007pub struct ModelCollection {
1008 pub models: Arc<[CompiledCausalModel]>,
1010 pub graph_keys: Arc<[u64]>,
1012 pub weights: Arc<[f64]>,
1014}
1015
1016impl ModelCollection {
1017 pub fn new(
1023 models: impl Into<Arc<[CompiledCausalModel]>>,
1024 graph_keys: impl Into<Arc<[u64]>>,
1025 weights: impl Into<Arc<[f64]>>,
1026 ) -> Result<Self, ModelError> {
1027 let models = models.into();
1028 let graph_keys = graph_keys.into();
1029 let weights = weights.into();
1030 if models.len() != graph_keys.len() || models.len() != weights.len() {
1031 return Err(ModelError::Shape { message: "ModelCollection length mismatch".into() });
1032 }
1033 let sum: f64 = weights.iter().sum();
1034 if sum.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) {
1035 return Err(ModelError::Shape {
1036 message: "ModelCollection weights non-positive".into(),
1037 });
1038 }
1039 let weights: Arc<[f64]> = Arc::from(weights.iter().map(|w| w / sum).collect::<Vec<_>>());
1040 Ok(Self { models, graph_keys, weights })
1041 }
1042
1043 #[must_use]
1045 pub fn len(&self) -> usize {
1046 self.models.len()
1047 }
1048
1049 #[must_use]
1051 pub fn is_empty(&self) -> bool {
1052 self.models.is_empty()
1053 }
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058 use super::*;
1059 use antecedent_core::{
1060 CausalSchemaBuilder, MeasurementSpec, RoleHint, SmallRoleSet, ValueType, VariableId,
1061 };
1062 use antecedent_data::column::{Float64Column, ValidityBitmap};
1063 use antecedent_data::{OwnedColumn, OwnedColumnarStorage, TabularData};
1064 use antecedent_graph::{Dag, DenseNodeId};
1065
1066 fn toy_data() -> (TabularData, Dag) {
1067 let n = 40usize;
1068 let mut b = CausalSchemaBuilder::new();
1069 b.add_variable(
1070 "x",
1071 ValueType::Continuous,
1072 SmallRoleSet::from_hint(RoleHint::Context),
1073 None,
1074 None,
1075 MeasurementSpec::default(),
1076 )
1077 .unwrap();
1078 b.add_variable(
1079 "y",
1080 ValueType::Continuous,
1081 SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
1082 None,
1083 None,
1084 MeasurementSpec::default(),
1085 )
1086 .unwrap();
1087 let schema = b.build().unwrap();
1088 let mut xv = vec![0.0; n];
1089 let mut yv = vec![0.0; n];
1090 for i in 0..n {
1091 xv[i] = i as f64 * 0.1;
1092 yv[i] = 1.0 + 2.0 * xv[i];
1093 }
1094 let validity = ValidityBitmap::all_valid(n);
1095 let cols = vec![
1096 OwnedColumn::Float64(
1097 Float64Column::new(VariableId::from_raw(0), Arc::from(xv), validity.clone())
1098 .unwrap(),
1099 ),
1100 OwnedColumn::Float64(
1101 Float64Column::new(VariableId::from_raw(1), Arc::from(yv), validity).unwrap(),
1102 ),
1103 ];
1104 let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1105 let mut g = Dag::with_variables(2);
1106 g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
1107 (TabularData::new(storage), g)
1108 }
1109
1110 #[test]
1111 fn auto_assign_linear_chain() {
1112 let (data, g) = toy_data();
1113 let compiled = CompiledCausalModel::compile(g).unwrap();
1114 let reg = MechanismRegistry::standard();
1115 let (store, assigns) =
1116 reg.assign_and_fit(&compiled, &data, SelectionPolicy::BestScore).unwrap();
1117 assert_eq!(assigns.len(), 2);
1118 assert!(matches!(
1119 store.get(DenseNodeId::from_raw(1)),
1120 MechanismSlot::LinearGaussian { .. }
1121 ));
1122 }
1123
1124 #[test]
1125 fn bayesian_families_fit_hierarchical_and_bvar() {
1126 let (data, g) = toy_data();
1127 let compiled = CompiledCausalModel::compile(g).unwrap();
1128 let reg = MechanismRegistry::with_bayesian_families();
1129 let (store, _) = reg
1130 .assign_and_fit(
1131 &compiled,
1132 &data,
1133 SelectionPolicy::RequireFamily(MechanismFamily::HierarchicalLinear),
1134 )
1135 .unwrap();
1136 assert!(matches!(
1137 store.get(DenseNodeId::from_raw(1)),
1138 MechanismSlot::HierarchicalLinear { .. }
1139 ));
1140 let (store2, _) = reg
1141 .assign_and_fit(&compiled, &data, SelectionPolicy::RequireFamily(MechanismFamily::Bvar))
1142 .unwrap();
1143 assert!(matches!(store2.get(DenseNodeId::from_raw(1)), MechanismSlot::Bvar { .. }));
1144 let (store3, _) = reg
1145 .assign_and_fit(
1146 &compiled,
1147 &data,
1148 SelectionPolicy::RequireFamily(MechanismFamily::LinearGaussianStateSpace),
1149 )
1150 .unwrap();
1151 assert!(matches!(
1152 store3.get(DenseNodeId::from_raw(1)),
1153 MechanismSlot::LinearGaussianStateSpace { .. }
1154 ));
1155 }
1156
1157 #[test]
1158 fn discrete_conditional_multinomial_logit_mle() {
1159 let n = 120usize;
1160 let mut b = CausalSchemaBuilder::new();
1161 b.add_variable(
1162 "x",
1163 ValueType::Continuous,
1164 SmallRoleSet::from_hint(RoleHint::Context),
1165 None,
1166 None,
1167 MeasurementSpec::default(),
1168 )
1169 .unwrap();
1170 b.add_variable(
1171 "y",
1172 ValueType::Continuous,
1173 SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
1174 None,
1175 None,
1176 MeasurementSpec::default(),
1177 )
1178 .unwrap();
1179 let schema = b.build().unwrap();
1180 let mut xv = vec![0.0; n];
1181 let mut yv = vec![0.0; n];
1182 for i in 0..n {
1183 let t = if i < n / 2 { 0.0 } else { 1.0 };
1184 xv[i] = t;
1185 yv[i] = if i % 8 == 0 { 1.0 - t } else { t };
1187 }
1188 let validity = ValidityBitmap::all_valid(n);
1189 let cols = vec![
1190 OwnedColumn::Float64(
1191 Float64Column::new(VariableId::from_raw(0), Arc::from(xv), validity.clone())
1192 .unwrap(),
1193 ),
1194 OwnedColumn::Float64(
1195 Float64Column::new(VariableId::from_raw(1), Arc::from(yv), validity).unwrap(),
1196 ),
1197 ];
1198 let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1199 let data = TabularData::new(storage);
1200 let mut g = Dag::with_variables(2);
1201 g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
1202 let compiled = CompiledCausalModel::compile(g).unwrap();
1203 let reg = MechanismRegistry::standard();
1204 let (store, _) = reg
1205 .assign_and_fit(
1206 &compiled,
1207 &data,
1208 SelectionPolicy::RequireFamily(MechanismFamily::Discrete),
1209 )
1210 .unwrap();
1211 let MechanismSlot::Discrete { support, logit_coeffs, .. } =
1212 store.get(DenseNodeId::from_raw(1))
1213 else {
1214 panic!("expected discrete mechanism");
1215 };
1216 let logits = logit_coeffs.as_ref().expect("parent-conditional logits");
1217 assert_eq!(support.len(), 2);
1218 assert_eq!(logits.len(), 2 * 2); assert!(logits[0].abs() < 1e-12 && logits[1].abs() < 1e-12);
1221 assert!(logits[3] > 0.5, "slope={}", logits[3]);
1223 }
1224}