Skip to main content

scirs2_stats/
bayesian_advanced.rs

1//! Advanced Bayesian statistical methods
2//!
3//! This module extends the existing Bayesian capabilities with:
4//! - Advanced hierarchical models
5//! - Bayesian model selection and comparison
6//! - Non-conjugate Bayesian inference
7//! - Robust Bayesian methods
8//! - Bayesian neural networks
9//! - Gaussian processes
10//! - Advanced MCMC diagnostics
11
12use crate::error::{StatsError, StatsResult};
13use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2, ScalarOperand};
14use scirs2_core::numeric::{Float, NumAssign, NumCast, One, Zero};
15use scirs2_core::{simd_ops::SimdUnifiedOps, validation::*};
16use std::collections::HashMap;
17use std::marker::PhantomData;
18
19mod bnn_train;
20mod diagnostics;
21mod glm;
22mod model_fit;
23
24pub use bnn_train::BnnTrainingConfig;
25
26/// Convenience trait bundling every numeric capability the advanced Bayesian
27/// routines in this module need: SIMD kernels (`SimdUnifiedOps`), the linear
28/// algebra used for Laplace/Gaussian-process posteriors (`scirs2-linalg`
29/// requires `NumAssign + Sum + ScalarOperand + 'static`), and safe
30/// round-tripping through `f64` for RNG draws and special functions.
31///
32/// In practice this is only ever instantiated for `f32`/`f64`, the two
33/// floating types `SimdUnifiedOps` supports, so widening the bound here (over
34/// the narrower bounds the individual `impl` blocks used before) does not
35/// restrict any real caller.
36pub trait AdvancedBayesianFloat:
37    Float
38    + NumCast
39    + NumAssign
40    + SimdUnifiedOps
41    + Zero
42    + One
43    + PartialOrd
44    + Copy
45    + Send
46    + Sync
47    + std::fmt::Display
48    + std::iter::Sum<Self>
49    + ScalarOperand
50    + 'static
51{
52}
53
54impl<T> AdvancedBayesianFloat for T where
55    T: Float
56        + NumCast
57        + NumAssign
58        + SimdUnifiedOps
59        + Zero
60        + One
61        + PartialOrd
62        + Copy
63        + Send
64        + Sync
65        + std::fmt::Display
66        + std::iter::Sum<T>
67        + ScalarOperand
68        + 'static
69{
70}
71
72/// Advanced Bayesian model comparison framework
73#[derive(Debug, Clone)]
74pub struct BayesianModelComparison<F> {
75    /// Collection of models to compare
76    pub models: Vec<BayesianModel<F>>,
77    /// Model comparison criteria
78    pub criteria: Vec<ModelSelectionCriterion>,
79    /// Cross-validation configuration
80    pub cv_config: CrossValidationConfig,
81    /// Parallel processing configuration
82    pub parallel_config: ParallelConfig,
83}
84
85/// Individual Bayesian model for comparison
86#[derive(Debug, Clone)]
87pub struct BayesianModel<F> {
88    /// Model identifier
89    pub id: String,
90    /// Model type
91    pub model_type: ModelType,
92    /// Prior specification
93    pub prior: AdvancedPrior<F>,
94    /// Likelihood specification
95    pub likelihood: LikelihoodType,
96    /// Model complexity (for complexity penalties)
97    pub complexity: f64,
98}
99
100/// Advanced prior specifications
101#[derive(Debug, Clone)]
102pub enum AdvancedPrior<F> {
103    /// Standard conjugate priors
104    Conjugate { parameters: HashMap<String, F> },
105    /// Hierarchical priors with hyperpriors
106    Hierarchical { levels: Vec<PriorLevel<F>> },
107    /// Mixture of priors
108    Mixture {
109        components: Vec<PriorComponent<F>>,
110        weights: Array1<F>,
111    },
112    /// Sparse inducing priors (e.g., horseshoe, spike-and-slab)
113    Sparse {
114        sparsity_type: SparsityType,
115        sparsity_params: HashMap<String, F>,
116    },
117    /// Non-parametric priors (e.g., Dirichlet process)
118    NonParametric {
119        process_type: NonParametricProcess,
120        concentration: F,
121    },
122}
123
124/// Prior level in hierarchical model
125#[derive(Debug, Clone)]
126pub struct PriorLevel<F> {
127    /// Level identifier
128    pub level_id: String,
129    /// Distribution type at this level
130    pub distribution: DistributionType<F>,
131    /// Dependencies on other levels
132    pub dependencies: Vec<String>,
133}
134
135/// Prior component in mixture
136#[derive(Debug, Clone)]
137pub struct PriorComponent<F> {
138    /// Component weight
139    pub weight: F,
140    /// Component distribution
141    pub distribution: DistributionType<F>,
142}
143
144/// Distribution types for priors and likelihoods
145pub enum DistributionType<F> {
146    Normal {
147        mean: F,
148        precision: F,
149    },
150    Gamma {
151        shape: F,
152        rate: F,
153    },
154    Beta {
155        alpha: F,
156        beta: F,
157    },
158    InverseGamma {
159        shape: F,
160        scale: F,
161    },
162    Exponential {
163        rate: F,
164    },
165    Uniform {
166        lower: F,
167        upper: F,
168    },
169    StudentT {
170        degrees_freedom: F,
171        location: F,
172        scale: F,
173    },
174    Laplace {
175        location: F,
176        scale: F,
177    },
178    Horseshoe {
179        tau: F,
180    },
181    Custom {
182        log_density: Box<dyn Fn(F) -> F + Send + Sync>,
183        parameters: HashMap<String, F>,
184    },
185}
186
187impl<F: std::fmt::Debug> std::fmt::Debug for DistributionType<F> {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        match self {
190            DistributionType::Normal { mean, precision } => f
191                .debug_struct("Normal")
192                .field("mean", mean)
193                .field("precision", precision)
194                .finish(),
195            DistributionType::Gamma { shape, rate } => f
196                .debug_struct("Gamma")
197                .field("shape", shape)
198                .field("rate", rate)
199                .finish(),
200            DistributionType::Beta { alpha, beta } => f
201                .debug_struct("Beta")
202                .field("alpha", alpha)
203                .field("beta", beta)
204                .finish(),
205            DistributionType::Uniform { lower, upper } => f
206                .debug_struct("Uniform")
207                .field("lower", lower)
208                .field("upper", upper)
209                .finish(),
210            DistributionType::InverseGamma { shape, scale } => f
211                .debug_struct("InverseGamma")
212                .field("shape", shape)
213                .field("scale", scale)
214                .finish(),
215            DistributionType::StudentT {
216                degrees_freedom,
217                location,
218                scale,
219            } => f
220                .debug_struct("StudentT")
221                .field("degrees_freedom", degrees_freedom)
222                .field("location", location)
223                .field("scale", scale)
224                .finish(),
225            DistributionType::Exponential { rate } => {
226                f.debug_struct("Exponential").field("rate", rate).finish()
227            }
228            DistributionType::Laplace { location, scale } => f
229                .debug_struct("Laplace")
230                .field("location", location)
231                .field("scale", scale)
232                .finish(),
233            DistributionType::Horseshoe { tau } => {
234                f.debug_struct("Horseshoe").field("tau", tau).finish()
235            }
236            DistributionType::Custom { parameters, .. } => f
237                .debug_struct("Custom")
238                .field("parameters", parameters)
239                .field("log_density", &"<function>")
240                .finish(),
241        }
242    }
243}
244
245impl<F: Clone> Clone for DistributionType<F> {
246    fn clone(&self) -> Self {
247        match self {
248            DistributionType::Normal { mean, precision } => DistributionType::Normal {
249                mean: mean.clone(),
250                precision: precision.clone(),
251            },
252            DistributionType::Gamma { shape, rate } => DistributionType::Gamma {
253                shape: shape.clone(),
254                rate: rate.clone(),
255            },
256            DistributionType::Beta { alpha, beta } => DistributionType::Beta {
257                alpha: alpha.clone(),
258                beta: beta.clone(),
259            },
260            DistributionType::Uniform { lower, upper } => DistributionType::Uniform {
261                lower: lower.clone(),
262                upper: upper.clone(),
263            },
264            DistributionType::InverseGamma { shape, scale } => DistributionType::InverseGamma {
265                shape: shape.clone(),
266                scale: scale.clone(),
267            },
268            DistributionType::StudentT {
269                degrees_freedom,
270                location,
271                scale,
272            } => DistributionType::StudentT {
273                degrees_freedom: degrees_freedom.clone(),
274                location: location.clone(),
275                scale: scale.clone(),
276            },
277            DistributionType::Exponential { rate } => {
278                DistributionType::Exponential { rate: rate.clone() }
279            }
280            DistributionType::Horseshoe { tau } => DistributionType::Horseshoe { tau: tau.clone() },
281            DistributionType::Laplace { location, scale } => DistributionType::Laplace {
282                location: location.clone(),
283                scale: scale.clone(),
284            },
285            DistributionType::Custom { parameters: _, .. } => {
286                // For Custom variant with function pointer, we can't actually clone the function
287                // So we'll create a placeholder that will panic if used
288                panic!("Cannot clone DistributionType::Custom with function pointer")
289            }
290        }
291    }
292}
293
294/// Sparsity-inducing prior types
295#[derive(Debug, Clone, Copy)]
296pub enum SparsityType {
297    /// Horseshoe prior for global-local shrinkage
298    Horseshoe,
299    /// Spike-and-slab for variable selection
300    SpikeAndSlab,
301    /// LASSO (Laplace) prior
302    Lasso,
303    /// Elastic net prior
304    ElasticNet,
305    /// Finnish horseshoe
306    FinnishHorseshoe,
307}
308
309/// Non-parametric process types
310#[derive(Debug, Clone, Copy)]
311pub enum NonParametricProcess {
312    /// Dirichlet process
313    DirichletProcess,
314    /// Pitman-Yor process
315    PitmanYor,
316    /// Chinese restaurant process
317    ChineseRestaurant,
318    /// Indian buffet process
319    IndianBuffet,
320}
321
322/// Model types for Bayesian analysis
323#[derive(Debug, Clone)]
324pub enum ModelType {
325    /// Linear regression with various priors
326    LinearRegression,
327    /// Logistic regression
328    LogisticRegression,
329    /// Generalized linear model
330    GeneralizedLinear { family: GLMFamily },
331    /// Hierarchical linear model
332    HierarchicalLinear { levels: usize },
333    /// Gaussian process regression
334    GaussianProcess { kernel: KernelType },
335    /// Bayesian neural network
336    BayesianNeuralNetwork {
337        layers: Vec<usize>,
338        activation: ActivationType,
339    },
340    /// State space model
341    StateSpace {
342        state_dim: usize,
343        observation_dim: usize,
344    },
345    /// Mixture model
346    Mixture {
347        components: usize,
348        component_type: ComponentType,
349    },
350}
351
352/// GLM family types
353#[derive(Debug, Clone, Copy)]
354pub enum GLMFamily {
355    Gaussian,
356    Binomial,
357    Poisson,
358    Gamma,
359    InverseGaussian,
360    NegativeBinomial,
361}
362
363/// Kernel types for Gaussian processes
364#[derive(Debug, Clone)]
365pub enum KernelType {
366    RBF { length_scale: f64 },
367    Matern { nu: f64, length_scale: f64 },
368    Periodic { period: f64, length_scale: f64 },
369    Linear { variance: f64 },
370    Polynomial { degree: usize, variance: f64 },
371    WhiteNoise { variance: f64 },
372    Sum { kernels: Vec<KernelType> },
373    Product { kernels: Vec<KernelType> },
374}
375
376/// Activation functions for Bayesian neural networks
377#[derive(Debug, Clone, Copy)]
378pub enum ActivationType {
379    ReLU,
380    Sigmoid,
381    Tanh,
382    Swish,
383    GELU,
384}
385
386/// Component types for mixture models
387#[derive(Debug, Clone, Copy)]
388pub enum ComponentType {
389    Gaussian,
390    StudentT,
391    Laplace,
392    Skewed,
393}
394
395/// Likelihood types
396#[derive(Debug, Clone, Copy)]
397pub enum LikelihoodType {
398    Gaussian,
399    Binomial,
400    Poisson,
401    Gamma,
402    Beta,
403    Exponential,
404    StudentT,
405    Laplace,
406    Robust,
407}
408
409/// Model selection criteria
410#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
411pub enum ModelSelectionCriterion {
412    /// Deviance Information Criterion
413    DIC,
414    /// Watanabe-Akaike Information Criterion
415    WAIC,
416    /// Leave-One-Out Cross-Validation
417    LooCv,
418    /// Marginal Likelihood (Bayes Factor)
419    MarginalLikelihood,
420    /// Posterior Predictive Loss
421    PPL,
422    /// Cross-Validation Information Criterion
423    CVIC,
424}
425
426/// Cross-validation configuration
427#[derive(Debug, Clone)]
428pub struct CrossValidationConfig {
429    /// Number of folds for k-fold CV
430    pub k_folds: usize,
431    /// Number of Monte Carlo samples
432    pub mc_samples: usize,
433    /// Random seed for reproducibility
434    pub seed: Option<u64>,
435    /// Stratification for classification
436    pub stratify: bool,
437}
438
439/// Parallel processing configuration
440#[derive(Debug, Clone)]
441pub struct ParallelConfig {
442    /// Number of parallel chains/threads
443    pub num_chains: usize,
444    /// Enable parallel model fitting
445    pub parallel_models: bool,
446    /// Enable parallel cross-validation
447    pub parallel_cv: bool,
448}
449
450/// Advanced Bayesian regression with non-conjugate methods
451#[derive(Debug, Clone)]
452pub struct AdvancedBayesianRegression<F> {
453    /// Model specification
454    pub model: BayesianModel<F>,
455    /// MCMC configuration
456    pub mcmc_config: MCMCConfig,
457    /// Variational inference configuration
458    pub vi_config: VIConfig,
459    _phantom: PhantomData<F>,
460}
461
462/// MCMC configuration for non-conjugate models
463#[derive(Debug, Clone)]
464pub struct MCMCConfig {
465    /// Number of MCMC samples
466    pub n_samples_: usize,
467    /// Number of burn-in samples
468    pub n_burnin: usize,
469    /// Thinning interval
470    pub thin: usize,
471    /// Number of parallel chains
472    pub n_chains: usize,
473    /// Adaptation period for step sizes
474    pub adaptation_period: usize,
475    /// Target acceptance rate
476    pub target_acceptance: f64,
477    /// Enable No-U-Turn Sampler (NUTS)
478    pub use_nuts: bool,
479    /// Enable Hamiltonian Monte Carlo
480    pub use_hmc: bool,
481}
482
483/// Variational inference configuration
484#[derive(Debug, Clone)]
485pub struct VIConfig {
486    /// Maximum iterations
487    pub max_iter: usize,
488    /// Convergence tolerance
489    pub tolerance: f64,
490    /// Learning rate for gradient-based VI
491    pub learning_rate: f64,
492    /// Variational family type
493    pub family: VariationalFamily,
494    /// Number of Monte Carlo samples for ELBO estimation
495    pub n_mc_samples: usize,
496}
497
498/// Variational family types
499#[derive(Debug, Clone, Copy)]
500pub enum VariationalFamily {
501    /// Mean-field (factorized) Gaussian
502    MeanFieldGaussian,
503    /// Full-rank Gaussian
504    FullRankGaussian,
505    /// Normalizing flows
506    NormalizingFlow,
507    /// Mixture of Gaussians
508    MixtureGaussian,
509}
510
511/// Gaussian process regression implementation
512#[derive(Debug, Clone)]
513pub struct BayesianGaussianProcess<F> {
514    /// Input data
515    pub x_train: Array2<F>,
516    /// Output data
517    pub y_train: Array1<F>,
518    /// Kernel function
519    pub kernel: KernelType,
520    /// Noise level
521    pub noise_level: F,
522    /// Hyperpriors for kernel parameters
523    pub hyperpriors: HashMap<String, DistributionType<F>>,
524    /// MCMC samples of hyperparameters
525    pub hyperparameter_samples: Option<Array2<F>>,
526}
527
528/// Bayesian neural network implementation
529#[derive(Debug, Clone)]
530pub struct BayesianNeuralNetwork<F> {
531    /// Network architecture
532    pub architecture: Vec<usize>,
533    /// Activation functions per layer
534    pub activations: Vec<ActivationType>,
535    /// Weight priors
536    pub weight_priors: Vec<DistributionType<F>>,
537    /// Bias priors
538    pub bias_priors: Vec<DistributionType<F>>,
539    /// Trained posterior ensemble of weights: `weight_samples[m][l]` is the
540    /// weight matrix of layer `l` for ensemble member `m`. Populated by
541    /// [`BayesianNeuralNetwork::fit`]; `None` until then.
542    pub weight_samples: Option<Vec<Vec<Array2<F>>>>,
543    /// Trained posterior ensemble of biases: `bias_samples[m][l]` is the bias
544    /// vector of layer `l` for ensemble member `m`. Populated by
545    /// [`BayesianNeuralNetwork::fit`]; `None` until then.
546    pub bias_samples: Option<Vec<Vec<Array1<F>>>>,
547}
548
549/// Results from Bayesian model comparison
550#[derive(Debug, Clone)]
551pub struct ModelComparisonResult<F> {
552    /// Model rankings by each criterion
553    pub rankings: HashMap<ModelSelectionCriterion, Vec<String>>,
554    /// Information criteria values
555    pub ic_values: HashMap<String, HashMap<ModelSelectionCriterion, F>>,
556    /// Bayes factors between models
557    pub bayes_factors: Array2<F>,
558    /// Model weights (posterior probabilities)
559    pub model_weights: HashMap<String, F>,
560    /// Cross-validation results
561    pub cv_results: HashMap<String, CrossValidationResult<F>>,
562    /// Best model by each criterion
563    pub best_models: HashMap<ModelSelectionCriterion, String>,
564}
565
566/// Cross-validation results
567#[derive(Debug, Clone)]
568pub struct CrossValidationResult<F> {
569    /// Mean cross-validation score
570    pub mean_score: F,
571    /// Standard error of CV score
572    pub std_error: F,
573    /// Individual fold scores
574    pub fold_scores: Array1<F>,
575    /// Effective number of parameters
576    pub effective_n_params: F,
577}
578
579/// Advanced Bayesian inference result
580#[derive(Debug, Clone)]
581pub struct AdvancedBayesianResult<F> {
582    /// Posterior samples
583    pub posterior_samples: Array2<F>,
584    /// Posterior summary statistics
585    pub posterior_summary: PosteriorSummary<F>,
586    /// MCMC diagnostics
587    pub diagnostics: MCMCDiagnostics<F>,
588    /// Model fit metrics
589    pub model_fit: ModelFitMetrics<F>,
590    /// Predictive distributions
591    pub predictions: PredictiveDistribution<F>,
592}
593
594/// Posterior summary statistics
595#[derive(Debug, Clone)]
596pub struct PosteriorSummary<F> {
597    /// Posterior means
598    pub means: Array1<F>,
599    /// Posterior standard deviations
600    pub stds: Array1<F>,
601    /// Credible intervals
602    pub credible_intervals: Array2<F>,
603    /// Effective sample sizes
604    pub ess: Array1<F>,
605    /// R-hat convergence diagnostics
606    pub rhat: Array1<F>,
607}
608
609/// MCMC diagnostics
610#[derive(Debug, Clone)]
611pub struct MCMCDiagnostics<F> {
612    /// Acceptance rates by chain
613    pub acceptance_rates: Array1<F>,
614    /// Autocorrelation functions
615    pub autocorrelations: Array2<F>,
616    /// Geweke diagnostic
617    pub geweke_diagnostic: Array1<F>,
618    /// Heidelberger-Welch test
619    pub heidelberger_welch: Array1<bool>,
620    /// Monte Carlo standard errors
621    pub mc_errors: Array1<F>,
622}
623
624/// Model fit metrics
625#[derive(Debug, Clone)]
626pub struct ModelFitMetrics<F> {
627    /// Deviance Information Criterion
628    pub dic: F,
629    /// Watanabe-Akaike Information Criterion
630    pub waic: F,
631    /// Log pointwise predictive density
632    pub lppd: F,
633    /// Effective number of parameters
634    pub p_eff: F,
635    /// Posterior predictive p-value (Pearson chi-square goodness of fit,
636    /// using the fitted predictive mean/variance at each observation)
637    pub posterior_p_value: F,
638    /// Laplace- (or, for closed-form Gaussian models, exact-) approximated
639    /// log marginal likelihood (model evidence), used to compute Bayes
640    /// factors between models
641    pub log_marginal_likelihood: F,
642    /// Gelfand-Ghosh posterior predictive loss `D = G + P`, where `G` is the
643    /// sum of squared errors between the predictive mean and the observed
644    /// data and `P` is the sum of predictive variances
645    pub ppl: F,
646    /// Leave-one-out cross-validation score, on the same `-2 * log-density`
647    /// deviance scale as `dic`/`waic` (lower is better)
648    pub loo_cv: F,
649    /// K-fold cross-validation information criterion, on the same
650    /// `-2 * log-density` deviance scale as `dic`/`waic` (lower is better)
651    pub cvic: F,
652}
653
654/// Predictive distribution results
655#[derive(Debug, Clone)]
656pub struct PredictiveDistribution<F> {
657    /// Predictive means
658    pub means: Array1<F>,
659    /// Predictive variances
660    pub variances: Array1<F>,
661    /// Predictive quantiles
662    pub quantiles: Array2<F>,
663    /// Posterior predictive samples
664    pub samples: Array2<F>,
665}
666
667impl<F: AdvancedBayesianFloat> BayesianModelComparison<F> {
668    /// Create new model comparison framework
669    pub fn new() -> Self {
670        Self {
671            models: Vec::new(),
672            criteria: vec![
673                ModelSelectionCriterion::DIC,
674                ModelSelectionCriterion::WAIC,
675                ModelSelectionCriterion::LooCv,
676            ],
677            cv_config: CrossValidationConfig::default(),
678            parallel_config: ParallelConfig::default(),
679        }
680    }
681
682    /// Add model to comparison
683    pub fn add_model(&mut self, model: BayesianModel<F>) {
684        self.models.push(model);
685    }
686
687    /// Perform comprehensive model comparison: fits every registered model
688    /// via a real Bayesian inference engine (see the crate-private
689    /// `model_fit::fit_dispatch`) -- a Laplace-approximated GLM, an exact
690    /// Gaussian process posterior, or a trained Bayesian neural network deep
691    /// ensemble, depending on each model's `model_type` -- computes real
692    /// information criteria and cross-validation scores from the resulting
693    /// posterior samples/likelihoods, and derives real pairwise Bayes
694    /// factors from each model's (Laplace- or exactly-) approximated log
695    /// marginal likelihood.
696    pub fn compare_models(
697        &self,
698        x: &ArrayView2<F>,
699        y: &ArrayView1<F>,
700    ) -> StatsResult<ModelComparisonResult<F>> {
701        checkarray_finite(x, "x")?;
702        checkarray_finite(y, "y")?;
703
704        if x.nrows() != y.len() {
705            return Err(StatsError::DimensionMismatch(
706                "X and y must have same number of observations".to_string(),
707            ));
708        }
709        if self.models.is_empty() {
710            return Err(StatsError::InvalidArgument(
711                "At least one model must be registered via add_model before compare_models"
712                    .to_string(),
713            ));
714        }
715
716        let mut rankings = HashMap::new();
717        let mut ic_values = HashMap::new();
718        let mut cv_results = HashMap::new();
719        let mut log_marginal_likelihoods: HashMap<String, F> = HashMap::new();
720
721        // Fit each model and compute criteria
722        for model in &self.models {
723            let model_result = self.fit_single_model(model, x, y)?;
724            log_marginal_likelihoods.insert(
725                model.id.clone(),
726                model_result.model_fit.log_marginal_likelihood,
727            );
728
729            let mut model_ic_values = HashMap::new();
730
731            for criterion in &self.criteria {
732                let ic_value = self.compute_criterion(&model_result, criterion)?;
733                model_ic_values.insert(*criterion, ic_value);
734            }
735
736            ic_values.insert(model.id.clone(), model_ic_values);
737
738            // Cross-validation
739            let cv_result = self.cross_validate_model(model, x, y)?;
740            cv_results.insert(model.id.clone(), cv_result);
741        }
742
743        // Compute rankings. `compute_criterion` always returns values on a
744        // "lower is better" deviance-like scale (including
745        // `MarginalLikelihood`, which it negates), so one ascending sort
746        // works uniformly for every criterion.
747        for criterion in &self.criteria {
748            let mut model_scores: Vec<(String, F)> = ic_values
749                .iter()
750                .map(|(id, scores)| (id.clone(), scores[criterion]))
751                .collect();
752
753            model_scores.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
754
755            let ranking: Vec<String> = model_scores.into_iter().map(|(id_, _)| id_).collect();
756            rankings.insert(*criterion, ranking);
757        }
758
759        // Real pairwise Bayes factors from each model's log marginal
760        // likelihood: bayes_factors[i][j] = p(y | model_i) / p(y | model_j).
761        let n_models = self.models.len();
762        let mut bayes_factors = Array2::<F>::ones((n_models, n_models));
763        for (i, model_i) in self.models.iter().enumerate() {
764            let log_ml_i = log_marginal_likelihoods[&model_i.id];
765            for (j, model_j) in self.models.iter().enumerate() {
766                let log_ml_j = log_marginal_likelihoods[&model_j.id];
767                bayes_factors[[i, j]] = (log_ml_i - log_ml_j).exp();
768            }
769        }
770
771        // Compute model weights using WAIC
772        let model_weights = self.compute_model_weights(&ic_values)?;
773
774        // Select best models
775        let mut best_models = HashMap::new();
776        for criterion in &self.criteria {
777            if let Some(ranking) = rankings.get(criterion) {
778                if let Some(best_model) = ranking.first() {
779                    best_models.insert(*criterion, best_model.clone());
780                }
781            }
782        }
783
784        Ok(ModelComparisonResult {
785            rankings,
786            ic_values,
787            bayes_factors,
788            model_weights,
789            cv_results,
790            best_models,
791        })
792    }
793
794    /// Fit a single model via a real Bayesian inference engine (see
795    /// [`model_fit::fit_dispatch`] for the per-`ModelType` dispatch), then
796    /// fill in the leave-one-out and k-fold cross-validation criteria, which
797    /// need repeated refits and so are computed separately from the rest of
798    /// `AdvancedBayesianResult`.
799    fn fit_single_model(
800        &self,
801        model: &BayesianModel<F>,
802        x: &ArrayView2<F>,
803        y: &ArrayView1<F>,
804    ) -> StatsResult<AdvancedBayesianResult<F>> {
805        let mut result = model_fit::fit_dispatch(model, x, y, &model_fit::primary_bnn_config())?;
806
807        let n = x.nrows();
808        let n_f = F::from(n).expect("sample count fits in any Float");
809        let two = F::from(-2.0).expect("-2.0 fits in any Float");
810
811        // True leave-one-out cross-validation is only affordable up to a
812        // modest sample size (it refits the model once per data point);
813        // beyond that, cap it at a bounded number of folds -- still a real,
814        // honestly-labeled k-fold estimate, just not exact LOO -- to keep
815        // worst-case runtime in check.
816        let loo_k = n.min(15);
817        let (loo_mean_ll, _, _) =
818            model_fit::k_fold_mean_loglik(model, x, y, loo_k, &model_fit::cv_bnn_config())?;
819        result.model_fit.loo_cv = two * loo_mean_ll * n_f;
820
821        let cvic_k = self.cv_config.k_folds.min(n.max(2));
822        let (cvic_mean_ll, _, _) =
823            model_fit::k_fold_mean_loglik(model, x, y, cvic_k, &model_fit::cv_bnn_config())?;
824        result.model_fit.cvic = two * cvic_mean_ll * n_f;
825
826        Ok(result)
827    }
828
829    /// Compute information criterion. Every criterion is returned on a
830    /// `-2 * log-likelihood`-like "deviance" scale where **lower is
831    /// better**, including `MarginalLikelihood` (negated, since raw
832    /// evidence is "higher is better") -- this lets `compare_models` rank
833    /// every criterion with the same ascending sort.
834    fn compute_criterion(
835        &self,
836        result: &AdvancedBayesianResult<F>,
837        criterion: &ModelSelectionCriterion,
838    ) -> StatsResult<F> {
839        match criterion {
840            ModelSelectionCriterion::DIC => Ok(result.model_fit.dic),
841            ModelSelectionCriterion::WAIC => Ok(result.model_fit.waic),
842            ModelSelectionCriterion::LooCv => Ok(result.model_fit.loo_cv),
843            ModelSelectionCriterion::MarginalLikelihood => {
844                Ok(-result.model_fit.log_marginal_likelihood)
845            }
846            ModelSelectionCriterion::PPL => Ok(result.model_fit.ppl),
847            ModelSelectionCriterion::CVIC => Ok(result.model_fit.cvic),
848        }
849    }
850
851    /// Cross-validate model via real, repeated refitting on `k`-fold splits
852    /// of `(x, y)` (see [`model_fit::k_fold_mean_loglik`]), scoring each
853    /// held-out fold by its mean log predictive density.
854    fn cross_validate_model(
855        &self,
856        model: &BayesianModel<F>,
857        x: &ArrayView2<F>,
858        y: &ArrayView1<F>,
859    ) -> StatsResult<CrossValidationResult<F>> {
860        let k = self.cv_config.k_folds.min(x.nrows().max(2));
861        let (mean_score, std_error, fold_scores) =
862            model_fit::k_fold_mean_loglik(model, x, y, k, &model_fit::cv_bnn_config())?;
863        let effective_n_params = F::from(x.ncols()).expect("column count fits in any Float");
864
865        Ok(CrossValidationResult {
866            mean_score,
867            std_error,
868            fold_scores,
869            effective_n_params,
870        })
871    }
872
873    /// Compute model weights using information criteria
874    fn compute_model_weights(
875        &self,
876        ic_values: &HashMap<String, HashMap<ModelSelectionCriterion, F>>,
877    ) -> StatsResult<HashMap<String, F>> {
878        let mut weights = HashMap::new();
879
880        // Use WAIC for weight computation
881        let waic_values: Vec<_> = ic_values
882            .iter()
883            .map(|(id, scores)| (id.clone(), scores[&ModelSelectionCriterion::WAIC]))
884            .collect();
885
886        let min_waic = waic_values
887            .iter()
888            .map(|(_, waic)| *waic)
889            .fold(F::infinity(), |a, b| if a < b { a } else { b });
890
891        let weight_sum: F = waic_values
892            .iter()
893            .map(|(_, waic)| {
894                (-((*waic - min_waic) / F::from(2.0).expect("Failed to convert constant to float")))
895                    .exp()
896            })
897            .sum();
898
899        for (id, waic) in waic_values {
900            let weight = (-(waic - min_waic)
901                / F::from(2.0).expect("Failed to convert constant to float"))
902            .exp()
903                / weight_sum;
904            weights.insert(id, weight);
905        }
906
907        Ok(weights)
908    }
909}
910
911impl Default for CrossValidationConfig {
912    fn default() -> Self {
913        Self {
914            k_folds: 5,
915            mc_samples: 1000,
916            seed: None,
917            stratify: false,
918        }
919    }
920}
921
922impl Default for ParallelConfig {
923    fn default() -> Self {
924        Self {
925            num_chains: 4,
926            parallel_models: true,
927            parallel_cv: true,
928        }
929    }
930}
931
932impl Default for MCMCConfig {
933    fn default() -> Self {
934        Self {
935            n_samples_: 2000,
936            n_burnin: 1000,
937            thin: 1,
938            n_chains: 4,
939            adaptation_period: 500,
940            target_acceptance: 0.65,
941            use_nuts: true,
942            use_hmc: false,
943        }
944    }
945}
946
947impl Default for VIConfig {
948    fn default() -> Self {
949        Self {
950            max_iter: 10000,
951            tolerance: 1e-6,
952            learning_rate: 0.01,
953            family: VariationalFamily::MeanFieldGaussian,
954            n_mc_samples: 100,
955        }
956    }
957}
958
959impl<F: AdvancedBayesianFloat> Default for BayesianModelComparison<F> {
960    fn default() -> Self {
961        Self::new()
962    }
963}
964
965impl<F: AdvancedBayesianFloat> BayesianGaussianProcess<F> {
966    /// Create new Gaussian process
967    pub fn new(
968        x_train: Array2<F>,
969        y_train: Array1<F>,
970        kernel: KernelType,
971        noise_level: F,
972    ) -> StatsResult<Self> {
973        checkarray_finite(&x_train.view(), "x_train")?;
974        checkarray_finite(&y_train.view(), "y_train")?;
975
976        if x_train.nrows() != y_train.len() {
977            return Err(StatsError::DimensionMismatch(
978                "X and y must have same number of observations".to_string(),
979            ));
980        }
981
982        if noise_level <= F::zero() {
983            return Err(StatsError::InvalidArgument(
984                "Noise _level must be positive".to_string(),
985            ));
986        }
987
988        Ok(Self {
989            x_train,
990            y_train,
991            kernel,
992            noise_level,
993            hyperpriors: HashMap::new(),
994            hyperparameter_samples: None,
995        })
996    }
997
998    /// Compute kernel matrix
999    pub fn compute_kernel_matrix(
1000        &self,
1001        x1: &ArrayView2<F>,
1002        x2: &ArrayView2<F>,
1003    ) -> StatsResult<Array2<F>> {
1004        let n1 = x1.nrows();
1005        let n2 = x2.nrows();
1006        let mut k = Array2::zeros((n1, n2));
1007
1008        for i in 0..n1 {
1009            for j in 0..n2 {
1010                let x1_row = x1.row(i);
1011                let x2_row = x2.row(j);
1012                k[[i, j]] = self.kernel_function(&x1_row, &x2_row)?;
1013            }
1014        }
1015
1016        Ok(k)
1017    }
1018
1019    /// Evaluate kernel function between two points
1020    fn kernel_function(&self, x1: &ArrayView1<F>, x2: &ArrayView1<F>) -> StatsResult<F> {
1021        match &self.kernel {
1022            KernelType::RBF { length_scale } => {
1023                let length_scale = F::from(*length_scale).expect("Failed to convert to float");
1024                let mut squared_dist = F::zero();
1025
1026                for (a, b) in x1.iter().zip(x2.iter()) {
1027                    let diff = *a - *b;
1028                    squared_dist = squared_dist + diff * diff;
1029                }
1030
1031                Ok((-squared_dist
1032                    / (F::from(2.0).expect("Failed to convert constant to float")
1033                        * length_scale
1034                        * length_scale))
1035                    .exp())
1036            }
1037            KernelType::Matern { nu, length_scale } => {
1038                let nu = F::from(*nu).expect("Failed to convert to float");
1039                let length_scale = F::from(*length_scale).expect("Failed to convert to float");
1040                let mut dist = F::zero();
1041
1042                for (a, b) in x1.iter().zip(x2.iter()) {
1043                    let diff = *a - *b;
1044                    dist = dist + diff * diff;
1045                }
1046                dist = dist.sqrt();
1047
1048                // Simplified Matern kernel for nu = 1.5
1049                if nu == F::from(1.5).expect("Failed to convert constant to float") {
1050                    let sqrt3_r_l = F::from(3.0)
1051                        .expect("Failed to convert constant to float")
1052                        .sqrt()
1053                        * dist
1054                        / length_scale;
1055                    Ok((F::one() + sqrt3_r_l) * (-sqrt3_r_l).exp())
1056                } else {
1057                    // Fallback to RBF for other nu values
1058                    Ok((-dist * dist
1059                        / (F::from(2.0).expect("Failed to convert constant to float")
1060                            * length_scale
1061                            * length_scale))
1062                        .exp())
1063                }
1064            }
1065            KernelType::Linear { variance } => {
1066                let variance = F::from(*variance).expect("Failed to convert to float");
1067                let dot_product = F::simd_dot(x1, x2);
1068                Ok(variance * dot_product)
1069            }
1070            KernelType::WhiteNoise { variance } => {
1071                let variance = F::from(*variance).expect("Failed to convert to float");
1072                // White noise kernel is only non-zero when x1 == x2
1073                let mut is_equal = true;
1074                for (a, b) in x1.iter().zip(x2.iter()) {
1075                    if (*a - *b).abs()
1076                        > F::from(1e-10).expect("Failed to convert constant to float")
1077                    {
1078                        is_equal = false;
1079                        break;
1080                    }
1081                }
1082                Ok(if is_equal { variance } else { F::zero() })
1083            }
1084            _ => {
1085                // For complex kernels (Sum, Product), use RBF as fallback
1086                let mut squared_dist = F::zero();
1087                for (a, b) in x1.iter().zip(x2.iter()) {
1088                    let diff = *a - *b;
1089                    squared_dist = squared_dist + diff * diff;
1090                }
1091                Ok(
1092                    (-squared_dist / F::from(2.0).expect("Failed to convert constant to float"))
1093                        .exp(),
1094                )
1095            }
1096        }
1097    }
1098
1099    /// Compute the Cholesky factor `L` of the noise-regularized training
1100    /// kernel matrix `K(X, X) + sigma^2 I`.
1101    fn training_cholesky(&self) -> StatsResult<Array2<F>> {
1102        let n_train = self.x_train.nrows();
1103        let mut k_train = self.compute_kernel_matrix(&self.x_train.view(), &self.x_train.view())?;
1104        for i in 0..n_train {
1105            k_train[[i, i]] = k_train[[i, i]] + self.noise_level;
1106        }
1107        scirs2_linalg::cholesky(&k_train.view(), None).map_err(|e| {
1108            StatsError::ComputationError(format!(
1109                "Gaussian process kernel matrix is not positive definite (Cholesky decomposition failed): {e}"
1110            ))
1111        })
1112    }
1113
1114    /// Solve `(K(X, X) + sigma^2 I) alpha = y_train` given the Cholesky
1115    /// factor `l` via forward + back substitution.
1116    fn solve_alpha(&self, l: &Array2<F>) -> StatsResult<Array1<F>> {
1117        let z = scirs2_linalg::solve_triangular(&l.view(), &self.y_train.view(), true, false)
1118            .map_err(|e| {
1119                StatsError::ComputationError(format!("GP forward substitution failed: {e}"))
1120            })?;
1121        scirs2_linalg::solve_triangular(&l.t(), &z.view(), false, false)
1122            .map_err(|e| StatsError::ComputationError(format!("GP back substitution failed: {e}")))
1123    }
1124
1125    /// Make predictions at new input points using the exact Gaussian process
1126    /// posterior: `mean = K(X*, X) alpha` and
1127    /// `var = k(x*, x*) - K(X*, X) (K(X, X) + sigma^2 I)^-1 K(X, X*)`, where
1128    /// `alpha = (K(X, X) + sigma^2 I)^-1 y_train`.
1129    pub fn predict(&self, xtest: &ArrayView2<F>) -> StatsResult<(Array1<F>, Array1<F>)> {
1130        checkarray_finite(xtest, "x_test")?;
1131        if xtest.ncols() != self.x_train.ncols() {
1132            return Err(StatsError::DimensionMismatch(format!(
1133                "x_test has {} columns, expected {} to match the training data",
1134                xtest.ncols(),
1135                self.x_train.ncols()
1136            )));
1137        }
1138
1139        let n_test = xtest.nrows();
1140        let l = self.training_cholesky()?;
1141        let alpha = self.solve_alpha(&l)?;
1142
1143        // Cross-covariance K(X*, X), shape (n_test, n_train).
1144        let k_star = self.compute_kernel_matrix(xtest, &self.x_train.view())?;
1145        let mean_pred = k_star.dot(&alpha);
1146
1147        let mut var_pred = Array1::<F>::zeros(n_test);
1148        for i in 0..n_test {
1149            let k_star_i = k_star.row(i).to_owned();
1150            let v = scirs2_linalg::solve_triangular(&l.view(), &k_star_i.view(), true, false)
1151                .map_err(|e| {
1152                    StatsError::ComputationError(format!(
1153                        "GP predictive variance solve failed: {e}"
1154                    ))
1155                })?;
1156            let quad = v.dot(&v);
1157            let test_row = xtest.row(i);
1158            let k_ii = self.kernel_function(&test_row, &test_row)?;
1159            var_pred[i] = (k_ii - quad).max(F::zero());
1160        }
1161
1162        Ok((mean_pred, var_pred))
1163    }
1164
1165    /// Exact log marginal likelihood (model evidence) of the training data:
1166    /// `log p(y|X) = -1/2 y^T alpha - sum_i log(L_ii) - n/2 log(2 pi)`.
1167    pub fn log_marginal_likelihood(&self) -> StatsResult<F> {
1168        let n = self.x_train.nrows();
1169        let l = self.training_cholesky()?;
1170        let alpha = self.solve_alpha(&l)?;
1171        let data_fit = self.y_train.dot(&alpha);
1172
1173        let mut log_det_half = F::zero();
1174        for i in 0..n {
1175            let diag = l[[i, i]]
1176                .abs()
1177                .max(F::from(1e-300).expect("1e-300 fits in any Float"));
1178            log_det_half = log_det_half + diag.ln();
1179        }
1180
1181        let two_pi = F::from(2.0 * std::f64::consts::PI).expect("2*pi fits in any Float");
1182        let half = F::from(0.5).expect("0.5 fits in any Float");
1183        Ok(-half * data_fit
1184            - log_det_half
1185            - half * F::from(n).expect("n fits in any Float") * two_pi.ln())
1186    }
1187}
1188
1189impl<F: AdvancedBayesianFloat> BayesianNeuralNetwork<F> {
1190    /// Create new Bayesian neural network
1191    pub fn new(architecture: Vec<usize>, activations: Vec<ActivationType>) -> StatsResult<Self> {
1192        if architecture.len() < 2 {
1193            return Err(StatsError::InvalidArgument(
1194                "Architecture must have at least input and output layers".to_string(),
1195            ));
1196        }
1197
1198        if activations.len() != architecture.len() - 1 {
1199            return Err(StatsError::InvalidArgument(
1200                "Number of activations must equal number of layers - 1".to_string(),
1201            ));
1202        }
1203
1204        let n_layers = architecture.len() - 1;
1205
1206        // Initialize priors with appropriate scales based on layer sizes
1207        let weight_priors = (0..n_layers)
1208            .map(|i| {
1209                let fan_in = F::from(architecture[i]).expect("Failed to convert to float");
1210                let precision = fan_in; // Xavier initialization scale
1211                DistributionType::Normal {
1212                    mean: F::zero(),
1213                    precision,
1214                }
1215            })
1216            .collect();
1217
1218        let bias_priors = (0..n_layers)
1219            .map(|_| DistributionType::Normal {
1220                mean: F::zero(),
1221                precision: F::from(0.1).expect("Failed to convert constant to float"),
1222            })
1223            .collect();
1224
1225        Ok(Self {
1226            architecture,
1227            activations,
1228            weight_priors,
1229            bias_priors,
1230            weight_samples: None,
1231            bias_samples: None,
1232        })
1233    }
1234
1235    /// Apply activation function
1236    fn apply_activation(&self, x: F, activation: ActivationType) -> F {
1237        match activation {
1238            ActivationType::ReLU => {
1239                if x > F::zero() {
1240                    x
1241                } else {
1242                    F::zero()
1243                }
1244            }
1245            ActivationType::Sigmoid => F::one() / (F::one() + (-x).exp()),
1246            ActivationType::Tanh => x.tanh(),
1247            ActivationType::Swish => x / (F::one() + (-x).exp()),
1248            ActivationType::GELU => {
1249                // Approximate GELU: 0.5 * x * (1 + tanh(sqrt(2/π) * (x + 0.044715 * x^3)))
1250                let sqrt_2_pi = F::from(0.7978845608).expect("Failed to convert constant to float"); // sqrt(2/π)
1251                let coeff = F::from(0.044715).expect("Failed to convert constant to float");
1252                let inner = sqrt_2_pi * (x + coeff * x * x * x);
1253                F::from(0.5).expect("Failed to convert constant to float")
1254                    * x
1255                    * (F::one() + inner.tanh())
1256            }
1257        }
1258    }
1259
1260    /// Forward pass through the network
1261    pub fn forward(
1262        &self,
1263        x: &ArrayView2<F>,
1264        weights: &[Array2<F>],
1265        biases: &[Array1<F>],
1266    ) -> StatsResult<Array2<F>> {
1267        checkarray_finite(x, "x")?;
1268
1269        if weights.len() != self.architecture.len() - 1 {
1270            return Err(StatsError::InvalidArgument(
1271                "Number of weight matrices must match network layers".to_string(),
1272            ));
1273        }
1274
1275        if biases.len() != self.architecture.len() - 1 {
1276            return Err(StatsError::InvalidArgument(
1277                "Number of bias vectors must match network layers".to_string(),
1278            ));
1279        }
1280
1281        let mut activations = x.to_owned();
1282
1283        for (layer_idx, &activation_type) in self.activations.iter().enumerate() {
1284            // Linear transformation: z = x * W + b
1285            let z = self.linear_transform(
1286                &activations.view(),
1287                &weights[layer_idx],
1288                &biases[layer_idx],
1289            )?;
1290
1291            // Apply activation function
1292            activations = z.mapv(|val| self.apply_activation(val, activation_type));
1293        }
1294
1295        Ok(activations)
1296    }
1297
1298    /// Linear transformation: z = x * W + b
1299    fn linear_transform(
1300        &self,
1301        x: &ArrayView2<F>,
1302        weights: &Array2<F>,
1303        bias: &Array1<F>,
1304    ) -> StatsResult<Array2<F>> {
1305        let (batchsize, input_dim) = x.dim();
1306        let (weight_input_dim, output_dim) = weights.dim();
1307
1308        if input_dim != weight_input_dim {
1309            return Err(StatsError::DimensionMismatch(
1310                "Input dimension must match weight matrix input dimension".to_string(),
1311            ));
1312        }
1313
1314        if bias.len() != output_dim {
1315            return Err(StatsError::DimensionMismatch(
1316                "Bias length must match weight matrix output dimension".to_string(),
1317            ));
1318        }
1319
1320        // Matrix multiplication: x * W
1321        let mut result = Array2::zeros((batchsize, output_dim));
1322
1323        for i in 0..batchsize {
1324            for j in 0..output_dim {
1325                let mut sum = F::zero();
1326                for k in 0..input_dim {
1327                    sum = sum + x[[i, k]] * weights[[k, j]];
1328                }
1329                result[[i, j]] = sum + bias[j];
1330            }
1331        }
1332
1333        Ok(result)
1334    }
1335
1336    // `fit` and `predict_with_uncertainty` (real deep-ensemble training and
1337    // posterior-predictive Monte Carlo, replacing the old fabricated
1338    // all-zero/all-one stub) live in `bayesian_advanced::bnn_train`, along
1339    // with the exact backpropagation machinery they share.
1340}
1341
1342#[cfg(test)]
1343mod tests {
1344    use super::*;
1345    use scirs2_core::ndarray::array;
1346
1347    #[test]
1348    fn test_model_comparison() {
1349        let mut comparison = BayesianModelComparison::<f64>::new();
1350
1351        let model = BayesianModel {
1352            id: "linear_model".to_string(),
1353            model_type: ModelType::LinearRegression,
1354            prior: AdvancedPrior::Conjugate {
1355                parameters: HashMap::new(),
1356            },
1357            likelihood: LikelihoodType::Gaussian,
1358            complexity: 3.0,
1359        };
1360
1361        comparison.add_model(model);
1362
1363        let x = array![[1.0, 0.5], [3.0, -1.0], [5.0, 2.0], [7.0, -0.5]];
1364        let y = array![1.2, 2.1, 3.4, 3.8];
1365
1366        let result = comparison
1367            .compare_models(&x.view(), &y.view())
1368            .expect("compare_models should succeed for a well-specified single model");
1369
1370        // The old stub produced a canned PosteriorSummary of zeros/ones and a
1371        // hardcoded R-hat of 1.0 for a model that was never actually fit.
1372        // With a real fit, the posterior mean/variance must reflect the
1373        // input data (not be exactly zero), and the reported diagnostics
1374        // must be finite real numbers.
1375        let fit = &result.ic_values["linear_model"];
1376        assert!(fit[&ModelSelectionCriterion::WAIC].is_finite());
1377        assert!(fit[&ModelSelectionCriterion::DIC].is_finite());
1378        assert!(result.model_weights["linear_model"] > 0.0);
1379    }
1380
1381    #[test]
1382    fn test_model_comparison_prefers_true_generating_model() {
1383        // True process: a (mostly) monotonic 0/1 step-like response in `x`,
1384        // with two intentionally "flipped" labels near the boundary (at
1385        // x=-0.5 and x=0.5) so the classes are not perfectly separable --
1386        // avoiding the classic logistic-regression perfect-separation
1387        // pathology (an infinite-magnitude MLE) while still being a shape
1388        // only a logit link can represent well. A logit-link (Binomial) GLM
1389        // is the correctly-specified model; a plain identity-link Gaussian
1390        // linear regression is fundamentally misspecified for a bounded
1391        // 0/1 response (it both extrapolates outside [0, 1] beyond the data
1392        // range and cannot saturate near the boundaries). Model comparison
1393        // over real fits of both should therefore robustly prefer the
1394        // correctly-specified model.
1395        let xs_base: Vec<f64> = vec![
1396            -4.0, -3.0, -2.0, -1.5, -1.0, -0.5, -0.2, 0.2, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0,
1397        ];
1398        let ys_base: Vec<f64> = vec![
1399            0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1400        ];
1401        // Replicate the pattern (a standard repeated-trials design, as in a
1402        // dose-response assay with several subjects tested at each dose
1403        // level) so the logit fit's Laplace posterior is well-identified
1404        // enough that WAIC/DIC's effective-parameter penalty does not swamp
1405        // its (real) better fit -- with only the 14 base points, the
1406        // near-boundary curvature leaves real, legitimate posterior
1407        // uncertainty in the logit slope large enough to dominate the
1408        // comparison, which is a separate, genuine phenomenon from "which
1409        // model is correctly specified".
1410        let reps = 4;
1411        let xs: Vec<f64> = xs_base
1412            .iter()
1413            .cloned()
1414            .cycle()
1415            .take(xs_base.len() * reps)
1416            .collect();
1417        let ys: Vec<f64> = ys_base
1418            .iter()
1419            .cloned()
1420            .cycle()
1421            .take(ys_base.len() * reps)
1422            .collect();
1423        // `fit_glm`'s design matrix has no implicit intercept column (`eta =
1424        // X . beta` exactly), so an explicit leading column of ones is
1425        // required for either candidate model to represent a nonzero
1426        // intercept.
1427        let x = Array2::from_shape_fn((xs.len(), 2), |(i, j)| if j == 0 { 1.0 } else { xs[i] });
1428        let y = Array1::from_vec(ys);
1429
1430        let mut comparison = BayesianModelComparison::<f64>::new();
1431        comparison.add_model(BayesianModel {
1432            id: "true_logit_link".to_string(),
1433            model_type: ModelType::GeneralizedLinear {
1434                family: GLMFamily::Binomial,
1435            },
1436            prior: AdvancedPrior::Conjugate {
1437                parameters: HashMap::new(),
1438            },
1439            likelihood: LikelihoodType::Binomial,
1440            complexity: 2.0,
1441        });
1442        comparison.add_model(BayesianModel {
1443            id: "wrong_gaussian_link".to_string(),
1444            model_type: ModelType::LinearRegression,
1445            prior: AdvancedPrior::Conjugate {
1446                parameters: HashMap::new(),
1447            },
1448            likelihood: LikelihoodType::Gaussian,
1449            complexity: 2.0,
1450        });
1451
1452        let result = comparison
1453            .compare_models(&x.view(), &y.view())
1454            .expect("compare_models should succeed for two well-specified GLM models");
1455
1456        for criterion in [ModelSelectionCriterion::WAIC, ModelSelectionCriterion::DIC] {
1457            let ranking = &result.rankings[&criterion];
1458            assert_eq!(
1459                ranking.first().map(|s| s.as_str()),
1460                Some("true_logit_link"),
1461                "{criterion:?} should rank the correctly-specified model first, got {ranking:?}"
1462            );
1463        }
1464
1465        // Bayes factor of the true model versus the misspecified one should
1466        // favor the true model (models are indexed in add_model order:
1467        // 0 = true_logit_link, 1 = wrong_gaussian_link).
1468        let bf_true_vs_wrong = result.bayes_factors[[0, 1]];
1469        assert!(
1470            bf_true_vs_wrong > 1.0,
1471            "Bayes factor should favor the true generating model, got {bf_true_vs_wrong}"
1472        );
1473    }
1474
1475    #[test]
1476    fn test_generalized_linear_family_likelihood_mismatch_is_rejected() {
1477        // `ModelType::GeneralizedLinear { family }` and `BayesianModel::likelihood`
1478        // are two separate fields that must describe the same distribution:
1479        // the actual Laplace-approximated fit is driven entirely by
1480        // `likelihood`, so a caller who declares `family: GLMFamily::Poisson`
1481        // while leaving `likelihood: LikelihoodType::Gaussian` would --
1482        // without this check -- have their declared Poisson family silently
1483        // discarded in favor of a Gaussian fit with no indication anything
1484        // was wrong. `compare_models` must instead reject this
1485        // inconsistency with a clear error rather than silently fitting a
1486        // different model than the one declared.
1487        let xs: Vec<f64> = (0..10).map(|i| i as f64 * 0.3).collect();
1488        let ys: Vec<f64> = xs.iter().map(|&xv| (0.4 + 0.6 * xv).exp()).collect();
1489        let x = Array2::from_shape_fn((xs.len(), 2), |(i, j)| if j == 0 { 1.0 } else { xs[i] });
1490        let y = Array1::from_vec(ys);
1491
1492        let mut comparison = BayesianModelComparison::<f64>::new();
1493        comparison.add_model(BayesianModel {
1494            id: "mismatched_model".to_string(),
1495            model_type: ModelType::GeneralizedLinear {
1496                family: GLMFamily::Poisson,
1497            },
1498            prior: AdvancedPrior::Conjugate {
1499                parameters: HashMap::new(),
1500            },
1501            // Deliberately inconsistent with `model_type`'s declared family.
1502            likelihood: LikelihoodType::Gaussian,
1503            complexity: 2.0,
1504        });
1505
1506        let err = comparison.compare_models(&x.view(), &y.view()).expect_err(
1507            "a GeneralizedLinear model whose declared family disagrees with its \
1508                 likelihood must be rejected, not silently fit as `likelihood` alone",
1509        );
1510        let message = err.to_string();
1511        assert!(
1512            message.contains("family") && message.contains("likelihood"),
1513            "error should explain the family/likelihood mismatch, got: {message}"
1514        );
1515    }
1516
1517    #[test]
1518    fn test_gaussian_process_noiseless_interpolation() {
1519        // Three points on a curve (not collinear), so a real RBF-kernel
1520        // posterior mean and a naive nearest-neighbor guess would disagree.
1521        let x_train = array![[0.0], [1.0], [2.0]];
1522        let y_train = array![0.0, 1.0, 4.0];
1523        let noise = 1e-6; // near-noiseless
1524
1525        let gp = BayesianGaussianProcess::new(
1526            x_train.clone(),
1527            y_train.clone(),
1528            KernelType::RBF { length_scale: 1.0 },
1529            noise,
1530        )
1531        .expect("GP construction should succeed");
1532
1533        assert_eq!(gp.x_train.nrows(), 3);
1534        assert_eq!(gp.y_train.len(), 3);
1535
1536        // Noiseless-GP interpolation property: posterior mean at the
1537        // training inputs should reproduce the training targets almost
1538        // exactly, with near-zero posterior variance there.
1539        let (mean_train, var_train) = gp
1540            .predict(&x_train.view())
1541            .expect("prediction at training points should succeed");
1542        for i in 0..3 {
1543            assert!(
1544                (mean_train[i] - y_train[i]).abs() < 1e-3,
1545                "GP should nearly interpolate noiseless training data at point {i}: got {}, expected {}",
1546                mean_train[i],
1547                y_train[i]
1548            );
1549            assert!(
1550                var_train[i] < 1e-2,
1551                "GP posterior variance at a training point should be tiny, got {}",
1552                var_train[i]
1553            );
1554        }
1555
1556        // At the midpoint between x=0 (y=0) and x=1 (y=1), the real
1557        // RBF-weighted posterior mean must be a smooth blend, not exactly
1558        // either training value (which is what a 1-nearest-neighbor stub
1559        // -- ties resolved toward the first point seen -- would return).
1560        let x_mid = array![[0.5]];
1561        let (mean_mid, _) = gp
1562            .predict(&x_mid.view())
1563            .expect("midpoint prediction should succeed");
1564        assert!(
1565            (mean_mid[0] - 0.0).abs() > 1e-3 && (mean_mid[0] - 1.0).abs() > 1e-3,
1566            "GP posterior mean at the midpoint should be a genuine blend of neighboring \
1567             training values, not equal to either one exactly: got {}",
1568            mean_mid[0]
1569        );
1570
1571        // Posterior variance must grow away from the training data -- the
1572        // headline GP behavior a constant-variance stub cannot reproduce.
1573        let x_far = array![[50.0]];
1574        let (_, var_far) = gp
1575            .predict(&x_far.view())
1576            .expect("far-point prediction should succeed");
1577        assert!(
1578            var_far[0] > var_train[0] + 1e-3,
1579            "GP posterior variance should grow away from training data: far={}, near={}",
1580            var_far[0],
1581            var_train[0]
1582        );
1583
1584        let log_ml = gp
1585            .log_marginal_likelihood()
1586            .expect("log marginal likelihood should compute");
1587        assert!(log_ml.is_finite());
1588    }
1589
1590    #[test]
1591    fn test_bayesian_neural_network_prior_predictive_is_input_dependent() {
1592        let bnn = BayesianNeuralNetwork::<f64>::new(
1593            vec![2, 5, 1],
1594            vec![ActivationType::ReLU, ActivationType::Sigmoid],
1595        )
1596        .expect("network construction should succeed");
1597
1598        // No `fit()` call: predictions must come from real forward passes
1599        // through prior-sampled weights (prior-predictive Monte Carlo), not
1600        // the old fabricated all-zero/all-one stub.
1601        let x_test = array![[0.0, 0.0], [5.0, -5.0], [-5.0, 5.0], [10.0, 10.0]];
1602        let (means, vars) = bnn
1603            .predict_with_uncertainty(&x_test.view(), 200)
1604            .expect("prior-predictive prediction should succeed");
1605
1606        let first_mean = means[[0, 0]];
1607        let all_means_equal =
1608            (0..x_test.nrows()).all(|i| (means[[i, 0]] - first_mean).abs() < 1e-9);
1609        assert!(
1610            !all_means_equal,
1611            "predictive means should genuinely depend on very different input rows, got {means:?}"
1612        );
1613        for v in vars.iter() {
1614            assert!(*v >= 0.0, "variance must be non-negative, got {v}");
1615        }
1616        assert!(
1617            means.iter().any(|&m| m.abs() > 1e-9),
1618            "means should not all be the fabricated placeholder 0.0, got {means:?}"
1619        );
1620    }
1621
1622    #[test]
1623    fn test_bayesian_neural_network_fit_improves_predictions() {
1624        // A function this architecture (ReLU hidden layer, Sigmoid output)
1625        // can realistically represent: y = sigmoid(0.5*x1 - 0.3*x2).
1626        let xs: Vec<[f64; 2]> = vec![
1627            [-2.0, -2.0],
1628            [-2.0, 0.0],
1629            [-2.0, 2.0],
1630            [0.0, -2.0],
1631            [0.0, 0.0],
1632            [0.0, 2.0],
1633            [2.0, -2.0],
1634            [2.0, 0.0],
1635            [2.0, 2.0],
1636        ];
1637        let sigmoid = |z: f64| 1.0 / (1.0 + (-z).exp());
1638        let ys: Vec<f64> = xs
1639            .iter()
1640            .map(|p| sigmoid(0.5 * p[0] - 0.3 * p[1]))
1641            .collect();
1642
1643        let x = Array2::from_shape_fn((xs.len(), 2), |(i, j)| xs[i][j]);
1644        let y_col = Array2::from_shape_fn((ys.len(), 1), |(i, _)| ys[i]);
1645        let y_flat = Array1::from_vec(ys);
1646
1647        let mut bnn = BayesianNeuralNetwork::<f64>::new(
1648            vec![2, 6, 1],
1649            vec![ActivationType::ReLU, ActivationType::Sigmoid],
1650        )
1651        .expect("network construction should succeed");
1652
1653        let config = BnnTrainingConfig {
1654            n_ensemble: 6,
1655            epochs: 400,
1656            learning_rate: 0.2,
1657            bootstrap: true,
1658            seed: Some(20_260_729),
1659        };
1660        bnn.fit(&x.view(), &y_col.view(), &config)
1661            .expect("BNN ensemble training should succeed");
1662
1663        let (means_after, vars_after) = bnn
1664            .predict_with_uncertainty(&x.view(), 40)
1665            .expect("post-fit prediction should succeed");
1666
1667        let mse_after: f64 = (0..xs.len())
1668            .map(|i| {
1669                let d = means_after[[i, 0]] - y_flat[i];
1670                d * d
1671            })
1672            .sum::<f64>()
1673            / xs.len() as f64;
1674
1675        let mean_y = y_flat.iter().sum::<f64>() / y_flat.len() as f64;
1676        let baseline_mse: f64 =
1677            y_flat.iter().map(|&yv| (yv - mean_y).powi(2)).sum::<f64>() / y_flat.len() as f64;
1678
1679        assert!(
1680            mse_after < baseline_mse * 0.5,
1681            "fitted BNN should fit learnable training data substantially better than a \
1682             mean-only baseline: mse_after={mse_after}, baseline_mse={baseline_mse}"
1683        );
1684
1685        let first_var = vars_after[[0, 0]];
1686        let any_different = (0..xs.len()).any(|i| (vars_after[[i, 0]] - first_var).abs() > 1e-9);
1687        assert!(
1688            any_different,
1689            "post-fit predictive variance should vary across inputs, got {vars_after:?}"
1690        );
1691    }
1692}