1use crate::gpu_polya_gamma::{PgSeed, PolyaGammaBatchInput};
27use faer::Side;
28use gam_linalg::faer_ndarray::{
29 FaerCholesky, FaerEigh, fast_ab, fast_ata_into, fast_atv, fast_av, fast_av_into,
30};
31use gam_linalg::matrix::DesignMatrix;
32use gam_linalg::triangular::back_substitution_lower_transpose_guarded_into;
33use gam_problem::types::{
34 GlmLikelihoodSpec, InverseLink, LikelihoodScaleMetadata, LikelihoodSpec,
35 ResolvedLikelihoodScale, ResponseFamily, RhoPrior, StandardLink, is_valid_tweedie_power,
36};
37use gam_solve::estimate::reml::FirthDenseOperator;
38use gam_solve::estimate::reml::penalty_logdet::PenaltyPseudologdet;
39use gam_solve::estimate::{UnifiedFitResult, validate_explicit_dense_hessian_for_whitening};
40use gam_solve::mixture_link::{
41 InverseLinkKernel, LinkParamPartials, inverse_link_jet_for_inverse_link, softmax_last_fixedzero,
42};
43use gam_terms::construction::CanonicalPenalty;
44use general_mcmc::generic_hmc::HamiltonianTarget;
45pub use general_mcmc::generic_nuts::NUTSMassMatrixConfig;
46use general_mcmc::generic_nuts::{GenericNUTS, MassMatrixAdaptation};
47use ndarray::{Array1, Array2, Array3, ArrayView1, ArrayView2, Axis, s};
48use rand::{RngExt, SeedableRng, rngs::StdRng};
49use serde::{Deserialize, Serialize};
50use std::cell::RefCell;
51use std::fmt;
52use std::sync::{Arc, Mutex};
53
54#[inline]
59fn likelihood_spec_supports_firth(spec: &LikelihoodSpec) -> bool {
60 spec.supports_firth()
61}
62
63#[inline]
66fn likelihood_spec_jeffreys_link(spec: &LikelihoodSpec) -> Option<InverseLink> {
67 if likelihood_spec_supports_firth(spec) {
68 Some(spec.link.clone())
69 } else {
70 None
71 }
72}
73
74#[derive(Debug, Clone)]
81pub enum HmcError {
82 NonFiniteState { reason: String },
85 InvalidConfig { reason: String },
88 DimensionMismatch { reason: String },
90 FirthUnsupported { reason: String },
93 LinkMismatch { reason: String },
96 UnsupportedFamily { reason: String },
98 SamplingFailed { reason: String },
101}
102
103impl fmt::Display for HmcError {
104 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 match self {
106 HmcError::NonFiniteState { reason }
107 | HmcError::InvalidConfig { reason }
108 | HmcError::DimensionMismatch { reason }
109 | HmcError::FirthUnsupported { reason }
110 | HmcError::LinkMismatch { reason }
111 | HmcError::UnsupportedFamily { reason }
112 | HmcError::SamplingFailed { reason } => f.write_str(reason),
113 }
114 }
115}
116
117impl From<HmcError> for String {
118 fn from(err: HmcError) -> String {
119 err.to_string()
120 }
121}
122
123const MAX_AUTOCORRELATION_LAG: usize = 1000;
128
129const AUTOCOVARIANCE_FLOOR: f64 = 1e-16;
133
134pub(crate) fn compute_split_rhat_and_ess(samples: &Array3<f64>) -> (f64, f64) {
139 let n_chains = samples.shape()[0];
140 let n_samples = samples.shape()[1];
141 let dim = samples.shape()[2];
142
143 if n_chains < 2 || n_samples < 4 {
144 return (1.0, n_chains as f64 * n_samples as f64 * 0.5);
145 }
146
147 let half = n_samples / 2;
149 let n_split_chains = n_chains * 2;
150 let n_split_samples = half;
151
152 let mut max_rhat = 0.0f64;
153 let mut min_ess = f64::INFINITY;
154
155 #[inline]
156 fn splitvalue(
157 samples: &Array3<f64>,
158 n_chains: usize,
159 half: usize,
160 dim: usize,
161 sc: usize,
162 t: usize,
163 ) -> f64 {
164 let chain = sc % n_chains;
165 if sc < n_chains {
166 samples[[chain, t, dim]]
167 } else {
168 samples[[chain, half + t, dim]]
169 }
170 }
171
172 fn ess_from_split_dimension(
173 samples: &Array3<f64>,
174 n_chains: usize,
175 half: usize,
176 dim: usize,
177 ) -> f64 {
178 let m = n_chains * 2;
179 let n = half;
180 if m == 0 || n < 4 {
181 return (m * n).max(1) as f64;
182 }
183
184 let mut means = vec![0.0_f64; m];
185 let mut gamma0 = vec![0.0_f64; m];
186 for sc in 0..m {
187 let mut sum = 0.0;
188 for t in 0..n {
189 sum += splitvalue(samples, n_chains, half, dim, sc, t);
190 }
191 let mean = sum / n as f64;
192 means[sc] = mean;
193 let mut g0 = 0.0;
194 for t in 0..n {
195 let d = splitvalue(samples, n_chains, half, dim, sc, t) - mean;
196 g0 += d * d;
197 }
198 gamma0[sc] = (g0 / n as f64).max(AUTOCOVARIANCE_FLOOR);
199 }
200
201 let max_lag = (n - 1).min(MAX_AUTOCORRELATION_LAG);
202 let mut tau = 1.0_f64;
203 let mut lag = 1usize;
204 while lag < max_lag {
205 let mut pair = 0.0_f64;
206 for l in [lag, lag + 1] {
207 if l > max_lag {
208 continue;
209 }
210 let mut rho_l = 0.0;
211 for sc in 0..m {
212 let mu = means[sc];
213 let mut cov = 0.0;
214 let denom = (n - l) as f64;
215 for t in 0..(n - l) {
216 let x0 = splitvalue(samples, n_chains, half, dim, sc, t);
217 let x1 = splitvalue(samples, n_chains, half, dim, sc, t + l);
218 cov += (x0 - mu) * (x1 - mu);
219 }
220 cov /= denom;
221 rho_l += cov / gamma0[sc];
222 }
223 rho_l /= m as f64;
224 pair += rho_l;
225 }
226 if !pair.is_finite() || pair <= 0.0 {
227 break;
228 }
229 tau += 2.0 * pair;
230 lag += 2;
231 }
232 if !tau.is_finite() || tau <= 0.0 {
233 return 1.0;
234 }
235 let total = (m * n) as f64;
236 (total / tau).clamp(1.0, total)
237 }
238
239 let mut chain_means = vec![0.0_f64; n_split_chains];
240 let mut chainvars = vec![0.0_f64; n_split_chains];
241 for d in 0..dim {
242 for chain in 0..n_chains {
243 let mut sum1 = 0.0;
245 for i in 0..half {
246 sum1 += samples[[chain, i, d]];
247 }
248 let mean1 = sum1 / half as f64;
249 let mut var1 = 0.0;
250 for i in 0..half {
251 let diff = samples[[chain, i, d]] - mean1;
252 var1 += diff * diff;
253 }
254 var1 /= (half - 1).max(1) as f64;
255 let first_idx = chain;
256 chain_means[first_idx] = mean1;
257 chainvars[first_idx] = var1;
258
259 let mut sum2 = 0.0;
261 for i in half..(2 * half) {
262 sum2 += samples[[chain, i, d]];
263 }
264 let mean2 = sum2 / half as f64;
265 let mut var2 = 0.0;
266 for i in half..(2 * half) {
267 let diff = samples[[chain, i, d]] - mean2;
268 var2 += diff * diff;
269 }
270 var2 /= (half - 1).max(1) as f64;
271 let second_idx = n_chains + chain;
272 chain_means[second_idx] = mean2;
273 chainvars[second_idx] = var2;
274 }
275
276 let w: f64 = chainvars.iter().copied().sum::<f64>() / n_split_chains as f64;
278
279 let overall_mean: f64 = chain_means.iter().copied().sum::<f64>() / n_split_chains as f64;
281 let b: f64 = chain_means
282 .iter()
283 .map(|m| (m - overall_mean).powi(2))
284 .sum::<f64>()
285 * n_split_samples as f64
286 / (n_split_chains - 1) as f64;
287
288 let var_hat = (n_split_samples as f64 - 1.0) / n_split_samples as f64 * w
290 + b / n_split_samples as f64;
291
292 let rhat_d = if w > 1e-10 { (var_hat / w).sqrt() } else { 1.0 };
294 max_rhat = max_rhat.max(rhat_d);
295
296 let ess_d = ess_from_split_dimension(samples, n_chains, half, d);
298 min_ess = min_ess.min(ess_d);
299 }
300
301 (max_rhat, min_ess.max(1.0))
302}
303
304fn solve_upper_triangular_transpose(l: &Array2<f64>, dim: usize) -> Array2<f64> {
325 let mut result = Array2::<f64>::zeros((dim, dim));
326 if dim == 0 {
327 return result;
328 }
329
330 let l_owned;
334 let l_rows: &[f64] = if let Some(s) = l.as_slice() {
335 s
336 } else {
337 l_owned = l.to_owned();
338 l_owned
339 .as_slice()
340 .expect("owned standard-layout Array2 has contiguous storage")
341 };
342
343 let mut y = vec![0.0_f64; dim];
345
346 for col in 0..dim {
347 let d_col = l_rows[col * dim + col];
350 let inv_d_col = if d_col.abs() > 1e-15 {
351 1.0 / d_col
352 } else {
353 0.0
354 };
355 y[col] = inv_d_col;
356
357 for i in (col + 1)..dim {
360 let row_off = i * dim;
361 let l_row = &l_rows[row_off + col..row_off + i];
362 let y_seg = &y[col..i];
363 let mut sum = 0.0_f64;
367 for k in 0..l_row.len() {
368 sum += l_row[k] * y_seg[k];
369 }
370 let d = l_rows[row_off + i];
371 y[i] = if d.abs() > 1e-15 { -sum / d } else { 0.0 };
372 }
373
374 let res_row_start = col * dim + col;
378 let res_row = &mut result.as_slice_mut().expect("owned Array2 contiguous")
379 [res_row_start..res_row_start + (dim - col)];
380 for (k, slot) in res_row.iter_mut().enumerate() {
381 *slot = y[col + k];
382 }
383
384 for slot in &mut y[col..dim] {
386 *slot = 0.0;
387 }
388 }
389
390 result
391}
392
393struct WhiteningTransform {
394 chol: Array2<f64>,
395 chol_t: Array2<f64>,
396}
397
398fn hessian_whitening_transform(
399 hessian: ArrayView2<f64>,
400 dim: usize,
401 cov_scale: f64,
402 cholesky_error_prefix: &str,
403) -> Result<WhiteningTransform, String> {
404 if !(cov_scale.is_finite() && cov_scale > 0.0) {
405 return Err(format!(
406 "whitening covariance scale must be finite and strictly positive, got {cov_scale}"
407 ));
408 }
409 let hessian_owned = hessian.to_owned();
410 gam_linalg::utils::certified_spd_factorize(&hessian_owned, cholesky_error_prefix)
411 .map_err(|error| error.to_string())?;
412 let chol_factor = hessian_owned
413 .cholesky(Side::Lower)
414 .map_err(|e| format!("{cholesky_error_prefix}: {:?}", e))?;
415 let l_h = chol_factor.lower_triangular();
416 let mut chol = solve_upper_triangular_transpose(&l_h, dim);
417 let sqrt_cov_scale = cov_scale.sqrt();
418 if (sqrt_cov_scale - 1.0).abs() > 0.0 {
419 chol.mapv_inplace(|v| v * sqrt_cov_scale);
420 }
421 let chol_t = chol.t().to_owned();
422 Ok(WhiteningTransform { chol, chol_t })
423}
424
425#[derive(Clone)]
430struct SharedData {
431 x: Arc<Array2<f64>>,
433 y: Arc<Array1<f64>>,
435 weights: Arc<Array1<f64>>,
437 mode: Arc<Array1<f64>>,
439 offset: Option<Arc<Array1<f64>>>,
447 likelihood: GlmLikelihoodSpec,
452 n_samples: usize,
454 dim: usize,
456}
457
458thread_local! {
459 static NUTS_RESIDUAL_SCRATCH: RefCell<Array1<f64>> = RefCell::new(Array1::zeros(0));
460}
461
462fn resolve_hmc_likelihood(
471 likelihood: GlmLikelihoodSpec,
472 dispersion: gam_solve::model_types::Dispersion,
473) -> Result<(GlmLikelihoodSpec, f64), HmcError> {
474 let resolved_scale = likelihood
475 .resolved_scale()
476 .map_err(|error| HmcError::InvalidConfig {
477 reason: format!("HMC likelihood scale metadata is unresolved: {error}"),
478 })?;
479 let phi = dispersion.phi();
480 let inv_phi = dispersion
481 .reciprocal()
482 .map_err(|error| HmcError::InvalidConfig {
483 reason: format!("HMC likelihood requires a finite positive dispersion: {error}"),
484 })?;
485
486 if matches!(resolved_scale, ResolvedLikelihoodScale::ProfiledGaussian) {
487 if !dispersion.is_estimated() {
488 return Err(HmcError::InvalidConfig {
489 reason: "profiled-Gaussian HMC requires an estimated fitted dispersion".to_string(),
490 });
491 }
492 } else {
493 let expected = gam_solve::estimate::dispersion_from_likelihood(&likelihood, None).map_err(
497 |error| HmcError::InvalidConfig {
498 reason: format!("HMC likelihood scale metadata is inconsistent: {error}"),
499 },
500 )?;
501 if expected.phi().to_bits() != phi.to_bits()
502 || expected.is_estimated() != dispersion.is_estimated()
503 {
504 return Err(HmcError::InvalidConfig {
505 reason: format!(
506 "HMC dispersion {dispersion:?} disagrees with likelihood metadata dispersion {expected:?}"
507 ),
508 });
509 }
510 }
511
512 match (&likelihood.spec.response, &likelihood.spec.link) {
513 (ResponseFamily::Gaussian, InverseLink::Standard(StandardLink::Identity))
514 | (ResponseFamily::Gamma, InverseLink::Standard(StandardLink::Log))
515 | (ResponseFamily::Poisson, InverseLink::Standard(StandardLink::Log))
516 | (ResponseFamily::Tweedie { .. }, InverseLink::Standard(StandardLink::Log))
517 | (ResponseFamily::NegativeBinomial { .. }, InverseLink::Standard(StandardLink::Log))
518 | (ResponseFamily::Beta { .. }, InverseLink::Standard(StandardLink::Logit))
519 | (ResponseFamily::Binomial, _) => {}
520 (family, link) => {
521 return Err(HmcError::LinkMismatch {
522 reason: format!(
523 "HMC response family {} is incompatible with inverse link {link:?}",
524 family.name()
525 ),
526 });
527 }
528 }
529
530 let cov_scale = likelihood
531 .coefficient_covariance_scale(phi)
532 .map_err(|error| HmcError::InvalidConfig {
533 reason: format!("HMC coefficient-covariance scale is unresolved: {error}"),
534 })?;
535 if !(cov_scale.is_finite() && cov_scale > 0.0) {
536 return Err(HmcError::InvalidConfig {
537 reason: format!(
538 "HMC coefficient-covariance scale must be finite and positive, got {cov_scale}"
539 ),
540 });
541 }
542
543 let mut target = likelihood;
544 target.scale = match (&target.spec.response, target.scale) {
545 (ResponseFamily::Gaussian, _) => LikelihoodScaleMetadata::FixedDispersion { phi },
546 (ResponseFamily::Gamma, LikelihoodScaleMetadata::FixedDispersion { .. }) => {
547 LikelihoodScaleMetadata::FixedGammaShape { shape: inv_phi }
548 }
549 (_, scale) => scale,
550 };
551 Ok((target, cov_scale))
552}
553
554fn validate_hmc_arrays(
555 x: ArrayView2<f64>,
556 y: ArrayView1<f64>,
557 weights: ArrayView1<f64>,
558 penalty: ArrayView2<f64>,
559 mode: ArrayView1<f64>,
560 hessian: ArrayView2<f64>,
561 context: &str,
562) -> Result<(), HmcError> {
563 let n = x.nrows();
564 let p = x.ncols();
565 if y.len() != n || weights.len() != n {
566 return Err(HmcError::DimensionMismatch {
567 reason: format!(
568 "{context}: row mismatch X={n}, y={}, weights={}",
569 y.len(),
570 weights.len()
571 ),
572 });
573 }
574 if mode.len() != p
575 || penalty.nrows() != p
576 || penalty.ncols() != p
577 || hessian.nrows() != p
578 || hessian.ncols() != p
579 {
580 return Err(HmcError::DimensionMismatch {
581 reason: format!(
582 "{context}: coefficient geometry mismatch X columns={p}, mode={}, penalty={:?}, hessian={:?}",
583 mode.len(),
584 penalty.dim(),
585 hessian.dim(),
586 ),
587 });
588 }
589 for (name, values) in [
590 ("design", x.iter()),
591 ("penalty", penalty.iter()),
592 ("hessian", hessian.iter()),
593 ] {
594 if let Some((index, value)) = values.enumerate().find(|(_, value)| !value.is_finite()) {
595 return Err(HmcError::NonFiniteState {
596 reason: format!(
597 "{context}: {name} has non-finite entry {value} at flat index {index}"
598 ),
599 });
600 }
601 }
602 if let Some((index, value)) = mode
603 .iter()
604 .enumerate()
605 .find(|(_, value)| !value.is_finite())
606 {
607 return Err(HmcError::NonFiniteState {
608 reason: format!("{context}: mode has non-finite entry {value} at index {index}"),
609 });
610 }
611 if let Some((index, weight)) = weights
612 .iter()
613 .enumerate()
614 .find(|(_, weight)| !(weight.is_finite() && **weight >= 0.0))
615 {
616 return Err(HmcError::InvalidConfig {
617 reason: format!(
618 "{context}: observation weight at row {index} must be finite and non-negative, got {weight}"
619 ),
620 });
621 }
622 for row in 0..p {
623 for col in (row + 1)..p {
624 if penalty[[row, col]] != penalty[[col, row]] {
625 return Err(HmcError::InvalidConfig {
626 reason: format!(
627 "{context}: penalty must be exactly symmetric; ({row},{col})={} but ({col},{row})={}",
628 penalty[[row, col]],
629 penalty[[col, row]],
630 ),
631 });
632 }
633 }
634 }
635 Ok(())
636}
637
638fn first_non_finite<'a>(values: impl IntoIterator<Item = &'a f64>) -> Option<(usize, f64)> {
639 values
640 .into_iter()
641 .copied()
642 .enumerate()
643 .find(|(_, value)| !value.is_finite())
644}
645
646pub struct NutsPosterior {
659 data: SharedData,
661 chol: Array2<f64>,
664 chol_t: Array2<f64>,
666 firth_enabled: bool,
669 penalty_z_quad: Array2<f64>,
675 penalty_z_lin: Array1<f64>,
678 penalty_z_const: f64,
680 cov_scale: f64,
686}
687
688impl NutsPosterior {
689 pub fn new(
704 x: ArrayView2<f64>,
705 y: ArrayView1<f64>,
706 weights: ArrayView1<f64>,
707 penalty_matrix: ArrayView2<f64>,
708 mode: ArrayView1<f64>,
709 hessian: ArrayView2<f64>,
710 likelihood: GlmLikelihoodSpec,
711 dispersion: gam_solve::model_types::Dispersion,
712 offset: Option<ArrayView1<f64>>,
713 firth_enabled: bool,
714 ) -> Result<Self, String> {
715 let n_samples = x.nrows();
716 let dim = x.ncols();
717
718 validate_hmc_arrays(x, y, weights, penalty_matrix, mode, hessian, "NUTS")
719 .map_err(String::from)?;
720 let (likelihood, cov_scale) =
721 resolve_hmc_likelihood(likelihood, dispersion).map_err(String::from)?;
722 if let Some(offset) = offset.as_ref() {
723 if offset.len() != n_samples {
724 return Err(HmcError::DimensionMismatch {
725 reason: format!(
726 "NUTS offset length {} does not match {n_samples} observations",
727 offset.len()
728 ),
729 }
730 .into());
731 }
732 if let Some((row, value)) = offset
733 .iter()
734 .enumerate()
735 .find(|(_, value)| !value.is_finite())
736 {
737 return Err(HmcError::NonFiniteState {
738 reason: format!("NUTS offset has non-finite value {value} at row {row}"),
739 }
740 .into());
741 }
742 }
743 validate_firth_likelihood_support(&likelihood.spec, firth_enabled).map_err(String::from)?;
744 if likelihood.spec.is_binomial() {
745 validate_binary_responses("binomial NUTS", &y, &weights).map_err(String::from)?;
746 }
747 if matches!(
748 likelihood.spec.response,
749 ResponseFamily::NegativeBinomial { .. }
750 ) {
751 validate_count_responses("negative-binomial NUTS", &y, &weights)
752 .map_err(String::from)?;
753 }
754 let mut eta_at_mode = x.dot(&mode);
755 if let Some(offset) = offset.as_ref() {
756 eta_at_mode += offset;
757 }
758 let mut score_at_mode = Array1::zeros(n_samples);
759 gam_solve::pirls::eta_log_likelihood_value_and_score_into(
760 y,
761 &eta_at_mode,
762 &likelihood,
763 &likelihood.spec.link,
764 weights,
765 &mut score_at_mode,
766 )
767 .map_err(|error| format!("NUTS likelihood is invalid at the fitted mode: {error}"))?;
768
769 let whitening = hessian_whitening_transform(
778 hessian,
779 dim,
780 cov_scale,
781 "Hessian Cholesky decomposition failed",
782 )?;
783 let chol = whitening.chol;
784 let chol_t = whitening.chol_t;
785
786 let penalty_owned = penalty_matrix.to_owned();
794 let mode_owned = mode.to_owned();
795 let s_mu = penalty_owned.dot(&mode_owned);
796 let penalty_z_const = 0.5 * mode_owned.dot(&s_mu);
797 let penalty_z_lin = chol_t.dot(&s_mu);
798 let s_chol = penalty_owned.dot(&chol);
801 let penalty_z_quad = chol_t.dot(&s_chol);
802
803 let data = SharedData {
804 x: Arc::new(x.to_owned()),
805 y: Arc::new(y.to_owned()),
806 weights: Arc::new(weights.to_owned()),
807 mode: Arc::new(mode_owned),
808 offset: offset.map(|values| Arc::new(values.to_owned())),
809 likelihood,
810 n_samples,
811 dim,
812 };
813
814 Ok(Self {
815 data,
816 chol,
817 chol_t,
818 firth_enabled,
819 penalty_z_quad,
820 penalty_z_lin,
821 penalty_z_const,
822 cov_scale,
823 })
824 }
825
826 fn compute_logp_and_grad_nd_into(
827 &self,
828 z: &Array1<f64>,
829 residual: &mut Array1<f64>,
830 grad: &mut Array1<f64>,
831 ) -> f64 {
832 let beta = self.data.mode.as_ref() + &self.chol.dot(z);
835
836 let mut eta = gam_linalg::faer_ndarray::fast_av(self.data.x.as_ref(), &beta);
838 if let Some(offset) = self.data.offset.as_ref() {
839 eta += offset.as_ref();
840 }
841
842 let (ll, mut grad_ll_beta) = match self.family_logp_and_grad_into(&eta, residual) {
844 Ok(value) => value,
845 Err(error) => {
846 log::warn!("[NUTS] likelihood target is unrepresentable: {error}");
847 grad.fill(0.0);
848 return f64::NEG_INFINITY;
849 }
850 };
851
852 let mut firth_logdet = 0.0;
853 if self.firth_enabled {
854 match firth_jeffreys_logp_and_grad(&self.data.likelihood.spec, &self.data, &eta) {
855 Ok((value, grad_beta_firth)) => {
856 firth_logdet = value;
857 grad_ll_beta += &grad_beta_firth;
858 }
859 Err(err) => {
860 log::warn!(
861 "[NUTS/Firth] Jeffreys target became invalid at the current state: {}",
862 err
863 );
864 grad.fill(0.0);
865 return f64::NEG_INFINITY;
866 }
867 }
868 }
869
870 let penalty_scale = 1.0 / self.cov_scale;
894 let mz = self.penalty_z_quad.dot(z);
895 let lin_term = self.penalty_z_lin.dot(z);
896 let quad_term = 0.5 * z.dot(&mz);
897 let penalty = penalty_scale * (self.penalty_z_const + lin_term + quad_term);
898
899 fast_av_into(&self.chol_t, &grad_ll_beta, grad);
902 let lin_view = self.penalty_z_lin.view();
904 ndarray::Zip::from(grad)
905 .and(&lin_view)
906 .and(&mz)
907 .par_for_each(|g, &l, &m| {
908 *g -= penalty_scale * (l + m);
909 });
910
911 ll + firth_logdet - penalty
912 }
913
914 fn family_logp_and_grad_into(
915 &self,
916 eta: &Array1<f64>,
917 residual: &mut Array1<f64>,
918 ) -> Result<(f64, Array1<f64>), String> {
919 exact_glm_logp_and_grad_into(&self.data, eta, residual)
920 }
921
922 pub fn chol(&self) -> &Array2<f64> {
924 &self.chol
925 }
926
927 pub fn mode(&self) -> &Array1<f64> {
929 &self.data.mode
930 }
931
932 pub fn dim(&self) -> usize {
934 self.data.dim
935 }
936}
937
938#[inline]
939fn validate_firth_likelihood_support(
940 likelihood: &LikelihoodSpec,
941 firth_enabled: bool,
942) -> Result<(), HmcError> {
943 if firth_enabled && !likelihood_spec_supports_firth(likelihood) {
944 return Err(HmcError::FirthUnsupported {
945 reason: format!(
946 "Joint HMC with Firth requires a Binomial inverse link with a Fisher-weight jet; {} does not support it",
947 likelihood.pretty_name()
948 ),
949 });
950 }
951 Ok::<(), _>(())
952}
953
954fn validate_count_responses(
963 family: &str,
964 y: &ArrayView1<'_, f64>,
965 weights: &ArrayView1<'_, f64>,
966) -> Result<(), HmcError> {
967 gam_solve::pirls::certify_count_responses(y, weights, family)
968 .map_err(|reason| HmcError::InvalidConfig { reason })
969}
970
971fn validate_binary_responses(
972 family: &str,
973 y: &ArrayView1<'_, f64>,
974 weights: &ArrayView1<'_, f64>,
975) -> Result<(), HmcError> {
976 for (i, (&yi, &wi)) in y.iter().zip(weights.iter()).enumerate() {
977 if wi > 0.0 && !(yi == 0.0 || yi == 1.0) {
978 return Err(HmcError::InvalidConfig {
979 reason: format!(
980 "{family} response must be exactly 0 or 1 at positive-weight row {i}; got {yi}"
981 ),
982 });
983 }
984 }
985 Ok(())
986}
987
988fn firth_jeffreys_logp_and_grad(
1001 likelihood: &LikelihoodSpec,
1002 data: &SharedData,
1003 eta: &Array1<f64>,
1004) -> Result<(f64, Array1<f64>), HmcError> {
1005 if eta.len() != data.n_samples {
1006 return Err(HmcError::DimensionMismatch {
1007 reason: format!(
1008 "Firth Jeffreys term eta length {} != number of samples {}",
1009 eta.len(),
1010 data.n_samples
1011 ),
1012 });
1013 }
1014 if data.dim == 0 || data.n_samples == 0 {
1015 return Ok((0.0, Array1::zeros(data.dim)));
1016 }
1017 validate_firth_likelihood_support(likelihood, true)?;
1018 if data.weights.iter().all(|w| *w == 0.0) {
1019 return Ok((0.0, Array1::zeros(data.dim)));
1020 }
1021
1022 let jeffreys_link =
1023 likelihood_spec_jeffreys_link(likelihood).ok_or_else(|| HmcError::FirthUnsupported {
1024 reason: format!(
1025 "Firth Jeffreys term has no Fisher-weight jet for {}",
1026 likelihood.pretty_name()
1027 ),
1028 })?;
1029 let op = if data.weights.iter().all(|&w| w == 1.0) {
1030 FirthDenseOperator::build_for_link(&jeffreys_link, data.x.as_ref(), eta)
1031 } else {
1032 FirthDenseOperator::build_with_observation_weights_for_link(
1033 &jeffreys_link,
1034 data.x.as_ref(),
1035 eta,
1036 data.weights.view(),
1037 )
1038 }
1039 .map_err(|e| HmcError::SamplingFailed {
1040 reason: format!("Firth Jeffreys operator failed: {e}"),
1041 })?;
1042 Ok(op.jeffreys_logdet_and_beta_gradient())
1043}
1044
1045fn exact_glm_logp_and_grad_for_likelihood_into(
1054 likelihood: &GlmLikelihoodSpec,
1055 data: &SharedData,
1056 eta: &Array1<f64>,
1057 residual: &mut Array1<f64>,
1058) -> Result<(f64, Array1<f64>), String> {
1059 let value = gam_solve::pirls::eta_log_likelihood_value_and_score_into(
1060 data.y.view(),
1061 eta,
1062 likelihood,
1063 &likelihood.spec.link,
1064 data.weights.view(),
1065 residual,
1066 )
1067 .map_err(|error| error.to_string())?;
1068 Ok((value, fast_atv(data.x.as_ref(), residual)))
1069}
1070
1071fn exact_glm_logp_and_grad_into(
1072 data: &SharedData,
1073 eta: &Array1<f64>,
1074 residual: &mut Array1<f64>,
1075) -> Result<(f64, Array1<f64>), String> {
1076 exact_glm_logp_and_grad_for_likelihood_into(&data.likelihood, data, eta, residual)
1077}
1078
1079#[derive(Clone, Debug)]
1080struct BinomialLinkTerms {
1081 log_mu: f64,
1082 log1m_mu: f64,
1083 dmu_dlink: Vec<f64>,
1084}
1085
1086#[inline]
1087fn log_terms_from_mu_and_dmu(
1088 mu: f64,
1089 dmu_deta: f64,
1090 dmu_dlink: Vec<f64>,
1091) -> Result<BinomialLinkTerms, String> {
1092 if !(mu.is_finite() && (0.0..=1.0).contains(&mu) && dmu_deta.is_finite()) {
1093 return Err(format!(
1094 "binomial inverse link returned invalid mu/deta derivative: mu={mu}, dmu_deta={dmu_deta}"
1095 ));
1096 }
1097 let log_mu = if mu == 0.0 {
1098 f64::NEG_INFINITY
1099 } else {
1100 mu.ln()
1101 };
1102 let one_minus_mu = 1.0 - mu;
1103 let log1m_mu = if one_minus_mu == 0.0 {
1104 f64::NEG_INFINITY
1105 } else {
1106 one_minus_mu.ln()
1107 };
1108 Ok(BinomialLinkTerms {
1109 log_mu,
1110 log1m_mu,
1111 dmu_dlink,
1112 })
1113}
1114
1115#[inline]
1116fn binomial_link_terms(
1117 inverse_link: &InverseLink,
1118 eta: f64,
1119 n_link_params: usize,
1120) -> Result<BinomialLinkTerms, String> {
1121 let jet =
1122 inverse_link_jet_for_inverse_link(inverse_link, eta).map_err(|err| err.to_string())?;
1123 let mut dmu_dlink = vec![0.0; n_link_params];
1124 if n_link_params > 0 {
1125 match inverse_link
1126 .param_partials(eta)
1127 .map_err(|err| err.to_string())?
1128 {
1129 Some(LinkParamPartials::Sas(partials)) => {
1130 if n_link_params != 2 {
1131 return Err(format!(
1132 "SAS/Beta-Logistic link parameter dimension mismatch: expected 2, got {n_link_params}"
1133 ));
1134 }
1135 dmu_dlink[0] = partials.djet_depsilon.mu;
1136 dmu_dlink[1] = partials.djet_dlog_delta.mu;
1137 }
1138 Some(LinkParamPartials::Mixture(partials)) => {
1139 if partials.djet_drho.len() != n_link_params {
1140 return Err(format!(
1141 "mixture link parameter dimension mismatch: expected {}, got {n_link_params}",
1142 partials.djet_drho.len()
1143 ));
1144 }
1145 for (slot, partial) in dmu_dlink.iter_mut().zip(partials.djet_drho.iter()) {
1146 *slot = partial.mu;
1147 }
1148 }
1149 None => {
1150 return Err(format!(
1151 "joint HMC expected {n_link_params} adaptive link parameters, but the inverse link exposes none"
1152 ));
1153 }
1154 }
1155 }
1156 log_terms_from_mu_and_dmu(jet.mu, jet.d1, dmu_dlink)
1157}
1158
1159fn joint_binomial_logp_grad_and_link_grad(
1160 inverse_link: &InverseLink,
1161 data: &SharedData,
1162 eta: &Array1<f64>,
1163 n_link_params: usize,
1164) -> Result<(f64, Array1<f64>, Array1<f64>), String> {
1165 let n = data.n_samples;
1166 let mut likelihood = data.likelihood.clone();
1167 likelihood.spec = LikelihoodSpec::new(ResponseFamily::Binomial, inverse_link.clone());
1168 let mut residual = Array1::<f64>::zeros(n);
1169 let (ll, grad_beta) =
1170 exact_glm_logp_and_grad_for_likelihood_into(&likelihood, data, eta, &mut residual)?;
1171
1172 use rayon::iter::{IntoParallelIterator, ParallelIterator};
1176 let per_row: Result<Vec<Vec<f64>>, String> = (0..n)
1177 .into_par_iter()
1178 .map(|i| {
1179 let y_i = data.y[i];
1180 let w_i = data.weights[i];
1181 if w_i == 0.0 {
1182 return Ok(vec![0.0; n_link_params]);
1183 }
1184 let terms = binomial_link_terms(inverse_link, eta[i], n_link_params)?;
1185 let (sign, log_probability) = if y_i == 1.0 {
1186 (1.0, terms.log_mu)
1187 } else if y_i == 0.0 {
1188 (-1.0, terms.log1m_mu)
1189 } else {
1190 return Err(format!(
1191 "binomial joint HMC response must be exactly 0 or 1 after validation; got {y_i}"
1192 ));
1193 };
1194 terms
1195 .dmu_dlink
1196 .into_iter()
1197 .map(|dmu| {
1198 if dmu == 0.0 {
1199 return Ok(0.0);
1200 }
1201 if !dmu.is_finite() {
1202 return Err(format!(
1203 "binomial adaptive-link derivative is non-finite at row {i}: {dmu}"
1204 ));
1205 }
1206 let log_abs = w_i.ln() + dmu.abs().ln() - log_probability;
1207 let value = sign * dmu.signum() * log_abs.exp();
1208 if value.is_finite() {
1209 Ok(value)
1210 } else {
1211 Err(format!(
1212 "binomial adaptive-link score is unrepresentable at row {i}"
1213 ))
1214 }
1215 })
1216 .collect()
1217 })
1218 .collect();
1219 let per_row = per_row?;
1220 let mut grad_link = Array1::<f64>::zeros(n_link_params);
1221 for grad_link_i in per_row {
1222 for (slot, value) in grad_link.iter_mut().zip(grad_link_i.iter()) {
1223 *slot += *value;
1224 }
1225 }
1226
1227 Ok((ll, grad_beta, grad_link))
1228}
1229
1230fn joint_family_logp_grad_and_link_grad(
1231 likelihood: &LikelihoodSpec,
1232 data: &SharedData,
1233 eta: &Array1<f64>,
1234 n_link_params: usize,
1235) -> Result<(f64, Array1<f64>, Array1<f64>), String> {
1236 match (&likelihood.response, &likelihood.link) {
1237 (ResponseFamily::Binomial, InverseLink::Standard(_)) => {
1238 let (ll, grad) = joint_family_logp_and_grad(likelihood, data, eta)?;
1239 Ok((ll, grad, Array1::zeros(n_link_params)))
1240 }
1241 (
1242 ResponseFamily::Binomial,
1243 InverseLink::LatentCLogLog(_)
1244 | InverseLink::Sas(_)
1245 | InverseLink::BetaLogistic(_)
1246 | InverseLink::Mixture(_),
1247 ) => joint_binomial_logp_grad_and_link_grad(&likelihood.link, data, eta, n_link_params),
1248 _ => {
1249 let (ll, grad) = joint_family_logp_and_grad(likelihood, data, eta)?;
1250 Ok((ll, grad, Array1::zeros(n_link_params)))
1251 }
1252 }
1253}
1254
1255fn joint_family_logp_and_grad(
1256 likelihood: &LikelihoodSpec,
1257 data: &SharedData,
1258 eta: &Array1<f64>,
1259) -> Result<(f64, Array1<f64>), String> {
1260 if likelihood.response != data.likelihood.spec.response {
1261 return Err(HmcError::InvalidConfig {
1262 reason: format!(
1263 "joint HMC step response {:?} disagrees with resolved response {:?}",
1264 likelihood.response, data.likelihood.spec.response
1265 ),
1266 }
1267 .into());
1268 }
1269 let mut resolved = data.likelihood.clone();
1270 resolved.spec = likelihood.clone();
1271 let mut residual = Array1::zeros(data.n_samples);
1272 exact_glm_logp_and_grad_for_likelihood_into(&resolved, data, eta, &mut residual)
1273}
1274
1275#[cfg(test)]
1276mod tests {
1277
1278 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1284 pub enum NutsFamily {
1285 Gaussian,
1286 BinomialLogit,
1287 BinomialProbit,
1288 PoissonLog,
1289 GammaLog,
1290 }
1291
1292 impl NutsFamily {
1293 #[inline]
1294 fn likelihood_spec(self) -> LikelihoodSpec {
1295 match self {
1296 Self::Gaussian => LikelihoodSpec {
1297 response: ResponseFamily::Gaussian,
1298 link: InverseLink::Standard(StandardLink::Identity),
1299 },
1300 Self::BinomialLogit => LikelihoodSpec {
1301 response: ResponseFamily::Binomial,
1302 link: InverseLink::Standard(StandardLink::Logit),
1303 },
1304 Self::BinomialProbit => LikelihoodSpec {
1305 response: ResponseFamily::Binomial,
1306 link: InverseLink::Standard(StandardLink::Probit),
1307 },
1308 Self::PoissonLog => LikelihoodSpec {
1309 response: ResponseFamily::Poisson,
1310 link: InverseLink::Standard(StandardLink::Log),
1311 },
1312 Self::GammaLog => LikelihoodSpec {
1313 response: ResponseFamily::Gamma,
1314 link: InverseLink::Standard(StandardLink::Log),
1315 },
1316 }
1317 }
1318 }
1319
1320 use super::{
1321 FamilyNutsInputs, GlmFlatInputs, JointBetaRhoInputs, JointBetaRhoPosterior, NutsConfig,
1322 NutsPosterior, NutsResult, SharedData, exact_glm_logp_and_grad_into,
1323 firth_jeffreys_logp_and_grad, joint_family_logp_and_grad,
1324 laplace_directional_cubic_diagnostic, laplace_skewness_threshold,
1325 laplace_trustworthiness_from_skewness, run_joint_beta_rho_sampling,
1326 run_logit_polya_gamma_gibbs, run_nuts_sampling_flattened_family,
1327 };
1328 use gam_linalg::matrix::DesignMatrix;
1329 use gam_models::survival::{PenaltyBlocks, SurvivalMonotonicityPenalty, SurvivalSpec};
1330 use gam_problem::types::{
1331 GlmLikelihoodSpec, InverseLink, LikelihoodScaleMetadata, LikelihoodSpec,
1332 LogLikelihoodNormalization, ResponseFamily, RhoPrior, StandardLink,
1333 };
1334 use gam_solve::estimate::{
1335 BlockRole, FitGeometry, FitInference, FittedBlock, FittedLinkState, UnifiedFitResult,
1336 UnifiedFitResultParts,
1337 };
1338 use gam_terms::construction::CanonicalPenalty;
1339 use general_mcmc::generic_hmc::HamiltonianTarget;
1340 use ndarray::{Array1, Array2, array};
1341 use std::sync::Arc;
1342
1343 #[test]
1344 fn posterior_interval_uses_shared_linear_quantiles() {
1345 let result = NutsResult {
1346 samples: array![[0.0], [1.0], [2.0], [3.0]],
1347 posterior_mean: array![1.5],
1348 posterior_std: array![1.0],
1349 rhat: 1.0,
1350 ess: 4.0,
1351 converged: true,
1352 };
1353
1354 let (lower, upper) = result.posterior_interval_of(|row| row[0], 25.0, 75.0);
1355
1356 assert!((lower - 0.75).abs() < 1e-12, "lower = {lower}");
1357 assert!((upper - 2.25).abs() < 1e-12, "upper = {upper}");
1358 }
1359
1360 impl NutsPosterior {
1361 pub(super) fn compute_logp_and_grad_nd(&self, z: &Array1<f64>) -> (f64, Array1<f64>) {
1363 let mut residual = Array1::<f64>::zeros(self.data.n_samples);
1364 let mut grad = Array1::<f64>::zeros(z.len());
1365 let logp = self.compute_logp_and_grad_nd_into(z, &mut residual, &mut grad);
1366 (logp, grad)
1367 }
1368 }
1369
1370 impl JointBetaRhoPosterior {
1371 pub(super) fn compute_joint_logp_and_grad(
1373 &self,
1374 params: &Array1<f64>,
1375 ) -> (f64, Array1<f64>) {
1376 let total_dim = self.n_beta + self.n_rho + self.n_link_params;
1377 let mut grad = Array1::<f64>::zeros(total_dim);
1378 let logp = self.compute_joint_logp_and_grad_into(params, &mut grad);
1379 (logp, grad)
1380 }
1381 }
1382
1383 fn nuts_test_likelihood(family: NutsFamily, parameter: f64) -> GlmLikelihoodSpec {
1384 let spec = family.likelihood_spec();
1385 let scale = match family {
1386 NutsFamily::Gaussian => LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
1387 NutsFamily::GammaLog => {
1388 LikelihoodScaleMetadata::EstimatedGammaShape { shape: parameter }
1389 }
1390 NutsFamily::BinomialLogit | NutsFamily::BinomialProbit | NutsFamily::PoissonLog => {
1391 LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 }
1392 }
1393 };
1394 GlmLikelihoodSpec { spec, scale }
1395 }
1396
1397 fn exact_eta_geometry(
1398 likelihood: &GlmLikelihoodSpec,
1399 y: &Array1<f64>,
1400 weights: &Array1<f64>,
1401 eta: &Array1<f64>,
1402 ) -> Result<(f64, Array1<f64>), String> {
1403 let mut score = Array1::zeros(eta.len());
1404 let value = gam_solve::pirls::eta_log_likelihood_value_and_score_into(
1405 y.view(),
1406 eta,
1407 likelihood,
1408 &likelihood.spec.link,
1409 weights.view(),
1410 &mut score,
1411 )
1412 .map_err(|error| error.to_string())?;
1413 Ok((value, score))
1414 }
1415
1416 fn hmc_test_fit(
1417 blocks: Vec<FittedBlock>,
1418 inference: Option<FitInference>,
1419 geometry: Option<FitGeometry>,
1420 ) -> UnifiedFitResult {
1421 let lambdas = Array1::zeros(0);
1422 UnifiedFitResult::try_from_parts(UnifiedFitResultParts {
1423 blocks,
1424 training_sample_size: 16,
1425 log_lambdas: lambdas.clone(),
1426 lambdas,
1427 likelihood_family: Some(LikelihoodSpec::new(
1428 ResponseFamily::Gaussian,
1429 InverseLink::Standard(StandardLink::Identity),
1430 )),
1431 likelihood_scale: LikelihoodScaleMetadata::ProfiledGaussian,
1432 log_likelihood_normalization: LogLikelihoodNormalization::Full,
1433 log_likelihood: -1.0,
1434 deviance: 2.0,
1435 reml_score: Some(0.0),
1436 stable_penalty_term: 0.0,
1437 penalized_objective: Some(0.0),
1438 used_device: false,
1439 outer_iterations: 0,
1446 outer_converged: true,
1447 outer_gradient_norm: None,
1448 standard_deviation: 1.0,
1449 covariance_conditional: None,
1450 covariance_corrected: None,
1451 inference,
1452 fitted_link: FittedLinkState::Standard(None),
1453 geometry,
1454 block_states: Vec::new(),
1455 pirls_status: gam_solve::pirls::PirlsStatus::Converged,
1456 max_abs_eta: 0.0,
1457 constraint_kkt: None,
1458 artifacts: Default::default(),
1459 inner_cycles: 0,
1460 })
1461 .expect("valid HMC handoff test fit")
1462 }
1463
1464 #[test]
1465 fn hmc_whitening_consumes_standard_fit_inference_hessian() {
1466 let hessian = array![[2.0, 0.1], [0.1, 1.6]];
1467 let fit = hmc_test_fit(
1468 vec![FittedBlock {
1469 beta: array![0.05, -0.1],
1470 role: BlockRole::Mean,
1471 edf: 2.0,
1472 lambdas: Array1::zeros(0),
1473 }],
1474 Some(FitInference {
1475 edf_by_block: vec![],
1476 penalty_block_trace: vec![],
1477 edf_total: 2.0,
1478 smoothing_correction: None,
1479 smoothing_correction_method: None,
1480 smoothing_correction_first_order: None,
1481 smoothing_correction_method_first_order: None,
1482 penalized_hessian: hessian.clone().into(),
1483 reparam_qs: None,
1484 dispersion: gam_solve::estimate::Dispersion::UNIT,
1485 beta_covariance: None,
1486 beta_standard_errors: None,
1487 beta_covariance_corrected: None,
1488 beta_standard_errors_corrected: None,
1489 beta_covariance_frequentist: None,
1490 coefficient_influence: None,
1491 weighted_gram: None,
1492 bias_correction_beta: None,
1493 bias_correction_jacobian: None,
1494 }),
1495 None,
1496 );
1497
1498 let explicit = super::explicit_fit_hessian_for_whitening(&fit, 2, "standard fit")
1499 .expect("standard fit exports explicit Hessian");
1500 assert_eq!(explicit, &hessian);
1501
1502 let x = array![[1.0, 0.0], [1.0, 0.5], [1.0, -0.5]];
1503 let y = array![0.0, 0.2, -0.1];
1504 let weights = Array1::ones(3);
1505 let penalty = Array2::eye(2);
1506 NutsPosterior::new(
1507 x.view(),
1508 y.view(),
1509 weights.view(),
1510 penalty.view(),
1511 fit.beta.view(),
1512 explicit.view(),
1513 nuts_test_likelihood(NutsFamily::Gaussian, 1.0),
1514 gam_solve::estimate::Dispersion::UNIT,
1515 None,
1516 false,
1517 )
1518 .expect("HMC target whitens with upstream Hessian");
1519 }
1520
1521 #[test]
1522 fn hmc_whitening_consumes_blockwise_geometry_hessian() {
1523 let hessian = array![[3.0, 0.2], [0.2, 2.0]];
1524 let fit = hmc_test_fit(
1525 vec![
1526 FittedBlock {
1527 beta: array![0.1],
1528 role: BlockRole::Location,
1529 edf: 1.0,
1530 lambdas: Array1::zeros(0),
1531 },
1532 FittedBlock {
1533 beta: array![-0.2],
1534 role: BlockRole::Scale,
1535 edf: 1.0,
1536 lambdas: Array1::zeros(0),
1537 },
1538 ],
1539 None,
1540 Some(FitGeometry {
1541 coefficient_gauge: gam_problem::Gauge::identity(&[1, 1]),
1542 penalized_hessian: hessian.clone().into(),
1543 constrained_posterior: None,
1544 working: None,
1545 }),
1546 );
1547
1548 let explicit = super::explicit_fit_hessian_for_whitening(&fit, 2, "blockwise fit")
1549 .expect("blockwise fit exports materialized Hessian");
1550 assert_eq!(explicit, &hessian);
1551 }
1552
1553 #[test]
1554 fn hmc_whitening_rejects_covariance_only_fit_without_synthesizing_hessian() {
1555 let fit = UnifiedFitResult::try_from_parts(UnifiedFitResultParts {
1556 blocks: vec![FittedBlock {
1557 beta: array![0.0],
1558 role: BlockRole::Mean,
1559 edf: 1.0,
1560 lambdas: Array1::zeros(0),
1561 }],
1562 training_sample_size: 16,
1563 log_lambdas: Array1::zeros(0),
1564 lambdas: Array1::zeros(0),
1565 likelihood_family: Some(LikelihoodSpec::new(
1566 ResponseFamily::Gaussian,
1567 InverseLink::Standard(StandardLink::Identity),
1568 )),
1569 likelihood_scale: LikelihoodScaleMetadata::ProfiledGaussian,
1570 log_likelihood_normalization: LogLikelihoodNormalization::Full,
1571 log_likelihood: -1.0,
1572 deviance: 2.0,
1573 reml_score: Some(0.0),
1574 stable_penalty_term: 0.0,
1575 penalized_objective: Some(0.0),
1576 used_device: false,
1577 outer_iterations: 0,
1584 outer_converged: true,
1585 outer_gradient_norm: None,
1586 standard_deviation: 1.0,
1587 covariance_conditional: Some(array![[0.5]]),
1588 covariance_corrected: None,
1589 inference: None,
1590 fitted_link: FittedLinkState::Standard(None),
1591 geometry: None,
1592 block_states: Vec::new(),
1593 pirls_status: gam_solve::pirls::PirlsStatus::Converged,
1594 max_abs_eta: 0.0,
1595 constraint_kkt: None,
1596 artifacts: Default::default(),
1597 inner_cycles: 0,
1598 })
1599 .expect("covariance-only fit can exist for prediction");
1600
1601 let err = super::explicit_fit_hessian_for_whitening(&fit, 1, "covariance-only fit")
1602 .expect_err("HMC must not invert covariance as a Hessian fallback");
1603 assert!(
1604 err.contains("missing an explicit penalized Hessian"),
1605 "unexpected error: {err}"
1606 );
1607 }
1608
1609 #[test]
1610 fn log1pexp_is_finite_for_extreme_eta() {
1611 assert!(gam_linalg::utils::stable_softplus(1000.0).is_finite());
1612 assert!(gam_linalg::utils::stable_softplus(-1000.0).is_finite());
1613 assert!((gam_linalg::utils::stable_softplus(-1000.0) - 0.0).abs() < 1e-12);
1614 }
1615
1616 #[test]
1617 fn sigmoid_stable_behaves_at_extremes() {
1618 let hi = gam_linalg::utils::stable_logistic(1000.0);
1619 let lo = gam_linalg::utils::stable_logistic(-1000.0);
1620 assert!((1.0 - 1e-12..=1.0).contains(&hi));
1621 assert!((0.0..=1e-12).contains(&lo));
1622 }
1623
1624 #[test]
1625 fn exact_hmc_family_surfaces_share_value_and_eta_score() {
1626 let fixed = |spec: LikelihoodSpec, scale: LikelihoodScaleMetadata| GlmLikelihoodSpec {
1627 spec,
1628 scale,
1629 };
1630 let cases = vec![
1631 (
1632 fixed(
1633 LikelihoodSpec::gaussian_identity(),
1634 LikelihoodScaleMetadata::FixedDispersion { phi: 2.0 },
1635 ),
1636 array![f64::NAN, -0.4, 1.7],
1637 ),
1638 (
1639 GlmLikelihoodSpec::canonical(LikelihoodSpec::binomial_logit()),
1640 array![f64::NAN, 1.0, 0.0],
1641 ),
1642 (
1643 GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
1644 ResponseFamily::Binomial,
1645 InverseLink::Standard(StandardLink::Probit),
1646 )),
1647 array![f64::NAN, 1.0, 0.0],
1648 ),
1649 (
1650 GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
1651 ResponseFamily::Binomial,
1652 InverseLink::Standard(StandardLink::CLogLog),
1653 )),
1654 array![f64::NAN, 1.0, 0.0],
1655 ),
1656 (
1657 GlmLikelihoodSpec::canonical(LikelihoodSpec::poisson_log()),
1658 array![f64::NAN, 0.0, 3.0],
1659 ),
1660 (
1661 fixed(
1662 LikelihoodSpec::new(
1663 ResponseFamily::Gamma,
1664 InverseLink::Standard(StandardLink::Log),
1665 ),
1666 LikelihoodScaleMetadata::FixedGammaShape { shape: 3.0 },
1667 ),
1668 array![f64::NAN, 0.4, 2.2],
1669 ),
1670 (
1671 fixed(
1672 LikelihoodSpec::new(
1673 ResponseFamily::Tweedie { p: 1.4 },
1674 InverseLink::Standard(StandardLink::Log),
1675 ),
1676 LikelihoodScaleMetadata::FixedDispersion { phi: 0.7 },
1677 ),
1678 array![f64::NAN, 0.0, 2.2],
1679 ),
1680 (
1681 fixed(
1682 LikelihoodSpec::new(
1683 ResponseFamily::NegativeBinomial {
1684 theta: 2.5,
1685 theta_fixed: true,
1686 },
1687 InverseLink::Standard(StandardLink::Log),
1688 ),
1689 LikelihoodScaleMetadata::FixedNegBinTheta { theta: 2.5 },
1690 ),
1691 array![f64::NAN, 0.0, 3.0],
1692 ),
1693 (
1694 fixed(
1695 LikelihoodSpec::new(
1696 ResponseFamily::Beta { phi: 12.0 },
1697 InverseLink::Standard(StandardLink::Logit),
1698 ),
1699 LikelihoodScaleMetadata::EstimatedBetaPhi { phi: 12.0 },
1700 ),
1701 array![f64::NAN, 0.2, 0.8],
1702 ),
1703 ];
1704 let weights = array![0.0, 1.0e-300, 1.25];
1705 let eta = array![0.0, -0.35, 0.6];
1706 for (likelihood, y) in cases {
1707 let (value, score) = exact_eta_geometry(&likelihood, &y, &weights, &eta)
1708 .unwrap_or_else(|error| panic!("{}: {error}", likelihood.spec.pretty_name()));
1709 assert!(value.is_finite());
1710 assert_eq!(
1720 score[0],
1721 0.0,
1722 "{} must erase a zero-weight row's score exactly",
1723 likelihood.spec.pretty_name()
1724 );
1725 assert!(
1726 score[1] != 0.0 && score[1].is_finite(),
1727 "{} erased a positive tiny weight",
1728 likelihood.spec.pretty_name()
1729 );
1730 let eps = 1.0e-6;
1731 let mut plus = eta.clone();
1732 let mut minus = eta.clone();
1733 plus[2] += eps;
1734 minus[2] -= eps;
1735 let (vp, _) = exact_eta_geometry(&likelihood, &y, &weights, &plus).unwrap();
1736 let (vm, _) = exact_eta_geometry(&likelihood, &y, &weights, &minus).unwrap();
1737 let fd = (vp - vm) / (2.0 * eps);
1738 let tolerance = 2.0e-5 * (1.0 + fd.abs());
1739 assert!(
1740 (score[2] - fd).abs() <= tolerance,
1741 "{} value/score mismatch: analytic={} fd={fd}",
1742 likelihood.spec.pretty_name(),
1743 score[2]
1744 );
1745 }
1746 }
1747
1748 #[test]
1749 fn exact_hmc_family_tails_are_finite_when_the_surface_is_representable() {
1750 let tail_cases = [
1751 (
1752 GlmLikelihoodSpec::canonical(LikelihoodSpec::poisson_log()),
1753 array![0.0],
1754 array![-1000.0],
1755 ),
1756 (
1757 GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
1758 ResponseFamily::Binomial,
1759 InverseLink::Standard(StandardLink::Probit),
1760 )),
1761 array![1.0],
1762 array![-30.0],
1763 ),
1764 (
1765 GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
1766 ResponseFamily::Binomial,
1767 InverseLink::Standard(StandardLink::CLogLog),
1768 )),
1769 array![1.0],
1770 array![-1000.0],
1771 ),
1772 (
1773 GlmLikelihoodSpec {
1774 spec: LikelihoodSpec::new(
1775 ResponseFamily::Gamma,
1776 InverseLink::Standard(StandardLink::Log),
1777 ),
1778 scale: LikelihoodScaleMetadata::FixedGammaShape { shape: 1.0 },
1779 },
1780 array![1.0],
1781 array![1.0e308],
1782 ),
1783 (
1784 GlmLikelihoodSpec {
1785 spec: LikelihoodSpec::new(
1786 ResponseFamily::NegativeBinomial {
1787 theta: 1.0,
1788 theta_fixed: true,
1789 },
1790 InverseLink::Standard(StandardLink::Log),
1791 ),
1792 scale: LikelihoodScaleMetadata::FixedNegBinTheta { theta: 1.0 },
1793 },
1794 array![2.0],
1795 array![1.0e308],
1796 ),
1797 (
1798 GlmLikelihoodSpec {
1799 spec: LikelihoodSpec::new(
1800 ResponseFamily::Beta { phi: 8.0 },
1801 InverseLink::Standard(StandardLink::Logit),
1802 ),
1803 scale: LikelihoodScaleMetadata::EstimatedBetaPhi { phi: 8.0 },
1804 },
1805 array![0.2],
1806 array![-1000.0],
1807 ),
1808 ];
1809 for (likelihood, y, eta) in tail_cases {
1810 let (value, score) = exact_eta_geometry(&likelihood, &y, &array![1.0], &eta)
1811 .unwrap_or_else(|error| {
1812 panic!(
1813 "{} tail should be representable: {error}",
1814 likelihood.spec.pretty_name()
1815 )
1816 });
1817 assert!(value.is_finite());
1818 assert!(score[0].is_finite());
1819 }
1820 }
1821
1822 #[test]
1823 fn hmc_scale_resolution_rejects_inconsistent_metadata_without_defaults() {
1824 let inconsistent_nb = GlmLikelihoodSpec {
1825 spec: LikelihoodSpec::new(
1826 ResponseFamily::NegativeBinomial {
1827 theta: 2.0,
1828 theta_fixed: true,
1829 },
1830 InverseLink::Standard(StandardLink::Log),
1831 ),
1832 scale: LikelihoodScaleMetadata::FixedNegBinTheta { theta: 3.0 },
1833 };
1834 assert!(
1835 super::resolve_hmc_likelihood(
1836 inconsistent_nb,
1837 gam_solve::model_types::Dispersion::UNIT,
1838 )
1839 .is_err()
1840 );
1841
1842 let unresolved_gamma = GlmLikelihoodSpec {
1843 spec: LikelihoodSpec::new(
1844 ResponseFamily::Gamma,
1845 InverseLink::Standard(StandardLink::Log),
1846 ),
1847 scale: LikelihoodScaleMetadata::Unspecified,
1848 };
1849 assert!(
1850 super::resolve_hmc_likelihood(
1851 unresolved_gamma,
1852 gam_solve::model_types::Dispersion::UNIT,
1853 )
1854 .is_err()
1855 );
1856 }
1857
1858 #[test]
1859 fn cloglog_log_mu_uses_complementary_loglog_inverse_link() {
1860 let eta = -1.0_f64;
1861 let likelihood = GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
1862 ResponseFamily::Binomial,
1863 InverseLink::Standard(StandardLink::CLogLog),
1864 ));
1865 let (ll_y1, score) =
1866 exact_eta_geometry(&likelihood, &array![1.0], &array![1.0], &array![eta])
1867 .expect("valid eta");
1868 let residual_y1 = score[0];
1869 let expected = (1.0 - (-eta.exp()).exp()).ln();
1870 let wrong_log_one_minus_exp_eta = (1.0 - eta.exp()).ln();
1871
1872 assert!((ll_y1 - expected).abs() < 1e-14);
1873 assert!((ll_y1 - wrong_log_one_minus_exp_eta).abs() > 0.5);
1874
1875 let eps = 1e-6;
1876 let (lp, _) =
1877 exact_eta_geometry(&likelihood, &array![1.0], &array![1.0], &array![eta + eps])
1878 .expect("valid eta");
1879 let (lm, _) =
1880 exact_eta_geometry(&likelihood, &array![1.0], &array![1.0], &array![eta - eps])
1881 .expect("valid eta");
1882 let fd = (lp - lm) / (2.0 * eps);
1883 assert!(
1884 (residual_y1 - fd).abs() < 1e-9,
1885 "cloglog residual is not the derivative of log μ: analytic={residual_y1}, fd={fd}"
1886 );
1887 }
1888
1889 #[test]
1890 fn finite_eta_beyond_the_old_support_window_keeps_its_valid_log_density() {
1891 let data = SharedData {
1896 x: Arc::new(array![[1.0]]),
1897 y: Arc::new(array![0.0]),
1898 weights: Arc::new(array![1.0]),
1899 mode: Arc::new(array![0.0]),
1900 offset: None,
1901 likelihood: nuts_test_likelihood(NutsFamily::PoissonLog, 1.0),
1902 n_samples: 1,
1903 dim: 1,
1904 };
1905 let eta = array![-701.0];
1906 let mut eta_score = Array1::zeros(1);
1907 let (ll, grad) = exact_glm_logp_and_grad_into(&data, &eta, &mut eta_score)
1908 .expect("representable Poisson tail");
1909 assert!(
1910 ll.is_finite() && ll.abs() < 1e-300,
1911 "Poisson y=0, eta=-701 must keep its ~0 log-density, got {ll}"
1912 );
1913 assert!(grad[0].is_finite());
1914
1915 let cloglog = GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
1918 ResponseFamily::Binomial,
1919 InverseLink::Standard(StandardLink::CLogLog),
1920 ));
1921 let (ll_tail, score_tail) =
1922 exact_eta_geometry(&cloglog, &array![1.0], &array![1.0], &array![-750.0])
1923 .expect("finite eta is valid");
1924 let res_tail = score_tail[0];
1925 assert!(
1926 (ll_tail - (-750.0)).abs() < 1e-9,
1927 "cloglog log-density must approach eta in the deep left tail, got {ll_tail}"
1928 );
1929 assert!((res_tail - 1.0).abs() < 1e-9, "residual limit is 1");
1930
1931 let data_pos = SharedData {
1934 y: Arc::new(array![3.0]),
1935 ..data
1936 };
1937 let mut overflow_score = Array1::zeros(1);
1938 assert!(
1939 exact_glm_logp_and_grad_into(&data_pos, &array![710.0], &mut overflow_score).is_err(),
1940 "unrepresentable Poisson tail must fail atomically"
1941 );
1942 }
1943
1944 #[test]
1954 fn weighted_bernoulli_target_is_centered_at_the_weighted_mode() {
1955 let data = SharedData {
1956 x: Arc::new(array![[1.0], [1.0]]),
1957 y: Arc::new(array![1.0, 0.0]),
1958 weights: Arc::new(array![100.0, 1.0]),
1959 mode: Arc::new(array![0.0]),
1960 offset: None,
1961 likelihood: nuts_test_likelihood(NutsFamily::BinomialLogit, 1.0),
1962 n_samples: 2,
1963 dim: 1,
1964 };
1965 let eta_star = 100.0_f64.ln();
1967 let mut score_star = Array1::zeros(2);
1968 let (_, grad_star) =
1969 exact_glm_logp_and_grad_into(&data, &array![eta_star, eta_star], &mut score_star)
1970 .expect("weighted logit geometry");
1971 assert!(
1972 grad_star[0].abs() < 1e-9,
1973 "weighted Bernoulli score must vanish at log 100, got {}",
1974 grad_star[0]
1975 );
1976 let mut score_zero = Array1::zeros(2);
1979 let (_, grad_zero) =
1980 exact_glm_logp_and_grad_into(&data, &array![0.0, 0.0], &mut score_zero)
1981 .expect("weighted logit geometry");
1982 assert!(
1983 (grad_zero[0] - 49.5).abs() < 1e-9,
1984 "unit-weight point must carry a large positive weighted score, got {}",
1985 grad_zero[0]
1986 );
1987 }
1988
1989 #[test]
1990 fn nuts_logitgradient_matches_finite_difference() {
1991 let x = array![[1.0, -0.5], [0.2, 0.7], [-1.0, 0.3], [0.5, -1.2]];
1992 let y = array![1.0, 0.0, 1.0, 0.0];
1993 let w = array![1.0, 1.5, 0.8, 1.2];
1994 let penalty = array![[0.4, 0.0], [0.0, 0.6]];
1995 let mode = array![0.1, -0.2];
1996 let hessian = array![[2.0, 0.2], [0.2, 1.7]]; let posterior = NutsPosterior::new(
1999 x.view(),
2000 y.view(),
2001 w.view(),
2002 penalty.view(),
2003 mode.view(),
2004 hessian.view(),
2005 nuts_test_likelihood(NutsFamily::BinomialLogit, 1.0),
2006 gam_solve::estimate::Dispersion::UNIT,
2007 None,
2008 true,
2009 )
2010 .expect("posterior");
2011
2012 let z = array![0.15, -0.35];
2013 let (_, grad) = posterior.compute_logp_and_grad_nd(&z);
2014
2015 let eps = 1e-6;
2016 for j in 0..z.len() {
2017 let mut z_plus = z.clone();
2018 let mut z_minus = z.clone();
2019 z_plus[j] += eps;
2020 z_minus[j] -= eps;
2021 let (lp, _) = posterior.compute_logp_and_grad_nd(&z_plus);
2022 let (lm, _) = posterior.compute_logp_and_grad_nd(&z_minus);
2023 let fd = (lp - lm) / (2.0 * eps);
2024 assert_eq!(
2025 grad[j].signum(),
2026 fd.signum(),
2027 "gradient sign mismatch at {}: analytic={}, fd={}",
2028 j,
2029 grad[j],
2030 fd
2031 );
2032 assert!(
2033 (grad[j] - fd).abs() < 1e-5,
2034 "gradient mismatch at {}: analytic={}, fd={}",
2035 j,
2036 grad[j],
2037 fd
2038 );
2039 }
2040 }
2041
2042 #[test]
2043 fn gamma_log_logp_and_grad_uses_fitted_shape() {
2044 let x = array![[1.0_f64], [1.0_f64]];
2045 let y = array![1.5_f64, 2.5_f64];
2046 let weights = array![1.0_f64, 2.0_f64];
2047 let eta = array![0.2_f64, 0.4_f64];
2048 let shape = 3.5_f64;
2049 let data = SharedData {
2050 x: Arc::new(x.clone()),
2051 y: Arc::new(y.clone()),
2052 weights: Arc::new(weights.clone()),
2053 mode: Arc::new(Array1::zeros(1)),
2054 offset: None,
2055 likelihood: nuts_test_likelihood(NutsFamily::GammaLog, shape),
2056 n_samples: x.nrows(),
2057 dim: x.ncols(),
2058 };
2059
2060 let mut eta_score = Array1::zeros(eta.len());
2061 let (ll, grad) =
2062 exact_glm_logp_and_grad_into(&data, &eta, &mut eta_score).expect("Gamma geometry");
2063
2064 let mut expected_ll = 0.0;
2065 let mut expected_score = 0.0;
2066 for i in 0..eta.len() {
2067 let mu = eta[i].exp();
2068 let ratio = y[i] / mu;
2069 expected_ll -= weights[i] * shape * (ratio - 1.0 - ratio.ln());
2070 expected_score += weights[i] * shape * (y[i] / mu - 1.0);
2071 }
2072
2073 assert!((ll - expected_ll).abs() < 1e-12);
2074 assert_eq!(grad.len(), 1);
2075 assert!((grad[0] - expected_score).abs() < 1e-12);
2076 }
2077
2078 fn gamma_log_observed_information(
2082 x: &Array2<f64>,
2083 mode: &Array1<f64>,
2084 y: &Array1<f64>,
2085 weights: &Array1<f64>,
2086 shape: f64,
2087 ) -> Array2<f64> {
2088 let p = x.ncols();
2089 let eta = x.dot(mode);
2090 let mut h = Array2::<f64>::zeros((p, p));
2091 for i in 0..x.nrows() {
2092 let mu = eta[i].exp();
2093 let wt = weights[i] * shape * y[i] / mu;
2094 for a in 0..p {
2095 for b in 0..p {
2096 h[[a, b]] += wt * x[[i, a]] * x[[i, b]];
2097 }
2098 }
2099 }
2100 h
2101 }
2102
2103 #[test]
2116 fn gamma_log_nuts_target_curvature_matches_unscaled_hessian_issue_680() {
2117 let x = array![[1.0, -0.7], [1.0, 0.3], [1.0, 1.1], [1.0, -0.2], [1.0, 0.8],];
2118 let mode = array![0.4_f64, -0.6_f64];
2119 let y = array![1.2_f64, 0.7, 2.3, 0.9, 1.6];
2120 let weights = array![1.0_f64, 1.5, 0.8, 1.2, 1.0];
2121 let shape = 4.0_f64;
2123 let p = x.ncols();
2124
2125 let h_data = gamma_log_observed_information(&x, &mode, &y, &weights, shape);
2126 let s = array![[0.5_f64, 0.1], [0.1, 0.9]];
2128 let hessian = &h_data + &s;
2129
2130 let target = NutsPosterior::new(
2131 x.view(),
2132 y.view(),
2133 weights.view(),
2134 s.view(),
2135 mode.view(),
2136 hessian.view(),
2137 nuts_test_likelihood(NutsFamily::GammaLog, shape),
2138 gam_solve::estimate::Dispersion::estimated(1.0 / shape).unwrap(),
2139 None,
2140 false,
2141 )
2142 .expect("GammaLog NUTS target builds");
2143
2144 let eps = 1e-6;
2147 let z0 = Array1::<f64>::zeros(p);
2148 let mut hz = Array2::<f64>::zeros((p, p));
2149 for j in 0..p {
2150 let mut zp = z0.clone();
2151 let mut zm = z0.clone();
2152 zp[j] += eps;
2153 zm[j] -= eps;
2154 let (_, gp) = target.compute_logp_and_grad_nd(&zp);
2155 let (_, gm) = target.compute_logp_and_grad_nd(&zm);
2156 for a in 0..p {
2157 hz[[a, j]] = -(gp[a] - gm[a]) / (2.0 * eps);
2158 }
2159 }
2160
2161 for a in 0..p {
2162 for b in 0..p {
2163 let expected = if a == b { 1.0 } else { 0.0 };
2164 assert!(
2165 (hz[[a, b]] - expected).abs() < 1e-4,
2166 "z-curvature[{a},{b}] = {} (expected {expected}); a non-identity \
2167 value means the GammaLog target re-introduced the #680 dispersion \
2168 double-count (penalty ×ν and/or whitening ×√φ)",
2169 hz[[a, b]]
2170 );
2171 }
2172 }
2173 let trace: f64 = (0..p).map(|i| hz[[i, i]]).sum();
2175 assert!(
2176 (trace - p as f64).abs() < 1e-3,
2177 "z-curvature trace {trace} ≠ {p}: dispersion double-count signature"
2178 );
2179 }
2180
2181 #[test]
2187 fn gamma_log_nuts_whitening_targets_unscaled_inverse_hessian_issue_680() {
2188 let x = array![[1.0, -0.4], [1.0, 0.6], [1.0, 0.1], [1.0, 1.3]];
2189 let mode = array![0.2_f64, 0.3_f64];
2190 let y = array![0.8_f64, 1.7, 1.1, 2.2];
2191 let weights = array![1.0_f64, 1.0, 1.5, 0.7];
2192 let shape = 6.25_f64; let p = x.ncols();
2194 let s = array![[0.3_f64, 0.0], [0.0, 0.7]];
2195 let hessian = &gamma_log_observed_information(&x, &mode, &y, &weights, shape) + &s;
2196
2197 let target = NutsPosterior::new(
2198 x.view(),
2199 y.view(),
2200 weights.view(),
2201 s.view(),
2202 mode.view(),
2203 hessian.view(),
2204 nuts_test_likelihood(NutsFamily::GammaLog, shape),
2205 gam_solve::estimate::Dispersion::estimated(1.0 / shape).unwrap(),
2206 None,
2207 false,
2208 )
2209 .expect("GammaLog NUTS target builds");
2210
2211 let l = target.chol();
2213 let llt = l.dot(&l.t());
2214 let prod = llt.dot(&hessian);
2215 for a in 0..p {
2216 for b in 0..p {
2217 let expected = if a == b { 1.0 } else { 0.0 };
2218 assert!(
2219 (prod[[a, b]] - expected).abs() < 1e-8,
2220 "L Lᵀ H[{a},{b}] = {} (expected {expected}); a φ·I result means \
2221 the Gamma whitening still scales by √φ (#680)",
2222 prod[[a, b]]
2223 );
2224 }
2225 }
2226 }
2227
2228 #[test]
2229 fn firth_jeffreys_logit_is_finite_for_rank_deficient_design() {
2230 let x = array![
2231 [1.0, -0.5, 1.0],
2232 [1.0, 0.3, 1.0],
2233 [1.0, 0.8, 1.0],
2234 [1.0, -1.2, 1.0],
2235 ];
2236 let y = array![1.0, 0.0, 1.0, 0.0];
2237 let weights = array![1.0, 2.0, 0.5, 1.5];
2238 let eta = array![0.2, -0.1, 0.4, -0.3];
2239
2240 let data = SharedData {
2241 x: Arc::new(x.clone()),
2242 y: Arc::new(y),
2243 weights: Arc::new(weights.clone()),
2244 mode: Arc::new(Array1::zeros(x.ncols())),
2245 offset: None,
2246 likelihood: nuts_test_likelihood(NutsFamily::BinomialLogit, 1.0),
2247 n_samples: x.nrows(),
2248 dim: x.ncols(),
2249 };
2250
2251 let (value, grad) =
2252 firth_jeffreys_logp_and_grad(&NutsFamily::BinomialLogit.likelihood_spec(), &data, &eta)
2253 .expect("firth");
2254
2255 assert!(value.is_finite());
2256 assert_eq!(grad.len(), x.ncols());
2257 assert!(grad.iter().all(|v| v.is_finite()));
2258
2259 let (value_probit, grad_probit) = firth_jeffreys_logp_and_grad(
2264 &NutsFamily::BinomialProbit.likelihood_spec(),
2265 &data,
2266 &eta,
2267 )
2268 .expect("probit firth");
2269 assert!(value_probit.is_finite());
2270 assert!(
2271 (value_probit - value).abs() > 1e-6,
2272 "probit and logit Jeffreys log-determinants must differ: {value_probit} vs {value}"
2273 );
2274 assert!(grad_probit.iter().all(|v| v.is_finite()));
2275 }
2276
2277 #[test]
2278 fn logit_pg_gibbs_returns_finite_samples() {
2279 let x = array![[1.0, 0.2], [1.0, -0.1], [1.0, 1.2], [1.0, -0.7]];
2280 let y = array![1.0, 0.0, 1.0, 0.0];
2281 let w = array![1.0, 1.0, 1.0, 1.0];
2282 let penalty = array![[0.2, 0.0], [0.0, 0.4]];
2283 let mode = array![0.0, 0.0];
2284 let cfg = NutsConfig {
2285 n_samples: 30,
2286 nwarmup: 30,
2287 n_chains: 2,
2288 target_accept: 0.8,
2289 seed: 123,
2290 };
2291 let out = run_logit_polya_gamma_gibbs(
2292 x.view(),
2293 y.view(),
2294 w.view(),
2295 penalty.view(),
2296 mode.view(),
2297 &cfg,
2298 )
2299 .expect("pg gibbs should run");
2300 assert_eq!(out.samples.ncols(), 2);
2301 assert_eq!(out.samples.nrows(), cfg.n_samples * cfg.n_chains);
2302 assert!(out.samples.iter().all(|v| v.is_finite()));
2303 assert!(out.posterior_mean.iter().all(|v| v.is_finite()));
2304 assert!(
2305 out.posterior_std
2306 .iter()
2307 .all(|value| value.is_finite() && *value > 0.0),
2308 "every sampled coefficient must have positive posterior spread"
2309 );
2310 let eta = x.dot(&out.posterior_mean);
2311 let posterior_nll: f64 = eta
2312 .iter()
2313 .zip(y.iter())
2314 .map(|(&eta_i, &y_i)| gam_linalg::utils::stable_softplus(eta_i) - y_i * eta_i)
2315 .sum();
2316 let zero_nll = x.nrows() as f64 * std::f64::consts::LN_2;
2317 assert!(
2318 posterior_nll < zero_nll,
2319 "posterior mean must discriminate the planted binary responses: {posterior_nll} !< {zero_nll}"
2320 );
2321 }
2322
2323 #[test]
2324 fn family_pg_dispatch_rejects_non_bernoulli_response() {
2325 let x = array![[1.0], [1.0]];
2326 let y = array![2.0, 0.0];
2327 let w = array![1.0, 1.0];
2328 let penalty = array![[0.1]];
2329 let mode = array![0.0];
2330 let non_spd_hessian = array![[0.0]];
2331 let cfg = NutsConfig {
2332 n_samples: 1,
2333 nwarmup: 1,
2334 n_chains: 1,
2335 target_accept: 0.8,
2336 seed: 321,
2337 };
2338
2339 let result = run_nuts_sampling_flattened_family(
2340 LikelihoodSpec::binomial_logit(),
2341 FamilyNutsInputs::Glm(GlmFlatInputs {
2342 x: x.view(),
2343 y: y.view(),
2344 weights: w.view(),
2345 penalty_matrix: penalty.view(),
2346 mode: mode.view(),
2347 hessian: non_spd_hessian.view(),
2348 likelihood_scale: LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
2349 dispersion: gam_solve::model_types::Dispersion::UNIT,
2350 firth_bias_reduction: false,
2351 offset: None,
2352 }),
2353 &cfg,
2354 );
2355
2356 let err = result.err().expect("PG dispatch should reject count rows");
2357 assert!(
2358 err.contains("response must be exactly 0 or 1"),
2359 "unexpected error: {err}"
2360 );
2361 }
2362
2363 #[test]
2364 fn family_dispatch_uses_pg_gibbs_for_standard_logit() {
2365 let x = array![[1.0, 0.2], [1.0, -0.1], [1.0, 1.2], [1.0, -0.7]];
2366 let y = array![1.0, 0.0, 1.0, 0.0];
2367 let w = array![1.0, 1.0, 1.0, 1.0];
2368 let penalty = array![[0.2, 0.0], [0.0, 0.4]];
2369 let mode = array![0.0, 0.0];
2370 let non_spdhessian = array![[0.0, 0.0], [0.0, 0.0]];
2371 let cfg = NutsConfig {
2372 n_samples: 20,
2373 nwarmup: 20,
2374 n_chains: 2,
2375 target_accept: 0.8,
2376 seed: 456,
2377 };
2378 let out = run_nuts_sampling_flattened_family(
2379 LikelihoodSpec {
2380 response: ResponseFamily::Binomial,
2381 link: InverseLink::Standard(StandardLink::Logit),
2382 },
2383 FamilyNutsInputs::Glm(GlmFlatInputs {
2384 x: x.view(),
2385 y: y.view(),
2386 weights: w.view(),
2387 penalty_matrix: penalty.view(),
2388 mode: mode.view(),
2389 hessian: non_spdhessian.view(),
2390 likelihood_scale: LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
2391 dispersion: gam_solve::estimate::Dispersion::UNIT,
2392 firth_bias_reduction: false,
2393 offset: None,
2394 }),
2395 &cfg,
2396 )
2397 .expect("dispatch should use PG Gibbs and not require Hessian factorization");
2398 assert_eq!(out.samples.nrows(), cfg.n_samples * cfg.n_chains);
2399 assert!(out.samples.iter().all(|v| v.is_finite()));
2400 }
2401
2402 #[test]
2403 fn family_dispatch_routes_probit_to_nuts_path() {
2404 let x = array![[1.0, 0.2], [1.0, -0.1], [1.0, 1.2], [1.0, -0.7]];
2405 let y = array![1.0, 0.0, 1.0, 0.0];
2406 let w = array![1.0, 1.0, 1.0, 1.0];
2407 let penalty = array![[0.2, 0.0], [0.0, 0.4]];
2408 let mode = array![0.0, 0.0];
2409 let non_spdhessian = array![[0.0, 0.0], [0.0, 0.0]];
2410 let cfg = NutsConfig {
2411 n_samples: 20,
2412 nwarmup: 20,
2413 n_chains: 2,
2414 target_accept: 0.8,
2415 seed: 654,
2416 };
2417
2418 let err = match run_nuts_sampling_flattened_family(
2419 LikelihoodSpec {
2420 response: ResponseFamily::Binomial,
2421 link: InverseLink::Standard(StandardLink::Probit),
2422 },
2423 FamilyNutsInputs::Glm(GlmFlatInputs {
2424 x: x.view(),
2425 y: y.view(),
2426 weights: w.view(),
2427 penalty_matrix: penalty.view(),
2428 mode: mode.view(),
2429 hessian: non_spdhessian.view(),
2430 likelihood_scale: LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
2431 dispersion: gam_solve::estimate::Dispersion::UNIT,
2432 firth_bias_reduction: false,
2433 offset: None,
2434 }),
2435 &cfg,
2436 ) {
2437 Ok(_) => panic!("non-SPD Hessian should fail after probit routes to the NUTS path"),
2438 Err(err) => err,
2439 };
2440
2441 assert!(
2442 err.contains("Hessian Cholesky decomposition failed"),
2443 "unexpected error: {err}"
2444 );
2445 }
2446
2447 #[test]
2448 fn family_dispatch_rejects_nonbinomial_firth_family() {
2449 let x = array![[1.0, 0.2], [1.0, -0.1], [1.0, 1.2], [1.0, -0.7]];
2450 let y = array![1.0, 2.0, 0.0, 3.0];
2451 let w = array![1.0, 1.0, 1.0, 1.0];
2452 let penalty = array![[0.2, 0.0], [0.0, 0.4]];
2453 let mode = array![0.0, 0.0];
2454 let hessian = array![[1.5, 0.1], [0.1, 1.2]];
2455 let cfg = NutsConfig {
2456 n_samples: 20,
2457 nwarmup: 20,
2458 n_chains: 2,
2459 target_accept: 0.8,
2460 seed: 111,
2461 };
2462
2463 let err = match run_nuts_sampling_flattened_family(
2464 LikelihoodSpec {
2465 response: ResponseFamily::Poisson,
2466 link: InverseLink::Standard(StandardLink::Log),
2467 },
2468 FamilyNutsInputs::Glm(GlmFlatInputs {
2469 x: x.view(),
2470 y: y.view(),
2471 weights: w.view(),
2472 penalty_matrix: penalty.view(),
2473 mode: mode.view(),
2474 hessian: hessian.view(),
2475 likelihood_scale: LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
2476 dispersion: gam_solve::estimate::Dispersion::UNIT,
2477 firth_bias_reduction: true,
2478 offset: None,
2479 }),
2480 &cfg,
2481 ) {
2482 Ok(_) => panic!("Poisson Firth should be rejected explicitly"),
2483 Err(err) => err,
2484 };
2485
2486 assert!(
2487 err.contains(
2488 "NUTS with Firth requires a Binomial inverse link with a Fisher-weight jet"
2489 ),
2490 "unexpected error: {err}"
2491 );
2492 }
2493
2494 #[test]
2495 fn run_nuts_sampling_rejects_invalid_target_accept() {
2496 let x = array![[1.0], [1.0], [1.0]];
2497 let y = array![0.5, -0.5, 1.0];
2498 let weights = array![1.0, 1.0, 1.0];
2499 let penalty = array![[0.25]];
2500 let mode = array![0.0];
2501 let hessian = array![[1.25]];
2502 let cfg = NutsConfig {
2503 n_samples: 10,
2504 nwarmup: 10,
2505 n_chains: 1,
2506 target_accept: 1.0,
2507 seed: 222,
2508 };
2509
2510 let err = super::run_nuts_sampling(
2511 x.view(),
2512 y.view(),
2513 weights.view(),
2514 penalty.view(),
2515 mode.view(),
2516 hessian.view(),
2517 nuts_test_likelihood(NutsFamily::Gaussian, 1.0),
2518 gam_solve::estimate::Dispersion::UNIT,
2519 false,
2520 None,
2521 &cfg,
2522 )
2523 .expect_err("invalid target_accept should be rejected before sampling");
2524
2525 assert!(
2526 err.contains("target_accept must be finite and lie in (0, 1)"),
2527 "unexpected error: {err}"
2528 );
2529 }
2530
2531 #[test]
2532 fn run_nuts_sampling_rejects_zero_or_too_few_samples() {
2533 let x = array![[1.0], [1.0], [1.0]];
2540 let y = array![0.5, -0.5, 1.0];
2541 let weights = array![1.0, 1.0, 1.0];
2542 let penalty = array![[0.25]];
2543 let mode = array![0.0];
2544 let hessian = array![[1.25]];
2545
2546 for bad_samples in [0usize, 1, 2, 3] {
2547 let cfg = NutsConfig {
2548 n_samples: bad_samples,
2549 nwarmup: 10,
2550 n_chains: 2,
2551 target_accept: 0.8,
2552 seed: 222,
2553 };
2554
2555 let err = super::run_nuts_sampling(
2556 x.view(),
2557 y.view(),
2558 weights.view(),
2559 penalty.view(),
2560 mode.view(),
2561 hessian.view(),
2562 nuts_test_likelihood(NutsFamily::Gaussian, 1.0),
2563 gam_solve::estimate::Dispersion::UNIT,
2564 false,
2565 None,
2566 &cfg,
2567 )
2568 .expect_err("too-few samples must be rejected before sampling");
2569
2570 assert!(
2571 err.contains("n_samples must be >= 4"),
2572 "n_samples={bad_samples} gave unexpected error: {err}"
2573 );
2574 }
2575 }
2576
2577 #[test]
2578 fn polya_gamma_gibbs_rejects_degenerate_counts_but_accepts_single_chain() {
2579 let x = array![[1.0], [1.0], [1.0], [1.0]];
2588 let y = array![1.0, 0.0, 1.0, 0.0];
2589 let weights = array![1.0, 1.0, 1.0, 1.0];
2590 let penalty = array![[0.25]];
2591 let mode = array![0.0];
2592
2593 let zero_chain_cfg = NutsConfig {
2594 n_samples: 20,
2595 nwarmup: 10,
2596 n_chains: 0,
2597 target_accept: 0.8,
2598 seed: 7,
2599 };
2600 let err = super::run_logit_polya_gamma_gibbs(
2601 x.view(),
2602 y.view(),
2603 weights.view(),
2604 penalty.view(),
2605 mode.view(),
2606 &zero_chain_cfg,
2607 )
2608 .expect_err("PG Gibbs must reject zero chains up front, not return an empty posterior");
2609 assert!(
2610 err.contains("n_chains must be >= 1"),
2611 "PG n_chains=0 gave unexpected error: {err}"
2612 );
2613
2614 let zero_sample_cfg = NutsConfig {
2615 n_samples: 0,
2616 nwarmup: 10,
2617 n_chains: 2,
2618 target_accept: 0.8,
2619 seed: 7,
2620 };
2621 let err = super::run_logit_polya_gamma_gibbs(
2622 x.view(),
2623 y.view(),
2624 weights.view(),
2625 penalty.view(),
2626 mode.view(),
2627 &zero_sample_cfg,
2628 )
2629 .expect_err("PG Gibbs must reject zero samples up front, not return an empty posterior");
2630 assert!(
2631 err.contains("n_samples must be >= 4"),
2632 "PG n_samples=0 gave unexpected error: {err}"
2633 );
2634
2635 let single_chain_cfg = NutsConfig {
2636 n_samples: 20,
2637 nwarmup: 10,
2638 n_chains: 1,
2639 target_accept: 0.8,
2640 seed: 7,
2641 };
2642 let result = super::run_logit_polya_gamma_gibbs(
2643 x.view(),
2644 y.view(),
2645 weights.view(),
2646 penalty.view(),
2647 mode.view(),
2648 &single_chain_cfg,
2649 )
2650 .expect("PG Gibbs must accept a single chain and return draws");
2651 assert_eq!(
2652 result.samples.nrows(),
2653 20,
2654 "single-chain PG run should return all 20 requested draws"
2655 );
2656 }
2657
2658 #[test]
2659 fn run_nuts_sampling_rejects_zero_chains_but_accepts_single_chain() {
2660 let x = array![[1.0], [1.0], [1.0]];
2674 let y = array![0.5, -0.5, 1.0];
2675 let weights = array![1.0, 1.0, 1.0];
2676 let penalty = array![[0.25]];
2677 let mode = array![0.0];
2678 let hessian = array![[1.25]];
2679
2680 let zero_chain_cfg = NutsConfig {
2681 n_samples: 50,
2682 nwarmup: 10,
2683 n_chains: 0,
2684 target_accept: 0.8,
2685 seed: 222,
2686 };
2687 let err = super::run_nuts_sampling(
2688 x.view(),
2689 y.view(),
2690 weights.view(),
2691 penalty.view(),
2692 mode.view(),
2693 hessian.view(),
2694 nuts_test_likelihood(NutsFamily::Gaussian, 1.0),
2695 gam_solve::estimate::Dispersion::UNIT,
2696 false,
2697 None,
2698 &zero_chain_cfg,
2699 )
2700 .expect_err("zero chains must be rejected before sampling");
2701 assert!(
2702 err.contains("n_chains must be >= 1"),
2703 "n_chains=0 gave unexpected error: {err}"
2704 );
2705
2706 let single_chain_cfg = NutsConfig {
2707 n_samples: 50,
2708 nwarmup: 10,
2709 n_chains: 1,
2710 target_accept: 0.8,
2711 seed: 222,
2712 };
2713 let result = super::run_nuts_sampling(
2714 x.view(),
2715 y.view(),
2716 weights.view(),
2717 penalty.view(),
2718 mode.view(),
2719 hessian.view(),
2720 nuts_test_likelihood(NutsFamily::Gaussian, 1.0),
2721 gam_solve::estimate::Dispersion::UNIT,
2722 false,
2723 None,
2724 &single_chain_cfg,
2725 )
2726 .expect("a single chain is a supported configuration and must return draws");
2727 assert_eq!(
2728 result.samples.nrows(),
2729 50,
2730 "single-chain run should return all 50 requested draws"
2731 );
2732 }
2733
2734 #[test]
2735 fn joint_hmc_boundary_rejects_nonbinomial_firth_family() {
2736 let x = array![[1.0, 0.2], [1.0, -0.1], [1.0, 1.2], [1.0, -0.7]];
2737 let y = array![1.0, 2.0, 0.0, 3.0];
2738 let w = array![1.0, 1.0, 1.0, 1.0];
2739 let hessian = array![[1.5, 0.1], [0.1, 1.2]];
2740 let penalty_root = array![[0.4, 0.0], [0.0, 0.6]];
2741 let mode = array![0.0, 0.0];
2742 let rho_mode = array![0.0];
2743 let cfg = NutsConfig {
2744 n_samples: 20,
2745 nwarmup: 20,
2746 n_chains: 2,
2747 target_accept: 0.8,
2748 seed: 111,
2749 };
2750
2751 let inputs = JointBetaRhoInputs {
2752 x: x.view(),
2753 y: y.view(),
2754 weights: w.view(),
2755 likelihood: GlmLikelihoodSpec {
2756 spec: LikelihoodSpec {
2757 response: ResponseFamily::Poisson,
2758 link: InverseLink::Standard(StandardLink::Log),
2759 },
2760 scale: LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
2761 },
2762 dispersion: gam_solve::model_types::Dispersion::UNIT,
2763 offset: None,
2764 mode: mode.view(),
2765 hessian: hessian.view(),
2766 penalty_roots: vec![CanonicalPenalty::from_dense_root(
2767 penalty_root.clone(),
2768 penalty_root.ncols(),
2769 )],
2770 rho_mode: rho_mode.view(),
2771 rho_prior: RhoPrior::default(),
2772 firth_bias_reduction: true,
2773 trigger_skewness: 0.75,
2774 };
2775
2776 let err = match run_joint_beta_rho_sampling(&inputs, &cfg) {
2777 Ok(_) => panic!("Poisson joint HMC Firth should be rejected explicitly"),
2778 Err(err) => err,
2779 };
2780
2781 assert!(
2782 err.contains(
2783 "Joint HMC with Firth requires a Binomial inverse link with a Fisher-weight jet"
2784 ),
2785 "unexpected error: {err}"
2786 );
2787 }
2788
2789 #[test]
2790 fn joint_hmc_uses_combined_penalty_logdet_for_overlapping_penalties() {
2791 let x = array![[0.0, 0.0]];
2792 let y = array![0.0];
2793 let w = array![0.0];
2794 let mode = array![0.0, 0.0];
2795 let hessian = array![[1.0, 0.0], [0.0, 1.0]];
2796 let rho_mode = array![0.0, 0.0];
2797 let penalty_1 = array![[1.0, 0.0], [0.0, 1.0]];
2798 let penalty_2 = array![[2.0_f64.sqrt(), 0.0], [0.0, 1.0]];
2799 let target = JointBetaRhoPosterior::new(
2800 x.view(),
2801 y.view(),
2802 w.view(),
2803 mode.view(),
2804 hessian.view(),
2805 vec![
2806 CanonicalPenalty::from_dense_root(penalty_1, 2),
2807 CanonicalPenalty::from_dense_root(penalty_2, 2),
2808 ],
2809 rho_mode.view(),
2810 GlmLikelihoodSpec {
2811 spec: LikelihoodSpec::gaussian_identity(),
2812 scale: LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
2813 },
2814 gam_solve::model_types::Dispersion::UNIT,
2815 None,
2816 RhoPrior::Flat,
2817 false,
2818 )
2819 .expect("joint target");
2820
2821 let params = array![0.0, 0.0, 0.0, 0.0];
2822 let (_, grad) = target.compute_joint_logp_and_grad(¶ms);
2823 assert!(
2824 (grad[2] - 5.0 / 12.0).abs() < 1.0e-10,
2825 "expected overlapping-penalty gradient 5/12, got {}",
2826 grad[2]
2827 );
2828 assert!(
2829 (grad[3] - 7.0 / 12.0).abs() < 1.0e-10,
2830 "expected overlapping-penalty gradient 7/12, got {}",
2831 grad[3]
2832 );
2833 }
2834
2835 #[test]
2836 fn joint_hmc_target_does_not_depend_on_rho_mode_when_prior_is_fixed() {
2837 let x = array![[0.0]];
2838 let y = array![0.0];
2839 let w = array![0.0];
2840 let mode = array![0.0];
2841 let hessian = array![[1.0]];
2842 let penalty = CanonicalPenalty::from_dense_root(array![[1.0]], 1);
2843 let prior = RhoPrior::Normal {
2844 mean: 0.25,
2845 sd: 1.7,
2846 };
2847
2848 let target_a = JointBetaRhoPosterior::new(
2849 x.view(),
2850 y.view(),
2851 w.view(),
2852 mode.view(),
2853 hessian.view(),
2854 vec![penalty.clone()],
2855 array![0.0].view(),
2856 GlmLikelihoodSpec {
2857 spec: LikelihoodSpec::gaussian_identity(),
2858 scale: LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
2859 },
2860 gam_solve::model_types::Dispersion::UNIT,
2861 None,
2862 prior.clone(),
2863 false,
2864 )
2865 .expect("target a");
2866 let target_b = JointBetaRhoPosterior::new(
2867 x.view(),
2868 y.view(),
2869 w.view(),
2870 mode.view(),
2871 hessian.view(),
2872 vec![penalty],
2873 array![2.5].view(),
2874 GlmLikelihoodSpec {
2875 spec: LikelihoodSpec::gaussian_identity(),
2876 scale: LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
2877 },
2878 gam_solve::model_types::Dispersion::UNIT,
2879 None,
2880 prior,
2881 false,
2882 )
2883 .expect("target b");
2884
2885 let params = array![0.0, -0.4];
2886 let (lp_a, grad_a) = target_a.compute_joint_logp_and_grad(¶ms);
2887 let (lp_b, grad_b) = target_b.compute_joint_logp_and_grad(¶ms);
2888 assert!((lp_a - lp_b).abs() < 1.0e-12);
2889 for i in 0..grad_a.len() {
2890 assert!(
2891 (grad_a[i] - grad_b[i]).abs() < 1.0e-12,
2892 "rho_mode leaked into target gradient at {}: {} vs {}",
2893 i,
2894 grad_a[i],
2895 grad_b[i]
2896 );
2897 }
2898 }
2899
2900 #[test]
2901 fn joint_hmc_target_retains_fit_dispersion_and_offset() {
2902 let x = array![[1.0]];
2908 let y = array![2.0];
2909 let w = array![1.0];
2910 let mode = array![0.0];
2911 let hessian = array![[1.0]];
2912 let offset = array![1.5];
2913 let target = JointBetaRhoPosterior::new(
2914 x.view(),
2915 y.view(),
2916 w.view(),
2917 mode.view(),
2918 hessian.view(),
2919 Vec::new(),
2920 Array1::<f64>::zeros(0).view(),
2921 GlmLikelihoodSpec::canonical(LikelihoodSpec {
2922 response: ResponseFamily::Gaussian,
2923 link: InverseLink::Standard(StandardLink::Identity),
2924 }),
2925 gam_solve::model_types::Dispersion::estimated(4.0).unwrap(),
2926 Some(offset.view()),
2927 RhoPrior::Flat,
2928 false,
2929 )
2930 .expect("joint target");
2931
2932 let params = array![0.0];
2933 let (logp, _) = target.compute_joint_logp_and_grad(¶ms);
2934 let expected = -0.5 * (1.0 / 4.0) * (2.0_f64 - 1.5).powi(2);
2935 assert!(
2936 (logp - expected).abs() < 1e-12,
2937 "joint target must keep phi and offset: logp = {logp}, expected {expected}"
2938 );
2939 }
2940
2941 #[test]
2942 fn joint_hmc_binomial_sas_uses_runtime_link_state() {
2943 let x = array![[1.0], [1.0]];
2944 let y = array![1.0, 0.0];
2945 let weights = array![1.0, 1.0];
2946 let eta = array![0.3, -0.2];
2947 let sas_state =
2948 gam_solve::mixture_link::state_from_sasspec(gam_problem::types::SasLinkSpec {
2949 initial_epsilon: 0.4,
2950 initial_log_delta: -0.2,
2951 })
2952 .expect("sas state");
2953 let data = SharedData {
2954 x: Arc::new(x),
2955 y: Arc::new(y),
2956 weights: Arc::new(weights),
2957 mode: Arc::new(Array1::zeros(1)),
2958 offset: None,
2959 likelihood: nuts_test_likelihood(NutsFamily::BinomialLogit, 1.0),
2960 n_samples: 2,
2961 dim: 1,
2962 };
2963
2964 let (ll_sas, _) = joint_family_logp_and_grad(
2965 &LikelihoodSpec {
2966 response: ResponseFamily::Binomial,
2967 link: InverseLink::Sas(sas_state),
2968 },
2969 &data,
2970 &eta,
2971 )
2972 .expect("sas joint logp");
2973 let (ll_logit, _) = joint_family_logp_and_grad(
2974 &LikelihoodSpec {
2975 response: ResponseFamily::Binomial,
2976 link: InverseLink::Standard(StandardLink::Logit),
2977 },
2978 &data,
2979 &eta,
2980 )
2981 .expect("logit joint logp");
2982
2983 assert!(
2984 (ll_sas - ll_logit).abs() > 1.0e-6,
2985 "adaptive SAS link should not collapse to the logit likelihood"
2986 );
2987 }
2988
2989 #[test]
2990 fn directional_cubic_diagnostic_is_rotation_invariant_for_hessian_eigenvectors() {
2991 let x = array![[1.0, 0.5], [-0.3, 1.4], [0.8, -1.1]];
2992 let c = array![0.7, -0.5, 0.2];
2993 let h = array![[4.0, 0.0], [0.0, 1.0]];
2994 let theta = std::f64::consts::FRAC_PI_4;
2995 let q = array![[theta.cos(), -theta.sin()], [theta.sin(), theta.cos()],];
2996 let x_rot = x.dot(&q);
2997 let h_rot = q.t().dot(&h).dot(&q);
2998
2999 let (base_max, base_vals) = laplace_directional_cubic_diagnostic(
3000 &h,
3001 &DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(x)),
3002 &c,
3003 true,
3004 )
3005 .expect("base diagnostic");
3006 let (rot_max, rot_vals) = laplace_directional_cubic_diagnostic(
3007 &h_rot,
3008 &DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(x_rot)),
3009 &c,
3010 true,
3011 )
3012 .expect("rotated diagnostic");
3013
3014 let mut base_abs: Vec<f64> = base_vals.iter().map(|v| v.abs()).collect();
3015 let mut rot_abs: Vec<f64> = rot_vals.iter().map(|v| v.abs()).collect();
3016 base_abs.sort_by(|a, b| a.partial_cmp(b).expect("finite compare"));
3017 rot_abs.sort_by(|a, b| a.partial_cmp(b).expect("finite compare"));
3018
3019 assert!((base_max - rot_max).abs() < 1.0e-10);
3020 for i in 0..base_abs.len() {
3021 assert!(
3022 (base_abs[i] - rot_abs[i]).abs() < 1.0e-10,
3023 "directional diagnostic changed under rotation at {}: {} vs {}",
3024 i,
3025 base_abs[i],
3026 rot_abs[i]
3027 );
3028 }
3029 }
3030
3031 #[test]
3043 fn batched_directional_cubics_match_the_single_direction_contraction() {
3044 use super::{directional_cubic_contraction, directional_cubic_contractions};
3045 use gam_linalg::matrix::{DenseDesignMatrix, DesignMatrix};
3046
3047 let n = 37;
3048 let p = 5;
3049 let x = Array2::from_shape_fn((n, p), |(i, j)| {
3052 ((i as f64) * 0.37 + (j as f64) * 1.13).sin() * (1.0 + j as f64 * 0.25)
3053 });
3054 let c = Array1::from_shape_fn(n, |i| 0.6 - 0.05 * (i as f64) + ((i % 3) as f64) * 0.4);
3055 let directions = Array2::from_shape_fn((p, 4), |(j, r)| {
3056 ((j as f64) * 0.91 - (r as f64) * 0.44).cos()
3057 });
3058
3059 use faer::sparse::{SparseColMat, Triplet};
3062 let mut triplets = Vec::new();
3063 for i in 0..n {
3064 for j in 0..p {
3065 if x[[i, j]] != 0.0 {
3066 triplets.push(Triplet::new(i, j, x[[i, j]]));
3067 }
3068 }
3069 }
3070 let dense = DesignMatrix::Dense(DenseDesignMatrix::from(x.clone()));
3071 let sparse = DesignMatrix::Sparse(gam_linalg::matrix::SparseDesignMatrix::new(
3072 SparseColMat::try_new_from_triplets(n, p, &triplets).expect("sparse twin"),
3073 ));
3074
3075 for (label, design) in [("dense", &dense), ("sparse", &sparse)] {
3076 let batched = directional_cubic_contractions(design, &c, &directions.view());
3077 for r in 0..directions.ncols() {
3078 let reference =
3079 directional_cubic_contraction(design, &c, &directions.column(r).view());
3080 assert!(
3081 (batched[r] - reference).abs() <= 1.0e-9 * reference.abs().max(1.0),
3082 "{label} arm disagreed on direction {r}: batched {} vs reference {}",
3083 batched[r],
3084 reference
3085 );
3086 }
3087 }
3088 }
3089
3090 #[test]
3094 fn joint_hmc_penalty_logdet_agrees_with_reml_path() {
3095 use gam_solve::estimate::reml::penalty_logdet::PenaltyPseudologdet;
3096
3097 let root_1 = array![[1.0, 0.5, 0.0], [0.0, 0.8, 0.3]];
3099 let root_2 = array![[0.0, 0.7, 0.0], [0.0, 0.0, 1.2]];
3100 let cp1 = CanonicalPenalty::from_dense_root(root_1, 3);
3101 let cp2 = CanonicalPenalty::from_dense_root(root_2, 3);
3102 let lambdas = [2.5_f64, 0.8];
3103 let penalties = [cp1.clone(), cp2.clone()];
3104
3105 let pld =
3107 PenaltyPseudologdet::from_penalties(&penalties, &lambdas, 0.0, 3).expect("reml pld");
3108 let reml_value = pld.value();
3109 let (reml_d1, reml_d2) = pld.rho_derivatives_from_penalties(&penalties, &lambdas);
3110
3111 let x = Array2::<f64>::zeros((1, 3));
3115 let y = array![0.0];
3116 let w = array![0.0];
3117 let mode = Array1::<f64>::zeros(3);
3118 let hessian = Array2::<f64>::eye(3);
3119 let rho = Array1::from_vec(lambdas.iter().map(|l| l.ln()).collect());
3120 let target = JointBetaRhoPosterior::new(
3121 x.view(),
3122 y.view(),
3123 w.view(),
3124 mode.view(),
3125 hessian.view(),
3126 vec![cp1, cp2],
3127 rho.view(),
3128 GlmLikelihoodSpec {
3129 spec: LikelihoodSpec::gaussian_identity(),
3130 scale: LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
3131 },
3132 gam_solve::model_types::Dispersion::UNIT,
3133 None,
3134 RhoPrior::Flat,
3135 false,
3136 )
3137 .expect("joint target");
3138
3139 let mut params = Array1::<f64>::zeros(3 + 2);
3141 params[3] = rho[0];
3142 params[4] = rho[1];
3143 let (logp, grad) = target.compute_joint_logp_and_grad(¶ms);
3144
3145 assert!(
3147 (logp - 0.5 * reml_value).abs() < 1.0e-8,
3148 "joint HMC logdet value {} vs REML 0.5*{} = {}",
3149 logp,
3150 reml_value,
3151 0.5 * reml_value,
3152 );
3153
3154 for k in 0..2 {
3156 assert!(
3157 (grad[3 + k] - 0.5 * reml_d1[k]).abs() < 1.0e-8,
3158 "joint HMC logdet gradient[{}] = {} vs REML 0.5*{} = {}",
3159 k,
3160 grad[3 + k],
3161 reml_d1[k],
3162 0.5 * reml_d1[k],
3163 );
3164 }
3165
3166 assert!(
3169 (reml_d2[[0, 1]] - reml_d2[[1, 0]]).abs() < 1.0e-12,
3170 "REML penalty logdet Hessian not symmetric"
3171 );
3172 }
3173
3174 #[test]
3179 fn joint_hmc_family_gating_never_remaps() {
3180 let eta = array![0.1, -0.1];
3181 let data_for = |spec: &LikelihoodSpec| SharedData {
3182 x: Arc::new(array![[1.0], [1.0]]),
3183 y: Arc::new(match &spec.response {
3184 ResponseFamily::Binomial => array![1.0, 0.0],
3185 ResponseFamily::Gamma | ResponseFamily::Beta { .. } => array![1.0, 0.5],
3186 _ => array![1.0, 0.0],
3187 }),
3188 weights: Arc::new(array![1.0, 1.0]),
3189 mode: Arc::new(Array1::zeros(1)),
3190 offset: None,
3191 likelihood: if matches!(&spec.response, ResponseFamily::Gaussian) {
3197 GlmLikelihoodSpec {
3198 spec: spec.clone(),
3199 scale: LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
3200 }
3201 } else {
3202 GlmLikelihoodSpec::canonical(spec.clone())
3203 },
3204 n_samples: 2,
3205 dim: 1,
3206 };
3207
3208 let accepted = [
3210 LikelihoodSpec {
3211 response: ResponseFamily::Binomial,
3212 link: InverseLink::Standard(StandardLink::Logit),
3213 },
3214 LikelihoodSpec {
3215 response: ResponseFamily::Binomial,
3216 link: InverseLink::Standard(StandardLink::Probit),
3217 },
3218 LikelihoodSpec {
3219 response: ResponseFamily::Binomial,
3220 link: InverseLink::Standard(StandardLink::CLogLog),
3221 },
3222 LikelihoodSpec {
3223 response: ResponseFamily::Gaussian,
3224 link: InverseLink::Standard(StandardLink::Identity),
3225 },
3226 LikelihoodSpec {
3227 response: ResponseFamily::Poisson,
3228 link: InverseLink::Standard(StandardLink::Log),
3229 },
3230 LikelihoodSpec {
3231 response: ResponseFamily::Gamma,
3232 link: InverseLink::Standard(StandardLink::Log),
3233 },
3234 ];
3235 for spec in &accepted {
3236 let data = data_for(spec);
3237 let result = joint_family_logp_and_grad(spec, &data, &eta);
3238 assert!(
3239 result.is_ok(),
3240 "spec {:?} should be accepted but got error: {:?}",
3241 spec,
3242 result.err(),
3243 );
3244 }
3245
3246 let sas_state =
3249 gam_solve::mixture_link::state_from_sasspec(gam_problem::types::SasLinkSpec {
3250 initial_epsilon: 0.0,
3251 initial_log_delta: 0.0,
3252 })
3253 .expect("sas state");
3254 let adaptive = [
3255 LikelihoodSpec {
3256 response: ResponseFamily::Binomial,
3257 link: InverseLink::Sas(sas_state),
3258 },
3259 LikelihoodSpec {
3260 response: ResponseFamily::Binomial,
3261 link: InverseLink::BetaLogistic(
3262 gam_solve::mixture_link::state_from_sasspec(gam_problem::types::SasLinkSpec {
3263 initial_epsilon: 0.0,
3264 initial_log_delta: 0.0,
3265 })
3266 .expect("bl state"),
3267 ),
3268 },
3269 ];
3270 for spec in &adaptive {
3271 let data = data_for(spec);
3272 let result = joint_family_logp_and_grad(spec, &data, &eta);
3273 assert!(
3274 result.is_ok(),
3275 "adaptive spec {:?} should be accepted with its real link",
3276 spec,
3277 );
3278 }
3279
3280 let rp = LikelihoodSpec {
3282 response: ResponseFamily::RoystonParmar,
3283 link: InverseLink::Standard(StandardLink::Logit),
3284 };
3285 let rp_data = data_for(&rp);
3286 let rp_result = joint_family_logp_and_grad(&rp, &rp_data, &eta);
3287 assert!(
3288 rp_result.is_err(),
3289 "RoystonParmar should be rejected, not silently accepted"
3290 );
3291 }
3292
3293 #[test]
3296 fn directional_cubic_power_iteration_finds_larger_or_equal_skewness() {
3297 let x = array![
3301 [2.0, 1.0],
3302 [-1.0, 2.0],
3303 [0.5, -0.5],
3304 [1.5, 0.3],
3305 [-0.8, 1.7],
3306 ];
3307 let c = array![1.0, -0.5, 0.3, -0.7, 0.4];
3308 let h = array![[3.0, 1.0], [1.0, 2.0]];
3309
3310 let (max_val, eigenvector_vals) = laplace_directional_cubic_diagnostic(
3311 &h,
3312 &DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(x)),
3313 &c,
3314 true,
3315 )
3316 .expect("diagnostic");
3317
3318 let eig_max = eigenvector_vals
3320 .iter()
3321 .fold(0.0_f64, |acc, &v| acc.max(v.abs()));
3322 assert!(
3323 max_val >= eig_max - 1.0e-12,
3324 "power iteration result {} should be >= eigenvector max {}",
3325 max_val,
3326 eig_max,
3327 );
3328 }
3329
3330 #[test]
3331 fn laplace_trustworthiness_is_block_local_and_threshold_shrinks_with_n() {
3332 let skew = array![0.01, 0.9];
3338
3339 let verdict = laplace_trustworthiness_from_skewness(&skew, 100.0);
3342 assert_eq!(
3343 verdict.untrustworthy_directions,
3344 vec![1],
3345 "only the strongly-skewed direction should be flagged (block-local)",
3346 );
3347 assert!(verdict.fallback_required());
3348 assert!((verdict.max_abs_skewness - 0.9).abs() < 1e-12);
3349
3350 let t_small = laplace_skewness_threshold(25.0);
3354 let t_large = laplace_skewness_threshold(10_000.0);
3355 assert!(
3356 t_large < t_small,
3357 "validity threshold must tighten with sample size: {t_large} !< {t_small}",
3358 );
3359
3360 let none = laplace_trustworthiness_from_skewness(&skew, 0.0);
3363 assert!(!none.fallback_required());
3364 assert!(none.threshold.is_infinite());
3365 }
3366
3367 struct AnharmonicBlock {
3373 lambdas: Array1<f64>,
3374 a: f64,
3375 }
3376 impl super::BlockExcessTarget for AnharmonicBlock {
3377 fn block_dim(&self) -> usize {
3378 self.lambdas.len()
3379 }
3380 fn rho_dim(&self) -> usize {
3381 self.lambdas.len()
3382 }
3383 fn block_curvatures(&self) -> &Array1<f64> {
3384 &self.lambdas
3385 }
3386 fn excess(&self, t: &Array1<f64>) -> f64 {
3387 self.a * t.iter().map(|&x| x.powi(4)).sum::<f64>()
3388 }
3389 fn excess_rho_gradient(&self, t: &Array1<f64>) -> Array1<f64> {
3390 t.mapv(|x| self.a * x.powi(4))
3391 }
3392 fn displaced_neg_score(&self, t: &Array1<f64>) -> Result<Array1<f64>, String> {
3393 assert_eq!(t.len(), self.block_dim(), "displacement dim mismatch");
3397 Ok(Array1::zeros(0))
3398 }
3399 fn base_neg_score(&self) -> Result<Array1<f64>, String> {
3400 Ok(Array1::zeros(0))
3401 }
3402 }
3403
3404 #[test]
3405 fn block_quadrature_marginal_is_zero_for_gaussian_block() {
3406 let target = AnharmonicBlock {
3411 lambdas: array![2.0, 0.5],
3412 a: 0.0,
3413 };
3414 let out = super::block_quadrature_marginal_correction(&target).expect("correction");
3415 assert!(
3416 out.value.abs() < 1e-12,
3417 "Gaussian block value {}",
3418 out.value
3419 );
3420 assert!(out.rho_gradient.iter().all(|&g| g.abs() < 1e-12));
3421 assert!(out.node_count > 0);
3422 assert_eq!(out.quadrature_error, 0.0);
3423 }
3424
3425 #[test]
3426 fn block_quadrature_marginal_recovers_analytic_quartic_correction() {
3427 let lambda = 3.0_f64;
3434 let a = 0.05_f64;
3435 let target = AnharmonicBlock {
3436 lambdas: array![lambda],
3437 a,
3438 };
3439 let out = super::block_quadrature_marginal_correction(&target).expect("correction");
3440
3441 let sigma = (1.0 / lambda).sqrt();
3444 let steps = 20_001;
3445 let lo = -8.0 * sigma;
3446 let hi = 8.0 * sigma;
3447 let h = (hi - lo) / (steps as f64 - 1.0);
3448 let mut integral = 0.0_f64;
3449 for i in 0..steps {
3450 let tt = lo + h * i as f64;
3451 let gauss = (-(tt * tt) / (2.0 * sigma * sigma)).exp()
3452 / (sigma * (2.0 * std::f64::consts::PI).sqrt());
3453 let w = if i == 0 || i == steps - 1 { 0.5 } else { 1.0 };
3454 integral += w * gauss * (-a * tt.powi(4)).exp() * h;
3455 }
3456 let reference = integral.ln();
3457 assert!(
3458 (out.value - reference).abs() < 5e-3,
3459 "quadrature Δ_b {} vs reference {}",
3460 out.value,
3461 reference,
3462 );
3463 assert!(out.value < 0.0, "quartic penalty must shrink block mass");
3464 }
3465
3466 struct MatvecBlock {
3474 lambdas: Array1<f64>,
3475 x: Array2<f64>,
3476 v_b: Array2<f64>,
3477 y: Array1<f64>,
3478 batched: bool,
3479 }
3480 impl MatvecBlock {
3481 fn s_of(&self, t: &Array1<f64>) -> Array1<f64> {
3482 let delta = self.v_b.dot(t);
3483 gam_linalg::faer_ndarray::fast_av(&self.x, &delta)
3484 }
3485 fn excess_and_ngs(&self, s: &Array1<f64>) -> (f64, Array1<f64>) {
3487 let mut excess = 0.0;
3488 let mut ngs = Array1::<f64>::zeros(s.len());
3489 for i in 0..s.len() {
3490 let mu = (self.y[i] + s[i]).tanh();
3491 excess += 0.5 * s[i] * s[i] - 0.1 * mu;
3492 ngs[i] = mu - self.y[i];
3493 }
3494 (excess, ngs)
3495 }
3496 }
3497 impl super::BlockExcessTarget for MatvecBlock {
3498 fn block_dim(&self) -> usize {
3499 self.lambdas.len()
3500 }
3501 fn rho_dim(&self) -> usize {
3502 self.lambdas.len()
3503 }
3504 fn block_curvatures(&self) -> &Array1<f64> {
3505 &self.lambdas
3506 }
3507 fn excess(&self, t: &Array1<f64>) -> f64 {
3508 self.excess_and_ngs(&self.s_of(t)).0
3509 }
3510 fn excess_rho_gradient(&self, t: &Array1<f64>) -> Array1<f64> {
3511 t.mapv(|x| 0.01 * x)
3512 }
3513 fn displaced_neg_score(&self, t: &Array1<f64>) -> Result<Array1<f64>, String> {
3514 Ok(self.excess_and_ngs(&self.s_of(t)).1)
3515 }
3516 fn base_neg_score(&self) -> Result<Array1<f64>, String> {
3517 Ok(self
3518 .excess_and_ngs(&self.s_of(&Array1::zeros(self.block_dim())))
3519 .1)
3520 }
3521 fn excess_with_displaced_neg_score_batch(
3522 &self,
3523 draws: &Array2<f64>,
3524 ) -> Vec<(f64, Option<Array1<f64>>)> {
3525 if !self.batched {
3526 let mut out = Vec::with_capacity(draws.ncols());
3528 let mut t = Array1::<f64>::zeros(draws.nrows());
3529 for s in 0..draws.ncols() {
3530 t.assign(&draws.column(s));
3531 out.push(self.excess_with_displaced_neg_score(&t));
3532 }
3533 return out;
3534 }
3535 let delta_all = gam_linalg::faer_ndarray::fast_ab(&self.v_b, draws);
3537 let s_all = gam_linalg::faer_ndarray::fast_ab(&self.x, &delta_all);
3538 (0..draws.ncols())
3539 .map(|c| {
3540 let (e, ngs) = self.excess_and_ngs(&s_all.column(c).to_owned());
3541 if e.is_finite() {
3542 (e, Some(ngs))
3543 } else {
3544 (e, None)
3545 }
3546 })
3547 .collect()
3548 }
3549 }
3550
3551 #[test]
3552 fn block_quadrature_marginal_batched_matches_serial_matvec() {
3553 let n = 80usize;
3557 let p = 40usize;
3558 let m = 3usize;
3559 let mut x = Array2::<f64>::zeros((n, p));
3560 for i in 0..n {
3561 for j in 0..p {
3562 x[(i, j)] = ((i * 7 + j * 13) % 11) as f64 * 0.05 - 0.25;
3563 }
3564 }
3565 let mut v_b = Array2::<f64>::zeros((p, m));
3566 for i in 0..p {
3567 for r in 0..m {
3568 v_b[(i, r)] = ((i * 3 + r * 5) % 7) as f64 * 0.1 - 0.3;
3569 }
3570 }
3571 let y: Array1<f64> = (0..n).map(|i| ((i % 5) as f64) * 0.2).collect();
3572 let lambdas = array![2.0, 1.0, 0.5];
3573
3574 let serial = super::block_quadrature_marginal_correction(&MatvecBlock {
3575 lambdas: lambdas.clone(),
3576 x: x.clone(),
3577 v_b: v_b.clone(),
3578 y: y.clone(),
3579 batched: false,
3580 })
3581 .expect("serial");
3582 let batched = super::block_quadrature_marginal_correction(&MatvecBlock {
3583 lambdas,
3584 x,
3585 v_b,
3586 y,
3587 batched: true,
3588 })
3589 .expect("batched");
3590
3591 assert_eq!(serial.node_count, batched.node_count);
3592 assert!(
3593 (serial.value - batched.value).abs() <= 1e-10 * (1.0 + serial.value.abs()),
3594 "value serial {} vs batched {}",
3595 serial.value,
3596 batched.value
3597 );
3598 for k in 0..serial.rho_gradient.len() {
3599 assert!(
3600 (serial.rho_gradient[k] - batched.rho_gradient[k]).abs()
3601 <= 1e-10 * (1.0 + serial.rho_gradient[k].abs()),
3602 "rho_gradient[{k}] serial {} vs batched {}",
3603 serial.rho_gradient[k],
3604 batched.rho_gradient[k]
3605 );
3606 }
3607 let ms = serial.moments.expect("serial moments");
3608 let mb = batched.moments.expect("batched moments");
3609 for (a, b) in ms.e_t.iter().zip(mb.e_t.iter()) {
3610 assert!((a - b).abs() <= 1e-10 * (1.0 + a.abs()), "e_t {a} vs {b}");
3611 }
3612 for (a, b) in ms.e_neg_score.iter().zip(mb.e_neg_score.iter()) {
3613 assert!(
3614 (a - b).abs() <= 1e-10 * (1.0 + a.abs()),
3615 "e_neg_score {a} vs {b}"
3616 );
3617 }
3618 for (a, b) in ms.e_t_neg_score.iter().zip(mb.e_t_neg_score.iter()) {
3619 assert!(
3620 (a - b).abs() <= 1e-10 * (1.0 + a.abs()),
3621 "e_t_neg_score {a} vs {b}"
3622 );
3623 }
3624 }
3625
3626 #[test]
3627 fn logit_pg_rao_blackwell_rejects_non_bernoulli_response() {
3628 let x = array![[1.0], [1.0]];
3629 let y = array![0.25, 1.0];
3630 let w = array![1.0, 1.0];
3631 let penalty = array![[0.1]];
3632 let mode = array![0.0];
3633 let roots = vec![array![[0.1_f64.sqrt()]]];
3634 let cfg = NutsConfig {
3635 n_samples: 1,
3636 nwarmup: 1,
3637 n_chains: 1,
3638 target_accept: 0.8,
3639 seed: 654,
3640 };
3641
3642 let result = super::estimate_logit_pg_rao_blackwell_terms(
3643 x.view(),
3644 y.view(),
3645 w.view(),
3646 penalty.view(),
3647 mode.view(),
3648 &roots,
3649 &cfg,
3650 );
3651
3652 let err = result
3653 .err()
3654 .expect("PG Rao-Blackwell should reject proportion rows");
3655 assert!(
3656 err.contains("response must be exactly 0 or 1"),
3657 "unexpected error: {err}"
3658 );
3659 }
3660
3661 #[test]
3662 fn logit_pg_rao_blackwell_matches_beta_quadratic_moment_sanity() {
3663 let x = array![[1.0, 0.2], [1.0, -0.1], [1.0, 1.2], [1.0, -0.7]];
3664 let y = array![1.0, 0.0, 1.0, 0.0];
3665 let w = array![1.0, 1.0, 1.0, 1.0];
3666 let penalty = array![[0.2, 0.0], [0.0, 0.4]];
3667 let mode = array![0.0, 0.0];
3668 let roots = vec![array![[0.2_f64.sqrt(), 0.0], [0.0, 0.4_f64.sqrt()]]];
3669 let cfg = NutsConfig {
3670 n_samples: 120,
3671 nwarmup: 80,
3672 n_chains: 2,
3673 target_accept: 0.8,
3674 seed: 901,
3675 };
3676
3677 let gibbs = run_logit_polya_gamma_gibbs(
3678 x.view(),
3679 y.view(),
3680 w.view(),
3681 penalty.view(),
3682 mode.view(),
3683 &cfg,
3684 )
3685 .expect("pg gibbs should run");
3686 let mc_quad = gibbs
3687 .samples
3688 .rows()
3689 .into_iter()
3690 .map(|beta| {
3691 let sb = penalty.dot(&beta.to_owned());
3692 beta.dot(&sb)
3693 })
3694 .sum::<f64>()
3695 / (gibbs.samples.nrows() as f64);
3696
3697 let rb = super::estimate_logit_pg_rao_blackwell_terms(
3698 x.view(),
3699 y.view(),
3700 w.view(),
3701 penalty.view(),
3702 mode.view(),
3703 &roots,
3704 &cfg,
3705 )
3706 .expect("rao-blackwell PG should run");
3707
3708 let diff = (rb[0] - mc_quad).abs();
3709 assert!(
3710 diff < 0.35,
3711 "Rao-Blackwell vs beta-moment mismatch too large: rb={}, mc={}, diff={}",
3712 rb[0],
3713 mc_quad,
3714 diff
3715 );
3716 }
3717
3718 #[test]
3719 fn survival_hmc_structural_monotonic_returns_finitevalues() {
3720 let age_entry = array![1.0];
3721 let age_exit = array![2.0];
3722 let event_target = array![1u8];
3723 let event_competing = array![0u8];
3724 let sampleweight = array![1.0];
3725 let x_entry = array![[1.0, 0.2]];
3726 let x_exit = array![[1.0, 0.6]];
3727 let x_derivative = array![[0.0, 1.0]];
3728 let penalties = PenaltyBlocks::new(Vec::new());
3729 let monotonicity = SurvivalMonotonicityPenalty { tolerance: 3.0 };
3730 let mode = array![0.0, 0.0];
3731 let hessian = Array2::<f64>::eye(2);
3732
3733 let posterior = super::survival_hmc::SurvivalPosterior::new(
3734 age_entry.view(),
3735 age_exit.view(),
3736 event_target.view(),
3737 event_competing.view(),
3738 sampleweight.view(),
3739 x_entry.view(),
3740 x_exit.view(),
3741 x_derivative.view(),
3742 None,
3743 None,
3744 None,
3745 penalties,
3746 monotonicity,
3747 SurvivalSpec::Net,
3748 true,
3749 2,
3750 mode.view(),
3751 hessian.view(),
3752 )
3753 .expect("construct survival posterior");
3754
3755 let position = array![0.0, 0.0];
3756 let mut grad = Array1::<f64>::zeros(2);
3757 let logp = HamiltonianTarget::logp_and_grad(&posterior, &position, &mut grad);
3758 assert!(logp.is_finite());
3759 assert!(grad.iter().all(|v| v.is_finite()));
3760 }
3761
3762 #[test]
3763 fn survival_hmc_structural_monotonic_differs_from_linear_geometry() {
3764 let age_entry = array![1.0];
3765 let age_exit = array![2.0];
3766 let event_target = array![1u8];
3767 let event_competing = array![0u8];
3768 let sampleweight = array![1.0];
3769 let x_entry = array![[0.2, 0.1]];
3770 let x_exit = array![[0.6, 0.3]];
3771 let x_derivative = array![[1.0, 0.0]];
3772 let monotonicity = SurvivalMonotonicityPenalty { tolerance: 3.0 };
3773 let mode = array![0.0, 0.0];
3774 let hessian = Array2::<f64>::eye(2);
3775 let z = array![std::f64::consts::LN_2, 0.0];
3776
3777 let posterior_linear = super::survival_hmc::SurvivalPosterior::new(
3778 age_entry.view(),
3779 age_exit.view(),
3780 event_target.view(),
3781 event_competing.view(),
3782 sampleweight.view(),
3783 x_entry.view(),
3784 x_exit.view(),
3785 x_derivative.view(),
3786 None,
3787 None,
3788 None,
3789 PenaltyBlocks::new(Vec::new()),
3790 monotonicity,
3791 SurvivalSpec::Net,
3792 false,
3793 0,
3794 mode.view(),
3795 hessian.view(),
3796 )
3797 .expect("construct linear posterior");
3798 let mut grad_linear = Array1::<f64>::zeros(2);
3799 HamiltonianTarget::logp_and_grad(&posterior_linear, &z, &mut grad_linear);
3800
3801 let posterior_struct = super::survival_hmc::SurvivalPosterior::new(
3802 age_entry.view(),
3803 age_exit.view(),
3804 event_target.view(),
3805 event_competing.view(),
3806 sampleweight.view(),
3807 x_entry.view(),
3808 x_exit.view(),
3809 x_derivative.view(),
3810 None,
3811 None,
3812 None,
3813 PenaltyBlocks::new(Vec::new()),
3814 monotonicity,
3815 SurvivalSpec::Net,
3816 true,
3817 2,
3818 mode.view(),
3819 hessian.view(),
3820 )
3821 .expect("construct structural posterior");
3822 let mut grad_struct = Array1::<f64>::zeros(2);
3823 HamiltonianTarget::logp_and_grad(&posterior_struct, &z, &mut grad_struct);
3824
3825 assert!(
3826 (grad_struct[0] - grad_linear[0]).abs() > 1e-6,
3827 "expected structural and linear fallback gradients to differ"
3828 );
3829 assert!(grad_struct[0].is_finite());
3830 assert!(grad_linear[0].is_finite());
3831 }
3832
3833 #[test]
3834 fn survival_hmc_fallback_barrier_rejects_offsets_below_monotonicity_threshold() {
3835 let age_entry = array![1.0];
3836 let age_exit = array![2.0];
3837 let event_target = array![1u8];
3838 let event_competing = array![0u8];
3839 let sampleweight = array![1.0];
3840 let x_entry = array![[1.0, 0.0]];
3841 let x_exit = array![[1.0, 0.0]];
3842 let x_derivative = array![[0.0, 0.0]];
3844 let penalties = PenaltyBlocks::new(Vec::new());
3845 let monotonicity = SurvivalMonotonicityPenalty { tolerance: 3.0 };
3846 let mode = array![0.0, 0.0];
3847 let hessian = Array2::<f64>::eye(2);
3848 let z = array![0.0, 0.0];
3849
3850 let posterior_no_offset = super::survival_hmc::SurvivalPosterior::new(
3851 age_entry.view(),
3852 age_exit.view(),
3853 event_target.view(),
3854 event_competing.view(),
3855 sampleweight.view(),
3856 x_entry.view(),
3857 x_exit.view(),
3858 x_derivative.view(),
3859 None,
3860 None,
3861 Some(array![0.0].view()),
3862 penalties.clone(),
3863 monotonicity,
3864 SurvivalSpec::Net,
3865 false,
3866 0,
3867 mode.view(),
3868 hessian.view(),
3869 )
3870 .expect("construct posterior without derivative offset");
3871 let mut grad_no_offset = Array1::<f64>::zeros(2);
3872 let logp_no_offset =
3873 HamiltonianTarget::logp_and_grad(&posterior_no_offset, &z, &mut grad_no_offset);
3874
3875 let posteriorwith_offset = super::survival_hmc::SurvivalPosterior::new(
3876 age_entry.view(),
3877 age_exit.view(),
3878 event_target.view(),
3879 event_competing.view(),
3880 sampleweight.view(),
3881 x_entry.view(),
3882 x_exit.view(),
3883 x_derivative.view(),
3884 None,
3885 None,
3886 Some(array![2.0].view()),
3887 penalties,
3888 monotonicity,
3889 SurvivalSpec::Net,
3890 false,
3891 0,
3892 mode.view(),
3893 hessian.view(),
3894 )
3895 .expect("construct posterior with derivative offset");
3896 let mut gradwith_offset = Array1::<f64>::zeros(2);
3897 let logpwith_offset =
3898 HamiltonianTarget::logp_and_grad(&posteriorwith_offset, &z, &mut gradwith_offset);
3899
3900 assert!(!logp_no_offset.is_finite());
3901 assert!(!logpwith_offset.is_finite());
3902 assert!(grad_no_offset.iter().all(|v| *v == 0.0));
3903 assert!(gradwith_offset.iter().all(|v| *v == 0.0));
3904 }
3905
3906 #[test]
3907 fn survival_hmc_fallback_barrier_becomes_finite_once_offset_clears_guard() {
3908 let age_entry = array![1.0];
3909 let age_exit = array![2.0];
3910 let event_target = array![1u8];
3911 let event_competing = array![0u8];
3912 let sampleweight = array![1.0];
3913 let x_entry = array![[1.0, 0.0]];
3914 let x_exit = array![[1.0, 0.0]];
3915 let x_derivative = array![[0.0, 0.0]];
3916 let penalties = PenaltyBlocks::new(Vec::new());
3917 let monotonicity = SurvivalMonotonicityPenalty { tolerance: 3.0 };
3918 let mode = array![0.0, 0.0];
3919 let hessian = Array2::<f64>::eye(2);
3920 let z = array![0.0, 0.0];
3921
3922 let posterior_below_guard = super::survival_hmc::SurvivalPosterior::new(
3923 age_entry.view(),
3924 age_exit.view(),
3925 event_target.view(),
3926 event_competing.view(),
3927 sampleweight.view(),
3928 x_entry.view(),
3929 x_exit.view(),
3930 x_derivative.view(),
3931 None,
3932 None,
3933 Some(array![2.0].view()),
3934 penalties.clone(),
3935 monotonicity,
3936 SurvivalSpec::Net,
3937 false,
3938 0,
3939 mode.view(),
3940 hessian.view(),
3941 )
3942 .expect("construct posterior below derivative guard");
3943 let mut grad_below_guard = Array1::<f64>::zeros(2);
3944 let logp_below_guard =
3945 HamiltonianTarget::logp_and_grad(&posterior_below_guard, &z, &mut grad_below_guard);
3946
3947 let posterior_above_guard = super::survival_hmc::SurvivalPosterior::new(
3948 age_entry.view(),
3949 age_exit.view(),
3950 event_target.view(),
3951 event_competing.view(),
3952 sampleweight.view(),
3953 x_entry.view(),
3954 x_exit.view(),
3955 x_derivative.view(),
3956 None,
3957 None,
3958 Some(array![3.1].view()),
3959 penalties,
3960 monotonicity,
3961 SurvivalSpec::Net,
3962 false,
3963 0,
3964 mode.view(),
3965 hessian.view(),
3966 )
3967 .expect("construct posterior above derivative guard");
3968 let mut grad_above_guard = Array1::<f64>::zeros(2);
3969 let logp_above_guard =
3970 HamiltonianTarget::logp_and_grad(&posterior_above_guard, &z, &mut grad_above_guard);
3971
3972 assert!(!logp_below_guard.is_finite());
3973 assert!(logp_above_guard.is_finite());
3974 assert!(grad_below_guard.iter().all(|v| *v == 0.0));
3975 assert!(grad_above_guard.iter().all(|v| v.is_finite()));
3976 }
3977
3978 #[test]
3979 fn survival_hmc_structural_monotonic_handles_sparse_multirow_geometry() {
3980 let age_entry = array![1.0, 1.2];
3981 let age_exit = array![2.0, 2.4];
3982 let event_target = array![1u8, 1u8];
3983 let event_competing = array![0u8, 0u8];
3984 let sampleweight = array![1.0, 1.0];
3985 let x_entry = array![[0.1, 0.0, 0.2], [0.2, 0.1, 0.2]];
3986 let x_exit = array![[0.4, 0.2, 0.3], [0.6, 0.1, 0.3]];
3987 let x_derivative = array![[1.0, 0.0, 0.0], [0.5, 1.0, 0.0]];
3989 let monotonicity = SurvivalMonotonicityPenalty { tolerance: 3.0 };
3990 let mode = array![4.0, 2.0, 0.0];
3991 let hessian = Array2::<f64>::eye(3);
3992 let z = array![0.05, -0.1, 0.15];
3993
3994 let posterior = super::survival_hmc::SurvivalPosterior::new(
3995 age_entry.view(),
3996 age_exit.view(),
3997 event_target.view(),
3998 event_competing.view(),
3999 sampleweight.view(),
4000 x_entry.view(),
4001 x_exit.view(),
4002 x_derivative.view(),
4003 None,
4004 None,
4005 None,
4006 PenaltyBlocks::new(Vec::new()),
4007 monotonicity,
4008 SurvivalSpec::Net,
4009 true,
4010 2,
4011 mode.view(),
4012 hessian.view(),
4013 )
4014 .expect("construct structural posterior");
4015
4016 let mut grad = Array1::<f64>::zeros(3);
4017 let logp = HamiltonianTarget::logp_and_grad(&posterior, &z, &mut grad);
4018 assert!(logp.is_finite());
4019 assert!(grad.iter().all(|v| v.is_finite()));
4020 let h = 1e-6;
4021 for axis in 0..z.len() {
4022 let mut plus = z.clone();
4023 let mut minus = z.clone();
4024 plus[axis] += h;
4025 minus[axis] -= h;
4026 let mut plus_grad = Array1::<f64>::zeros(3);
4027 let mut minus_grad = Array1::<f64>::zeros(3);
4028 let plus_logp = HamiltonianTarget::logp_and_grad(&posterior, &plus, &mut plus_grad);
4029 let minus_logp = HamiltonianTarget::logp_and_grad(&posterior, &minus, &mut minus_grad);
4030 let finite_difference = (plus_logp - minus_logp) / (2.0 * h);
4031 assert!(
4032 (grad[axis] - finite_difference).abs() <= 2e-5 * finite_difference.abs().max(1.0),
4033 "structural sparse HMC gradient[{axis}]: analytic={}, finite_difference={finite_difference}",
4034 grad[axis]
4035 );
4036 }
4037 }
4038}
4039
4040impl HamiltonianTarget<Array1<f64>> for NutsPosterior {
4042 fn logp_and_grad(&self, position: &Array1<f64>, grad: &mut Array1<f64>) -> f64 {
4043 NUTS_RESIDUAL_SCRATCH.with(|scratch| {
4044 let mut residual = scratch.borrow_mut();
4045 if residual.len() != self.data.n_samples {
4046 *residual = Array1::<f64>::zeros(self.data.n_samples);
4047 }
4048 self.compute_logp_and_grad_nd_into(position, &mut residual, grad)
4049 })
4050 }
4051}
4052
4053#[derive(Clone, Debug, Serialize, Deserialize)]
4055pub struct NutsConfig {
4056 pub n_samples: usize,
4058 pub nwarmup: usize,
4060 pub n_chains: usize,
4062 pub target_accept: f64,
4064 #[serde(default = "default_nuts_seed")]
4066 pub seed: u64,
4067}
4068
4069fn default_nuts_seed() -> u64 {
4070 42
4071}
4072
4073fn validate_nuts_target_accept(target_accept: f64) -> Result<(), HmcError> {
4074 if target_accept.is_finite() && target_accept > 0.0 && target_accept < 1.0 {
4075 Ok(())
4076 } else {
4077 Err(HmcError::InvalidConfig {
4078 reason: format!(
4079 "NUTS target_accept must be finite and lie in (0, 1), got {target_accept}"
4080 ),
4081 })
4082 }
4083}
4084
4085const MIN_NUTS_SAMPLES: usize = 4;
4093
4094const MIN_NUTS_CHAINS: usize = 1;
4103
4104fn validate_nuts_draws(config: &NutsConfig) -> Result<(), HmcError> {
4109 if config.n_chains < MIN_NUTS_CHAINS {
4110 return Err(HmcError::InvalidConfig {
4111 reason: format!(
4112 "NUTS n_chains must be >= {MIN_NUTS_CHAINS}; with zero chains the \
4113 sampler has no initial positions to run, got {}",
4114 config.n_chains
4115 ),
4116 });
4117 }
4118 if config.n_samples < MIN_NUTS_SAMPLES {
4119 return Err(HmcError::InvalidConfig {
4120 reason: format!(
4121 "NUTS n_samples must be >= {MIN_NUTS_SAMPLES} so split-R-hat / ESS \
4122 diagnostics are defined, got {}",
4123 config.n_samples
4124 ),
4125 });
4126 }
4127 Ok(())
4128}
4129
4130pub(crate) fn validate_nuts_config(config: &NutsConfig) -> Result<(), HmcError> {
4134 validate_nuts_target_accept(config.target_accept)?;
4135 validate_nuts_draws(config)?;
4136 Ok(())
4137}
4138
4139#[inline]
4140fn splitmix64(x: u64) -> u64 {
4141 gam_linalg::utils::splitmix64_hash(x)
4142}
4143
4144#[inline]
4145fn chain_stream_seed(seed: u64, chain: usize, stream: u64) -> u64 {
4146 splitmix64(seed ^ stream ^ ((chain as u64).wrapping_mul(0xD1B5_4A32_D192_ED03)))
4147}
4148
4149#[inline]
4150fn nuts_transition_seed(seed: u64, stream: u64) -> u64 {
4151 splitmix64(seed ^ stream ^ 0xA24B_AED4_963E_E407)
4152}
4153
4154#[inline]
4155fn gibbs_pg_seed(seed: u64, chain: usize, stream: u64, iter: usize) -> u64 {
4156 chain_stream_seed(
4157 seed,
4158 chain,
4159 stream ^ ((iter as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)),
4160 )
4161}
4162
4163fn draw_logit_pg1_omega(
4164 shapes: ArrayView1<'_, u32>,
4165 tilts: ArrayView1<'_, f64>,
4166 seed: u64,
4167 out: &mut Array1<f64>,
4168) -> Result<(), String> {
4169 if out.len() != tilts.len() {
4170 return Err(HmcError::DimensionMismatch {
4171 reason: "draw_logit_pg1_omega: output length mismatch".to_string(),
4172 }
4173 .into());
4174 }
4175 let draws = crate::gpu_polya_gamma::draw_batch(PolyaGammaBatchInput {
4176 shapes,
4177 tilts,
4178 seed: PgSeed(seed),
4179 })?;
4180 out.assign(&draws);
4181 out.mapv_inplace(|v| v.max(1.0e-12));
4182 Ok(())
4183}
4184
4185const HIGH_DIM_THRESHOLD: usize = 50;
4191
4192const HIGH_DIM_TARGET_ACCEPT_FLOOR: f64 = 0.92;
4196const LOW_DIM_TARGET_ACCEPT_FLOOR: f64 = 0.90;
4198const MAX_TARGET_ACCEPT: f64 = 0.95;
4201
4202const MIN_WARMUP_FOR_MASS_ADAPT: usize = 80;
4207
4208const DENSE_MASS_MATRIX_MAX_DIM: usize = 75;
4212
4213const MASS_REGULARIZE_HIGH_DIM: f64 = 0.14;
4217const MASS_REGULARIZE_LOW_DIM: f64 = 0.10;
4218const SURVIVAL_MASS_REGULARIZE_HIGH_DIM: f64 = 0.18;
4221const SURVIVAL_MASS_REGULARIZE_LOW_DIM: f64 = 0.12;
4222
4223const MASS_MATRIX_JITTER: f64 = 1e-5;
4226
4227#[inline]
4228fn robust_target_accept(requested: f64, dim: usize) -> f64 {
4229 let floor = if dim > HIGH_DIM_THRESHOLD {
4230 HIGH_DIM_TARGET_ACCEPT_FLOOR
4231 } else {
4232 LOW_DIM_TARGET_ACCEPT_FLOOR
4233 };
4234 requested.max(floor).min(MAX_TARGET_ACCEPT)
4235}
4236
4237fn jittered_initial_positions(
4238 config: &NutsConfig,
4239 dim: usize,
4240 scale: f64,
4241 stream: u64,
4242) -> Vec<Array1<f64>> {
4243 (0..config.n_chains)
4244 .map(|chain| {
4245 let mut rng = StdRng::seed_from_u64(chain_stream_seed(config.seed, chain, stream));
4246 Array1::from_shape_fn(dim, |_| sample_standard_normal(&mut rng) * scale)
4247 })
4248 .collect()
4249}
4250
4251fn robust_mass_matrix_config(dim: usize, nwarmup: usize) -> NUTSMassMatrixConfig {
4252 if nwarmup < MIN_WARMUP_FOR_MASS_ADAPT {
4253 return NUTSMassMatrixConfig::disabled();
4254 }
4255 let start_buffer = (nwarmup / 8).clamp(35, 180);
4256 let end_buffer = (nwarmup / 5).clamp(50, 250);
4257 let initial_window = (nwarmup / 20).clamp(10, 60);
4258 NUTSMassMatrixConfig {
4259 adaptation: MassMatrixAdaptation::Diagonal,
4260 start_buffer,
4261 end_buffer,
4262 initial_window,
4263 regularize: if dim > HIGH_DIM_THRESHOLD {
4264 MASS_REGULARIZE_HIGH_DIM
4265 } else {
4266 MASS_REGULARIZE_LOW_DIM
4267 },
4268 jitter: MASS_MATRIX_JITTER,
4269 dense_max_dim: DENSE_MASS_MATRIX_MAX_DIM,
4270 }
4271}
4272
4273fn robust_survival_mass_matrix_config(dim: usize, nwarmup: usize) -> NUTSMassMatrixConfig {
4274 if nwarmup < MIN_WARMUP_FOR_MASS_ADAPT {
4275 return NUTSMassMatrixConfig::disabled();
4276 }
4277 let start_buffer = (nwarmup / 7).clamp(40, 200);
4280 let end_buffer = (nwarmup / 4).clamp(60, 280);
4281 let initial_window = (nwarmup / 20).clamp(10, 60);
4282 NUTSMassMatrixConfig {
4283 adaptation: MassMatrixAdaptation::Diagonal,
4284 start_buffer,
4285 end_buffer,
4286 initial_window,
4287 regularize: if dim > HIGH_DIM_THRESHOLD {
4288 SURVIVAL_MASS_REGULARIZE_HIGH_DIM
4289 } else {
4290 SURVIVAL_MASS_REGULARIZE_LOW_DIM
4291 },
4292 jitter: MASS_MATRIX_JITTER,
4293 dense_max_dim: DENSE_MASS_MATRIX_MAX_DIM,
4294 }
4295}
4296
4297impl Default for NutsConfig {
4298 fn default() -> Self {
4299 Self {
4300 n_samples: 1000,
4301 nwarmup: 500,
4302 n_chains: 4,
4303 target_accept: 0.9,
4304 seed: 42,
4305 }
4306 }
4307}
4308
4309impl NutsConfig {
4310 pub fn for_dimension(n_params: usize) -> Self {
4318 let effective_autocorr = (n_params as f64).sqrt().max(1.0);
4320
4321 let target_ess = 100 * n_params;
4323
4324 let raw_samples = (target_ess as f64 * (1.0 + 2.0 * effective_autocorr) * 1.5) as usize;
4326
4327 let n_samples = raw_samples.clamp(500, 10_000);
4329
4330 let nwarmup = n_samples;
4332
4333 let n_chains = if n_params > 50 { 4 } else { 2 };
4335
4336 Self {
4337 n_samples,
4338 nwarmup,
4339 n_chains,
4340 target_accept: 0.9,
4341 seed: 42,
4342 }
4343 }
4344}
4345
4346#[derive(Clone, Debug)]
4348pub struct NutsResult {
4349 pub samples: Array2<f64>,
4351 pub posterior_mean: Array1<f64>,
4353 pub posterior_std: Array1<f64>,
4355 pub rhat: f64,
4357 pub ess: f64,
4359 pub converged: bool,
4361}
4362
4363#[derive(Clone, Copy)]
4364struct NutsConvergenceThresholds {
4365 max_rhat: f64,
4366 min_ess: Option<f64>,
4367}
4368
4369impl NutsConvergenceThresholds {
4370 #[inline]
4371 fn converged(self, rhat: f64, ess: f64) -> bool {
4372 let rhat_ok = rhat < self.max_rhat;
4373 match self.min_ess {
4374 Some(min_ess) => rhat_ok && ess > min_ess,
4375 None => rhat_ok,
4376 }
4377 }
4378}
4379
4380fn run_whitened_nuts_samples<Target>(
4381 target: Target,
4382 initial_positions: Vec<Array1<f64>>,
4383 config: &NutsConfig,
4384 dim: usize,
4385 mass_cfg: NUTSMassMatrixConfig,
4386 transition_seed_stream: u64,
4387 sampling_error_label: &str,
4388) -> Result<(Array3<f64>, String), String>
4389where
4390 Target: HamiltonianTarget<Array1<f64>> + Sync + Send,
4391{
4392 let mut sampler = GenericNUTS::new_with_mass_matrix(
4393 target,
4394 initial_positions,
4395 robust_target_accept(config.target_accept, dim),
4396 mass_cfg,
4397 )
4398 .set_seed(nuts_transition_seed(config.seed, transition_seed_stream));
4399
4400 let (samples_array, run_stats) = sampler
4401 .run_progress(config.n_samples, config.nwarmup)
4402 .map_err(|e| format!("{sampling_error_label}: {e}"))?;
4403 Ok((samples_array, run_stats.to_string()))
4404}
4405
4406fn unwhiten_samples(
4407 samples_array: &Array3<f64>,
4408 mode: &Array1<f64>,
4409 chol: &Array2<f64>,
4410 dim: usize,
4411 z_start: usize,
4412) -> Array2<f64> {
4413 let shape = samples_array.shape();
4414 let n_chains = shape[0];
4415 let n_samples_out = shape[1];
4416 let total_samples = n_chains * n_samples_out;
4417
4418 let mut samples = Array2::<f64>::zeros((total_samples, dim));
4419 let mut z_buffer = Array1::<f64>::zeros(dim);
4420 for chain in 0..n_chains {
4421 for sample_i in 0..n_samples_out {
4422 let zview = samples_array.slice(ndarray::s![chain, sample_i, z_start..z_start + dim]);
4423 z_buffer.assign(&zview);
4424 let beta = mode + &chol.dot(&z_buffer);
4425 let sample_idx = chain * n_samples_out + sample_i;
4426 samples.row_mut(sample_idx).assign(&beta);
4427 }
4428 }
4429
4430 samples
4431}
4432
4433fn summarize_unwhitened_nuts_samples(
4434 samples: Array2<f64>,
4435 samples_array: &Array3<f64>,
4436 empty_mean: Array1<f64>,
4437 convergence: NutsConvergenceThresholds,
4438) -> NutsResult {
4439 let posterior_mean = samples.mean_axis(Axis(0)).unwrap_or(empty_mean);
4440 let posterior_std = samples.std_axis(Axis(0), 0.0);
4441 let (rhat, ess) = compute_split_rhat_and_ess(samples_array);
4442 let converged = convergence.converged(rhat, ess);
4443
4444 NutsResult {
4445 samples,
4446 posterior_mean,
4447 posterior_std,
4448 rhat,
4449 ess,
4450 converged,
4451 }
4452}
4453
4454fn run_whitened_nuts_result<Target>(
4455 target: Target,
4456 mode: &Array1<f64>,
4457 chol: &Array2<f64>,
4458 initial_positions: Vec<Array1<f64>>,
4459 config: &NutsConfig,
4460 dim: usize,
4461 mass_cfg: NUTSMassMatrixConfig,
4462 transition_seed_stream: u64,
4463 sampling_error_label: &str,
4464 empty_mean: Array1<f64>,
4465 convergence: NutsConvergenceThresholds,
4466) -> Result<(NutsResult, String), String>
4467where
4468 Target: HamiltonianTarget<Array1<f64>> + Sync + Send,
4469{
4470 let (samples_array, run_stats) = run_whitened_nuts_samples(
4471 target,
4472 initial_positions,
4473 config,
4474 dim,
4475 mass_cfg,
4476 transition_seed_stream,
4477 sampling_error_label,
4478 )?;
4479 let samples = unwhiten_samples(&samples_array, mode, chol, dim, 0);
4480 let result =
4481 summarize_unwhitened_nuts_samples(samples, &samples_array, empty_mean, convergence);
4482 Ok((result, run_stats))
4483}
4484
4485impl NutsResult {
4486 pub fn posterior_mean_of<F>(&self, f: F) -> f64
4489 where
4490 F: Fn(ArrayView1<f64>) -> f64 + Sync,
4491 {
4492 let n = self.samples.nrows();
4493 if n == 0 {
4494 return 0.0;
4495 }
4496 let sum: f64 = gam_linalg::pairwise_reduce::par_pairwise_sum(n, |i| f(self.samples.row(i)));
4499 sum / n as f64
4500 }
4501
4502 pub fn posterior_interval_of<F>(&self, f: F, lower_pct: f64, upper_pct: f64) -> (f64, f64)
4504 where
4505 F: Fn(ArrayView1<f64>) -> f64,
4506 {
4507 let n = self.samples.nrows();
4508 if n == 0 {
4509 return (0.0, 0.0);
4510 }
4511 let mut values: Vec<f64> = (0..n).map(|i| f(self.samples.row(i))).collect();
4512 values.sort_by(f64::total_cmp);
4513
4514 (
4515 gam_math::quantile::quantile_from_sorted(&values, lower_pct / 100.0),
4516 gam_math::quantile::quantile_from_sorted(&values, upper_pct / 100.0),
4517 )
4518 }
4519}
4520
4521#[inline]
4522fn sample_standard_normal<R: rand::Rng + ?Sized>(rng: &mut R) -> f64 {
4523 let u1 = loop {
4528 let draw = rng.random::<f64>();
4529 if draw > 0.0 {
4530 break draw;
4531 }
4532 };
4533 let u2 = rng.random::<f64>();
4534 (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
4535}
4536
4537pub fn run_logit_polya_gamma_gibbs(
4546 x: ArrayView2<f64>,
4547 y: ArrayView1<f64>,
4548 weights: ArrayView1<f64>,
4549 penalty_matrix: ArrayView2<f64>,
4550 mode: ArrayView1<f64>,
4551 config: &NutsConfig,
4552) -> Result<NutsResult, String> {
4553 let n = x.nrows();
4554 let p = x.ncols();
4555 if y.len() != n || weights.len() != n {
4556 return Err(HmcError::DimensionMismatch {
4557 reason: "run_logit_polya_gamma_gibbs: input length mismatch".to_string(),
4558 }
4559 .into());
4560 }
4561 if mode.len() != p || penalty_matrix.nrows() != p || penalty_matrix.ncols() != p {
4562 return Err(HmcError::DimensionMismatch {
4563 reason: "run_logit_polya_gamma_gibbs: coefficient/penalty dimension mismatch"
4564 .to_string(),
4565 }
4566 .into());
4567 }
4568 if !weights.iter().all(|w| (*w - 1.0).abs() <= 1e-10) {
4569 return Err(HmcError::InvalidConfig {
4570 reason: "run_logit_polya_gamma_gibbs requires unit weights (PG(1,·)); use NUTS for non-unit weights".to_string(),
4571 }
4572 .into());
4573 }
4574 validate_binary_responses("run_logit_polya_gamma_gibbs", &y, &weights).map_err(String::from)?;
4575 validate_nuts_config(config).map_err(String::from)?;
4582
4583 let n_iter = config.nwarmup + config.n_samples;
4584
4585 let kappa = y.mapv(|v| v - 0.5);
4587 let rhs_b = fast_atv(&x, &kappa);
4588
4589 let mut samples_array = Array3::<f64>::zeros((config.n_chains, config.n_samples, p));
4590 let mut eta = Array1::<f64>::zeros(n);
4591 let mut omega = Array1::<f64>::ones(n);
4592 let pg_shapes = Array1::<u32>::from_elem(n, 1);
4593 let mut xw = x.to_owned();
4594 let mut xt_omega_x = Array2::<f64>::zeros((p, p));
4595 let penalty = penalty_matrix.to_owned();
4596 let mut q = Array2::<f64>::zeros((p, p));
4597 let mut mean = Array1::<f64>::zeros(p);
4598 let mut z = Array1::<f64>::zeros(p);
4599 let mut noise = Array1::<f64>::zeros(p);
4600
4601 for chain in 0..config.n_chains {
4602 let mut init_rng =
4603 StdRng::seed_from_u64(chain_stream_seed(config.seed, chain, 0xB3C4_5A1F_8E9D_7632));
4604 let mut draw_rng =
4605 StdRng::seed_from_u64(chain_stream_seed(config.seed, chain, 0x17A9_26D5_4C1B_E083));
4606 let mut beta = mode.to_owned();
4607 for j in 0..p {
4609 beta[j] += 0.05 * sample_standard_normal(&mut init_rng);
4610 }
4611
4612 for iter in 0..n_iter {
4613 eta.assign(&gam_linalg::faer_ndarray::fast_av(&x, &beta));
4614 draw_logit_pg1_omega(
4615 pg_shapes.view(),
4616 eta.view(),
4617 gibbs_pg_seed(config.seed, chain, 0x4D94_DF4E_5D72_81AB, iter),
4618 &mut omega,
4619 )?;
4620
4621 ndarray::Zip::from(xw.rows_mut())
4624 .and(x.rows())
4625 .and(&omega)
4626 .par_for_each(|mut xw_row, x_row, omega_i| {
4627 let s = omega_i.sqrt();
4628 for j in 0..p {
4629 xw_row[j] = x_row[j] * s;
4630 }
4631 });
4632 fast_ata_into(&xw, &mut xt_omega_x);
4633
4634 q.assign(&penalty);
4635 q += &xt_omega_x;
4636
4637 let factor = q
4639 .cholesky(Side::Lower)
4640 .map_err(|e| format!("PG Gibbs failed to factor Q: {:?}", e))?;
4641 mean.assign(&factor.solvevec(&rhs_b));
4642
4643 for j in 0..p {
4644 z[j] = sample_standard_normal(&mut draw_rng);
4645 }
4646 let l = factor.lower_triangular();
4647 back_substitution_lower_transpose_guarded_into(&l, &z, &mut noise);
4648 beta.assign(&(&mean + &noise));
4649
4650 if iter >= config.nwarmup {
4651 let keep_idx = iter - config.nwarmup;
4652 samples_array
4653 .slice_mut(ndarray::s![chain, keep_idx, ..])
4654 .assign(&beta);
4655 }
4656 }
4657 }
4658
4659 let total_samples = config.n_chains * config.n_samples;
4660 let mut samples = Array2::<f64>::zeros((total_samples, p));
4661 for chain in 0..config.n_chains {
4662 for s in 0..config.n_samples {
4663 let idx = chain * config.n_samples + s;
4664 samples
4665 .row_mut(idx)
4666 .assign(&samples_array.slice(ndarray::s![chain, s, ..]));
4667 }
4668 }
4669
4670 let posterior_mean = samples
4671 .mean_axis(Axis(0))
4672 .unwrap_or_else(|| Array1::zeros(p));
4673 let posterior_std = samples.std_axis(Axis(0), 0.0);
4674 let (rhat, ess) = if config.n_chains >= 2 && config.n_samples >= 4 {
4675 compute_split_rhat_and_ess(&samples_array)
4676 } else {
4677 (1.0, (total_samples as f64) * 0.5)
4678 };
4679 let converged = rhat < 1.1 && ess > 100.0;
4680
4681 Ok(NutsResult {
4682 samples,
4683 posterior_mean,
4684 posterior_std,
4685 rhat,
4686 ess,
4687 converged,
4688 })
4689}
4690
4691pub fn estimate_logit_pg_rao_blackwell_terms(
4701 x: ArrayView2<f64>,
4702 y: ArrayView1<f64>,
4703 weights: ArrayView1<f64>,
4704 penalty_matrix: ArrayView2<f64>,
4705 mode: ArrayView1<f64>,
4706 penalty_roots: &[Array2<f64>],
4707 config: &NutsConfig,
4708) -> Result<Array1<f64>, String> {
4709 let n = x.nrows();
4710 let p = x.ncols();
4711 if y.len() != n || weights.len() != n {
4712 return Err(HmcError::DimensionMismatch {
4713 reason: "estimate_logit_pg_rao_blackwell_terms: input length mismatch".to_string(),
4714 }
4715 .into());
4716 }
4717 if mode.len() != p || penalty_matrix.nrows() != p || penalty_matrix.ncols() != p {
4718 return Err(HmcError::DimensionMismatch {
4719 reason: "estimate_logit_pg_rao_blackwell_terms: coefficient/penalty dimension mismatch"
4720 .to_string(),
4721 }
4722 .into());
4723 }
4724 if !weights.iter().all(|w| (*w - 1.0).abs() <= 1e-10) {
4725 return Err(HmcError::InvalidConfig {
4726 reason: "estimate_logit_pg_rao_blackwell_terms requires unit weights (PG(1,·))"
4727 .to_string(),
4728 }
4729 .into());
4730 }
4731 validate_binary_responses("estimate_logit_pg_rao_blackwell_terms", &y, &weights)
4732 .map_err(String::from)?;
4733 if penalty_roots.iter().any(|r| r.ncols() != p) {
4734 return Err(HmcError::DimensionMismatch {
4735 reason: "estimate_logit_pg_rao_blackwell_terms: root width mismatch".to_string(),
4736 }
4737 .into());
4738 }
4739 let penalty_roots_t: Vec<Array2<f64>> =
4742 penalty_roots.iter().map(|r| r.t().to_owned()).collect();
4743
4744 let n_iter = config.nwarmup + config.n_samples;
4745
4746 let kappa = y.mapv(|v| v - 0.5);
4749 let rhs_b = fast_atv(&x, &kappa);
4750
4751 let penalty = penalty_matrix.to_owned();
4752 let mut eta = Array1::<f64>::zeros(n);
4753 let mut omega = Array1::<f64>::ones(n);
4754 let pg_shapes = Array1::<u32>::from_elem(n, 1);
4755 let mut xw = x.to_owned();
4756 let mut xt_omega_x = Array2::<f64>::zeros((p, p));
4757 let mut q = Array2::<f64>::zeros((p, p));
4758 let mut mean = Array1::<f64>::zeros(p);
4759 let mut rb_sum = Array1::<f64>::zeros(penalty_roots.len());
4760 let mut z = Array1::<f64>::zeros(p);
4761 let mut noise = Array1::<f64>::zeros(p);
4762
4763 let mut kept = 0usize;
4764 for chain in 0..config.n_chains {
4765 let mut init_rng =
4766 StdRng::seed_from_u64(chain_stream_seed(config.seed, chain, 0x28F0_7B65_1A4D_C93E));
4767 let mut draw_rng =
4768 StdRng::seed_from_u64(chain_stream_seed(config.seed, chain, 0xC642_6E35_B5A9_1D80));
4769 let mut beta = mode.to_owned();
4770 for j in 0..p {
4771 beta[j] += 0.05 * sample_standard_normal(&mut init_rng);
4772 }
4773
4774 for iter in 0..n_iter {
4775 eta.assign(&gam_linalg::faer_ndarray::fast_av(&x, &beta));
4776 draw_logit_pg1_omega(
4777 pg_shapes.view(),
4778 eta.view(),
4779 gibbs_pg_seed(config.seed, chain, 0x83F1_56C9_A7E0_2D4B, iter),
4780 &mut omega,
4781 )?;
4782
4783 ndarray::Zip::from(xw.rows_mut())
4784 .and(x.rows())
4785 .and(&omega)
4786 .par_for_each(|mut xw_row, x_row, &omega_i| {
4787 let s = omega_i.sqrt();
4788 for j in 0..p {
4789 xw_row[j] = x_row[j] * s;
4790 }
4791 });
4792 fast_ata_into(&xw, &mut xt_omega_x);
4793
4794 q.assign(&penalty);
4797 q += &xt_omega_x;
4798
4799 let factor = q
4800 .cholesky(Side::Lower)
4801 .map_err(|e| format!("PG Rao-Blackwell failed to factor Q: {:?}", e))?;
4802 mean.assign(&factor.solvevec(&rhs_b));
4805
4806 for j in 0..p {
4808 z[j] = sample_standard_normal(&mut draw_rng);
4809 }
4810 let l = factor.lower_triangular();
4811 back_substitution_lower_transpose_guarded_into(&l, &z, &mut noise);
4812 beta.assign(&(&mean + &noise));
4813
4814 if iter < config.nwarmup {
4815 continue;
4816 }
4817 kept += 1;
4818
4819 for (k, r_k) in penalty_roots.iter().enumerate() {
4820 if r_k.nrows() == 0 {
4821 continue;
4822 }
4823
4824 let rmu = r_k.dot(&mean);
4826 let mu_quad = rmu.dot(&rmu);
4827
4828 let solved_mat = factor.solve_mat(&penalty_roots_t[k]); let solved_t = solved_mat.t();
4833 let mut trace_term = 0.0_f64;
4834 for (&a, &b) in r_k.iter().zip(solved_t.iter()) {
4835 trace_term += a * b;
4836 }
4837
4838 rb_sum[k] += trace_term + mu_quad;
4839 }
4840 }
4841 }
4842
4843 if kept == 0 {
4844 return Err(HmcError::SamplingFailed {
4845 reason: "estimate_logit_pg_rao_blackwell_terms: no retained samples".to_string(),
4846 }
4847 .into());
4848 }
4849 let out = rb_sum.mapv(|v| v / (kept as f64));
4850 if !out.iter().all(|v| v.is_finite()) {
4851 return Err(HmcError::NonFiniteState {
4852 reason: "estimate_logit_pg_rao_blackwell_terms: non-finite expectation".to_string(),
4853 }
4854 .into());
4855 }
4856 Ok(out)
4857}
4858
4859pub(crate) fn run_nuts_sampling(
4872 x: ArrayView2<f64>,
4873 y: ArrayView1<f64>,
4874 weights: ArrayView1<f64>,
4875 penalty_matrix: ArrayView2<f64>,
4876 mode: ArrayView1<f64>,
4877 hessian: ArrayView2<f64>,
4878 likelihood: GlmLikelihoodSpec,
4879 dispersion: gam_solve::model_types::Dispersion,
4880 firth_bias_reduction: bool,
4881 offset: Option<ArrayView1<f64>>,
4882 config: &NutsConfig,
4883) -> Result<NutsResult, String> {
4884 validate_firth_likelihood_support(&likelihood.spec, firth_bias_reduction)
4885 .map_err(String::from)?;
4886 validate_nuts_config(config).map_err(String::from)?;
4887 let dim = mode.len();
4888
4889 let target = NutsPosterior::new(
4892 x,
4893 y,
4894 weights,
4895 penalty_matrix,
4896 mode,
4897 hessian,
4898 likelihood,
4899 dispersion,
4900 offset,
4901 firth_bias_reduction,
4902 )?;
4903
4904 let chol = target.chol().clone();
4906 let mode_arr = target.mode().clone();
4907
4908 let initial_positions = jittered_initial_positions(config, dim, 0.1, 0x0F65_83B2_BC71_4D9E);
4909 let mass_cfg = robust_mass_matrix_config(dim, config.nwarmup);
4910 let (result, run_stats) = run_whitened_nuts_result(
4911 target,
4912 &mode_arr,
4913 &chol,
4914 initial_positions,
4915 config,
4916 dim,
4917 mass_cfg,
4918 0xF1D3_C2B5_A697_804E,
4919 "NUTS sampling failed",
4920 Array1::zeros(dim),
4921 NutsConvergenceThresholds {
4922 max_rhat: 1.1,
4923 min_ess: Some(100.0),
4924 },
4925 )?;
4926 log::info!("NUTS sampling complete: {}", run_stats);
4927
4928 Ok(result)
4929}
4930
4931const RHO_NUTS_INFEASIBLE_LOGP_PENALTY: f64 = 1.0e8;
4937
4938struct WhitenedRhoCriterionTarget<F> {
4954 criterion_and_grad: Mutex<F>,
4956 mode: Array1<f64>,
4958 chol: Array2<f64>,
4960 chol_t: Array2<f64>,
4962 cost_hat: f64,
4964}
4965
4966impl<F> HamiltonianTarget<Array1<f64>> for WhitenedRhoCriterionTarget<F>
4967where
4968 F: FnMut(&Array1<f64>) -> Option<(f64, Array1<f64>)> + Send,
4969{
4970 fn logp_and_grad(&self, position: &Array1<f64>, grad: &mut Array1<f64>) -> f64 {
4971 let rho = &self.mode + &self.chol.dot(position);
4972 let eval = {
4973 let mut criterion = self
4974 .criterion_and_grad
4975 .lock()
4976 .expect("rho-criterion mutex poisoned");
4977 (*criterion)(&rho)
4978 };
4979 match eval {
4980 Some((cost, g))
4981 if cost.is_finite()
4982 && g.len() == position.len()
4983 && g.iter().all(|v| v.is_finite()) =>
4984 {
4985 let grad_z = self.chol_t.dot(&g);
4986 for (gi, &v) in grad.iter_mut().zip(grad_z.iter()) {
4987 *gi = -v;
4988 }
4989 -(cost - self.cost_hat)
4990 }
4991 _ => {
4992 let mut quad = 0.0;
4994 for (gi, &zi) in grad.iter_mut().zip(position.iter()) {
4995 *gi = -zi;
4996 quad += zi * zi;
4997 }
4998 -0.5 * quad - RHO_NUTS_INFEASIBLE_LOGP_PENALTY
4999 }
5000 }
5001 }
5002}
5003
5004pub fn run_rho_criterion_nuts<F>(
5019 rho_hat: ArrayView1<f64>,
5020 outer_hessian: ArrayView2<f64>,
5021 mut criterion_and_grad: F,
5022 config: &NutsConfig,
5023) -> Result<NutsResult, String>
5024where
5025 F: FnMut(&Array1<f64>) -> Option<(f64, Array1<f64>)> + Send,
5026{
5027 validate_nuts_config(config).map_err(String::from)?;
5028 let dim = rho_hat.len();
5029 if dim == 0 {
5030 return Err("rho-posterior NUTS: zero-dimensional rho".to_string());
5031 }
5032 if outer_hessian.nrows() != dim || outer_hessian.ncols() != dim {
5033 return Err(format!(
5034 "rho-posterior NUTS: outer Hessian shape {:?} does not match rho dim {dim}",
5035 outer_hessian.dim()
5036 ));
5037 }
5038
5039 let mode = rho_hat.to_owned();
5040 let whitening = hessian_whitening_transform(
5041 outer_hessian,
5042 dim,
5043 1.0,
5044 "rho-posterior NUTS: outer-Hessian Cholesky failed",
5045 )?;
5046
5047 let cost_hat = match criterion_and_grad(&mode) {
5048 Some((cost, _)) if cost.is_finite() => cost,
5049 _ => {
5050 return Err(
5051 "rho-posterior NUTS: criterion is infeasible at rho_hat itself".to_string(),
5052 );
5053 }
5054 };
5055
5056 let chol = whitening.chol;
5057 let target = WhitenedRhoCriterionTarget {
5058 criterion_and_grad: Mutex::new(criterion_and_grad),
5059 mode: mode.clone(),
5060 chol: chol.clone(),
5061 chol_t: whitening.chol_t,
5062 cost_hat,
5063 };
5064 let initial_positions = jittered_initial_positions(config, dim, 0.1, 0x3D8A_91C4_E27B_5F60);
5065 let mass_cfg = NUTSMassMatrixConfig::disabled();
5070 let (result, run_stats) = run_whitened_nuts_result(
5071 target,
5072 &mode,
5073 &chol,
5074 initial_positions,
5075 config,
5076 dim,
5077 mass_cfg,
5078 0x6B42_E9A1_05D7_C83F,
5079 "rho-posterior NUTS sampling failed",
5080 mode.clone(),
5081 NutsConvergenceThresholds {
5082 max_rhat: 1.1,
5083 min_ess: None,
5084 },
5085 )?;
5086 log::info!("rho-posterior NUTS (#938 tier 2): sampling complete dim={dim} {run_stats}");
5087 Ok(result)
5088}
5089
5090pub struct GlmFlatInputs<'a> {
5092 pub x: ArrayView2<'a, f64>,
5093 pub y: ArrayView1<'a, f64>,
5094 pub weights: ArrayView1<'a, f64>,
5095 pub penalty_matrix: ArrayView2<'a, f64>,
5096 pub mode: ArrayView1<'a, f64>,
5097 pub hessian: ArrayView2<'a, f64>,
5098 pub likelihood_scale: LikelihoodScaleMetadata,
5102 pub dispersion: gam_solve::model_types::Dispersion,
5109 pub firth_bias_reduction: bool,
5110 pub offset: Option<ArrayView1<'a, f64>>,
5115}
5116
5117pub struct SurvivalFlatInputs<'a> {
5119 pub age_entry: ArrayView1<'a, f64>,
5120 pub age_exit: ArrayView1<'a, f64>,
5121 pub event_target: ArrayView1<'a, u8>,
5122 pub event_competing: ArrayView1<'a, u8>,
5123 pub weights: ArrayView1<'a, f64>,
5124 pub x_entry: ArrayView2<'a, f64>,
5125 pub x_exit: ArrayView2<'a, f64>,
5126 pub x_derivative: ArrayView2<'a, f64>,
5127 pub eta_offset_entry: Option<ArrayView1<'a, f64>>,
5128 pub eta_offset_exit: Option<ArrayView1<'a, f64>>,
5129 pub derivative_offset_exit: Option<ArrayView1<'a, f64>>,
5130}
5131
5132pub struct SurvivalNutsInputs<'a> {
5134 pub flat: SurvivalFlatInputs<'a>,
5135 pub penalties: gam_models::survival::PenaltyBlocks,
5136 pub monotonicity: gam_models::survival::SurvivalMonotonicityPenalty,
5137 pub spec: gam_models::survival::SurvivalSpec,
5138 pub structurally_monotonic: bool,
5139 pub structural_time_columns: usize,
5140 pub mode: ArrayView1<'a, f64>,
5141 pub hessian: ArrayView2<'a, f64>,
5142}
5143
5144pub enum FamilyNutsInputs<'a> {
5146 Glm(GlmFlatInputs<'a>),
5147 Survival(Box<SurvivalNutsInputs<'a>>),
5148}
5149
5150pub fn explicit_fit_hessian_for_whitening<'a>(
5159 fit: &'a UnifiedFitResult,
5160 expected_dim: usize,
5161 label: &str,
5162) -> Result<&'a Array2<f64>, String> {
5163 let hessian = fit.penalized_hessian().ok_or_else(|| {
5164 format!(
5165 "{label}: fit result is missing an explicit penalized Hessian for HMC/NUTS whitening"
5166 )
5167 })?;
5168 validate_explicit_dense_hessian_for_whitening(
5169 &format!("{label} penalized Hessian"),
5170 hessian,
5171 expected_dim,
5172 )
5173 .map_err(|err| err.to_string())?;
5174 Ok(hessian)
5175}
5176
5177pub fn run_nuts_sampling_flattened_family(
5179 likelihood: LikelihoodSpec,
5180 inputs: FamilyNutsInputs<'_>,
5181 config: &NutsConfig,
5182) -> Result<NutsResult, String> {
5183 let resolved_glm_likelihood = match &inputs {
5184 FamilyNutsInputs::Glm(glm) => Some(GlmLikelihoodSpec {
5185 spec: likelihood.clone(),
5186 scale: glm.likelihood_scale,
5187 }),
5188 FamilyNutsInputs::Survival(_) => None,
5189 };
5190 if let (Some(resolved), FamilyNutsInputs::Glm(glm)) =
5191 (resolved_glm_likelihood.as_ref(), &inputs)
5192 {
5193 resolve_hmc_likelihood(resolved.clone(), glm.dispersion).map_err(String::from)?;
5196 }
5197 if let FamilyNutsInputs::Glm(glm) = &inputs
5198 && glm.firth_bias_reduction
5199 && !likelihood_spec_supports_firth(&likelihood)
5200 {
5201 return Err(HmcError::FirthUnsupported {
5202 reason: format!(
5203 "NUTS with Firth requires a Binomial inverse link with a Fisher-weight jet; {} does not support it",
5204 likelihood.pretty_name()
5205 ),
5206 }
5207 .into());
5208 }
5209
5210 match (likelihood.response.clone(), likelihood.link.clone(), inputs) {
5211 (
5212 ResponseFamily::Gaussian,
5213 InverseLink::Standard(StandardLink::Identity),
5214 FamilyNutsInputs::Glm(glm),
5215 ) => run_nuts_sampling(
5216 glm.x,
5217 glm.y,
5218 glm.weights,
5219 glm.penalty_matrix,
5220 glm.mode,
5221 glm.hessian,
5222 resolved_glm_likelihood
5223 .clone()
5224 .expect("GLM match arm has resolved likelihood"),
5225 glm.dispersion,
5226 glm.firth_bias_reduction,
5227 glm.offset,
5228 config,
5229 ),
5230 (
5231 ResponseFamily::Binomial,
5232 InverseLink::Standard(StandardLink::Logit),
5233 FamilyNutsInputs::Glm(glm),
5234 ) => {
5235 if !glm.firth_bias_reduction
5242 && glm.offset.is_none()
5243 && glm.weights.iter().all(|w| (*w - 1.0).abs() <= 1e-10)
5244 {
5245 run_logit_polya_gamma_gibbs(
5246 glm.x,
5247 glm.y,
5248 glm.weights,
5249 glm.penalty_matrix,
5250 glm.mode,
5251 config,
5252 )
5253 } else {
5254 run_nuts_sampling(
5255 glm.x,
5256 glm.y,
5257 glm.weights,
5258 glm.penalty_matrix,
5259 glm.mode,
5260 glm.hessian,
5261 resolved_glm_likelihood
5262 .clone()
5263 .expect("GLM match arm has resolved likelihood"),
5264 glm.dispersion,
5265 glm.firth_bias_reduction,
5266 glm.offset,
5267 config,
5268 )
5269 }
5270 }
5271 (
5272 ResponseFamily::Binomial,
5273 InverseLink::Standard(StandardLink::Probit),
5274 FamilyNutsInputs::Glm(glm),
5275 ) => run_nuts_sampling(
5276 glm.x,
5277 glm.y,
5278 glm.weights,
5279 glm.penalty_matrix,
5280 glm.mode,
5281 glm.hessian,
5282 resolved_glm_likelihood
5283 .clone()
5284 .expect("GLM match arm has resolved likelihood"),
5285 glm.dispersion,
5286 glm.firth_bias_reduction,
5287 glm.offset,
5288 config,
5289 ),
5290 (
5291 ResponseFamily::Binomial,
5292 InverseLink::Standard(StandardLink::CLogLog),
5293 FamilyNutsInputs::Glm(glm),
5294 ) => run_nuts_sampling(
5295 glm.x,
5296 glm.y,
5297 glm.weights,
5298 glm.penalty_matrix,
5299 glm.mode,
5300 glm.hessian,
5301 resolved_glm_likelihood
5302 .clone()
5303 .expect("GLM match arm has resolved likelihood"),
5304 glm.dispersion,
5305 glm.firth_bias_reduction,
5306 glm.offset,
5307 config,
5308 ),
5309 (
5310 ResponseFamily::Binomial,
5311 InverseLink::LatentCLogLog(_),
5312 FamilyNutsInputs::Glm(glm),
5313 ) => run_nuts_sampling(
5314 glm.x,
5315 glm.y,
5316 glm.weights,
5317 glm.penalty_matrix,
5318 glm.mode,
5319 glm.hessian,
5320 resolved_glm_likelihood
5321 .clone()
5322 .expect("GLM match arm has resolved likelihood"),
5323 glm.dispersion,
5324 glm.firth_bias_reduction,
5325 glm.offset,
5326 config,
5327 ),
5328 (ResponseFamily::Binomial, InverseLink::Mixture(_), FamilyNutsInputs::Glm(_)) => Err(
5329 "BinomialMixture NUTS is not implemented yet; use fit_gam/predict_gam for blended inverse-link models"
5330 .to_string(),
5331 ),
5332 (ResponseFamily::Binomial, InverseLink::Sas(_), FamilyNutsInputs::Glm(_)) => Err(
5333 "BinomialSas NUTS is not implemented yet; use fit_gam/predict_gam for SAS-link models"
5334 .to_string(),
5335 ),
5336 (ResponseFamily::Binomial, InverseLink::BetaLogistic(_), FamilyNutsInputs::Glm(_)) => Err(
5337 "BinomialBetaLogistic NUTS is not implemented yet; use fit_gam/predict_gam for beta-logistic-link models"
5338 .to_string(),
5339 ),
5340 (ResponseFamily::Binomial, InverseLink::Standard(_), FamilyNutsInputs::Glm(_)) => Err(
5341 "NUTS sampling is not implemented for this binomial inverse link".to_string(),
5342 ),
5343 (ResponseFamily::RoystonParmar, _, FamilyNutsInputs::Survival(survival)) => {
5344 survival_hmc::run_survival_nuts_sampling(
5345 survival.flat.age_entry,
5346 survival.flat.age_exit,
5347 survival.flat.event_target,
5348 survival.flat.event_competing,
5349 survival.flat.weights,
5350 survival.flat.x_entry,
5351 survival.flat.x_exit,
5352 survival.flat.x_derivative,
5353 survival.flat.eta_offset_entry,
5354 survival.flat.eta_offset_exit,
5355 survival.flat.derivative_offset_exit,
5356 survival.penalties,
5357 survival.monotonicity,
5358 survival.spec,
5359 survival.structurally_monotonic,
5360 survival.structural_time_columns,
5361 survival.mode,
5362 survival.hessian,
5363 config,
5364 )
5365 }
5366 (ResponseFamily::RoystonParmar, _, FamilyNutsInputs::Glm(_)) => Err(
5367 "RoystonParmar family requires FamilyNutsInputs::Survival flattened inputs".to_string(),
5368 ),
5369 (_, _, FamilyNutsInputs::Survival(_)) => Err(
5370 "Survival flattened inputs are only valid for the Royston-Parmar response family"
5371 .to_string(),
5372 ),
5373 (ResponseFamily::Poisson, _, FamilyNutsInputs::Glm(glm)) => run_nuts_sampling(
5374 glm.x,
5375 glm.y,
5376 glm.weights,
5377 glm.penalty_matrix,
5378 glm.mode,
5379 glm.hessian,
5380 resolved_glm_likelihood
5381 .clone()
5382 .expect("GLM match arm has resolved likelihood"),
5383 glm.dispersion,
5384 glm.firth_bias_reduction,
5385 glm.offset,
5386 config,
5387 ),
5388 (ResponseFamily::Tweedie { p }, _, FamilyNutsInputs::Glm(glm)) => {
5389 if !is_valid_tweedie_power(p) {
5392 return Err(format!(
5393 "Tweedie variance power must be finite and strictly between 1 and 2; got {p}"
5394 ));
5395 }
5396 run_nuts_sampling(
5397 glm.x,
5398 glm.y,
5399 glm.weights,
5400 glm.penalty_matrix,
5401 glm.mode,
5402 glm.hessian,
5403 resolved_glm_likelihood
5404 .clone()
5405 .expect("GLM match arm has resolved likelihood"),
5406 glm.dispersion,
5407 glm.firth_bias_reduction,
5408 glm.offset,
5409 config,
5410 )
5411 }
5412 (ResponseFamily::NegativeBinomial { .. }, _, FamilyNutsInputs::Glm(glm)) => {
5413 run_nuts_sampling(
5416 glm.x,
5417 glm.y,
5418 glm.weights,
5419 glm.penalty_matrix,
5420 glm.mode,
5421 glm.hessian,
5422 resolved_glm_likelihood
5423 .clone()
5424 .expect("GLM match arm has resolved likelihood"),
5425 glm.dispersion,
5426 glm.firth_bias_reduction,
5427 glm.offset,
5428 config,
5429 )
5430 }
5431 (
5432 ResponseFamily::Beta { .. },
5433 InverseLink::Standard(StandardLink::Logit),
5434 FamilyNutsInputs::Glm(glm),
5435 ) => run_nuts_sampling(
5436 glm.x,
5437 glm.y,
5438 glm.weights,
5439 glm.penalty_matrix,
5440 glm.mode,
5441 glm.hessian,
5442 resolved_glm_likelihood
5443 .clone()
5444 .expect("GLM match arm has resolved likelihood"),
5445 glm.dispersion,
5446 glm.firth_bias_reduction,
5447 glm.offset,
5448 config,
5449 ),
5450 (ResponseFamily::Beta { .. }, _, FamilyNutsInputs::Glm(_)) => {
5451 Err("beta-regression NUTS requires the logit inverse link".to_string())
5452 }
5453 (ResponseFamily::Gamma, _, FamilyNutsInputs::Glm(glm)) => run_nuts_sampling(
5454 glm.x,
5455 glm.y,
5456 glm.weights,
5457 glm.penalty_matrix,
5458 glm.mode,
5459 glm.hessian,
5460 resolved_glm_likelihood
5461 .clone()
5462 .expect("GLM match arm has resolved likelihood"),
5463 glm.dispersion,
5464 glm.firth_bias_reduction,
5465 glm.offset,
5466 config,
5467 ),
5468 (ResponseFamily::Gaussian, _, FamilyNutsInputs::Glm(_)) => Err(
5469 "NUTS sampling is only implemented for Gaussian with identity link".to_string(),
5470 ),
5471 }
5472}
5473
5474pub fn laplace_directional_cubic_diagnostic(
5517 hessian: &Array2<f64>,
5518 design: &DesignMatrix,
5519 c_weights: &Array1<f64>,
5520 refine_supremum: bool,
5521) -> Result<(f64, Array1<f64>), String> {
5522 let p = hessian.nrows();
5523 if p == 0 || hessian.ncols() != p {
5524 return Ok((0.0, Array1::zeros(0)));
5525 }
5526
5527 let sym_h = (hessian + &hessian.t()) * 0.5;
5528 let (evals, evecs) = sym_h
5529 .eigh(Side::Lower)
5530 .map_err(|e| format!("directional cubic diagnostic eigendecomposition failed: {e}"))?;
5531 let max_eval = evals.iter().fold(0.0_f64, |acc, &ev| acc.max(ev.abs()));
5532 let tol = (max_eval * 1.0e-12).max(1.0e-14);
5533 let mut directional = Array1::<f64>::zeros(p);
5534 let mut max_abs = 0.0_f64;
5535
5536 let positive: Vec<usize> = (0..p).filter(|&r| evals[r] > tol).collect();
5550 if !positive.is_empty() {
5551 let mut directions = Array2::<f64>::zeros((p, positive.len()));
5552 for (slot, &r) in positive.iter().enumerate() {
5553 directions.column_mut(slot).assign(&evecs.column(r));
5554 }
5555 let cubics = directional_cubic_contractions(design, c_weights, &directions.view());
5556 for (slot, &r) in positive.iter().enumerate() {
5557 let gamma = cubics[slot] / evals[r].powf(1.5);
5558 directional[r] = if gamma.is_finite() { gamma } else { 0.0 };
5559 max_abs = max_abs.max(directional[r].abs());
5560 }
5561 }
5562
5563 if refine_supremum && p >= 2 {
5575 let positive_mask: Vec<bool> = evals.iter().map(|&ev| ev > tol).collect();
5579 let n_pos = positive_mask.iter().filter(|&&m| m).count();
5580 if n_pos >= 2 {
5581 let max_abs_from_probes = cubic_power_iteration_refinement(
5582 design,
5583 c_weights,
5584 &evals,
5585 &evecs,
5586 &positive_mask,
5587 n_pos,
5588 );
5589 if max_abs_from_probes > max_abs {
5590 max_abs = max_abs_from_probes;
5591 }
5592 }
5593 }
5594
5595 Ok((max_abs, directional))
5596}
5597
5598const CUBIC_PANEL_DOUBLES: usize = 1 << 21;
5602
5603fn directional_cubic_contractions(
5617 design: &DesignMatrix,
5618 c_weights: &Array1<f64>,
5619 directions: &ArrayView2<f64>,
5620) -> Array1<f64> {
5621 let k = directions.ncols();
5622 let mut cubics = Array1::<f64>::zeros(k);
5623 if k == 0 {
5624 return cubics;
5625 }
5626 match design.as_sparse() {
5627 Some(x_sparse) => {
5628 let (symbolic, values) = x_sparse.as_ref().parts();
5632 let col_ptr = symbolic.col_ptr();
5633 let row_idx = symbolic.row_idx();
5634 let rows = x_sparse.nrows().min(c_weights.len());
5635 if rows == 0 {
5636 return cubics;
5637 }
5638 let panel = (CUBIC_PANEL_DOUBLES / k).clamp(1, rows);
5639 let mut start = 0;
5640 while start < rows {
5641 let stop = (start + panel).min(rows);
5642 let mut projections = Array2::<f64>::zeros((stop - start, k));
5643 for col in 0..x_sparse.ncols() {
5644 let coeffs = directions.row(col);
5645 for ptr in col_ptr[col]..col_ptr[col + 1] {
5646 let row = row_idx[ptr];
5647 if row < start || row >= stop {
5648 continue;
5649 }
5650 let value = values[ptr];
5651 let mut target = projections.row_mut(row - start);
5652 for r in 0..k {
5653 target[r] += value * coeffs[r];
5654 }
5655 }
5656 }
5657 for (offset, i) in (start..stop).enumerate() {
5658 let weight = c_weights[i];
5659 let row = projections.row(offset);
5660 for r in 0..k {
5661 cubics[r] += weight * row[r].powi(3);
5662 }
5663 }
5664 start = stop;
5665 }
5666 }
5667 None => {
5668 let x_dense = design.to_dense_cow();
5669 let x_dense = x_dense.as_ref();
5670 let rows = x_dense.nrows().min(c_weights.len());
5671 if rows == 0 {
5672 return cubics;
5673 }
5674 let panel = (CUBIC_PANEL_DOUBLES / k).clamp(1, rows);
5675 let mut start = 0;
5676 while start < rows {
5677 let stop = (start + panel).min(rows);
5678 let projections = fast_ab(&x_dense.slice(s![start..stop, ..]), directions);
5679 for (offset, i) in (start..stop).enumerate() {
5680 let weight = c_weights[i];
5681 let row = projections.row(offset);
5682 for r in 0..k {
5683 cubics[r] += weight * row[r].powi(3);
5684 }
5685 }
5686 start = stop;
5687 }
5688 }
5689 }
5690 for value in cubics.iter_mut() {
5691 if !value.is_finite() {
5692 *value = 0.0;
5693 }
5694 }
5695 cubics
5696}
5697
5698fn directional_cubic_contraction(
5700 design: &DesignMatrix,
5701 c_weights: &Array1<f64>,
5702 v: &ArrayView1<f64>,
5703) -> f64 {
5704 match design.as_sparse() {
5705 Some(x_sparse) => {
5706 let (symbolic, values) = x_sparse.as_ref().parts();
5707 let col_ptr = symbolic.col_ptr();
5708 let row_idx = symbolic.row_idx();
5709 let mut row_scores = vec![0.0_f64; x_sparse.nrows()];
5710 for col in 0..x_sparse.ncols() {
5711 let coeff = v[col];
5712 for ptr in col_ptr[col]..col_ptr[col + 1] {
5713 row_scores[row_idx[ptr]] += values[ptr] * coeff;
5714 }
5715 }
5716 let mut cubic = 0.0_f64;
5717 for i in 0..row_scores.len().min(c_weights.len()) {
5718 cubic += c_weights[i] * row_scores[i].powi(3);
5719 }
5720 cubic
5721 }
5722 None => {
5723 let x_dense = design.to_dense_cow();
5724 let x_dense = x_dense.as_ref();
5725 let rows = x_dense.nrows().min(c_weights.len());
5726 if rows == 0 {
5727 return 0.0;
5728 }
5729 let projections = fast_av(&x_dense.slice(s![..rows, ..]), v);
5738 let mut cubic = 0.0_f64;
5739 for i in 0..rows {
5740 cubic += c_weights[i] * projections[i].powi(3);
5741 }
5742 cubic
5743 }
5744 }
5745}
5746
5747fn directional_cubic_gradient(
5750 design: &DesignMatrix,
5751 c_weights: &Array1<f64>,
5752 v: &Array1<f64>,
5753) -> Array1<f64> {
5754 let p = v.len();
5755 match design.as_sparse() {
5756 Some(x_sparse) => {
5757 let (symbolic, values) = x_sparse.as_ref().parts();
5758 let col_ptr = symbolic.col_ptr();
5759 let row_idx = symbolic.row_idx();
5760 let n = x_sparse.nrows();
5761 let mut row_scores = vec![0.0_f64; n];
5762 for col in 0..x_sparse.ncols() {
5763 let coeff = v[col];
5764 for ptr in col_ptr[col]..col_ptr[col + 1] {
5765 row_scores[row_idx[ptr]] += values[ptr] * coeff;
5766 }
5767 }
5768 let mut quad_weights = vec![0.0_f64; n];
5770 for i in 0..n.min(c_weights.len()) {
5771 quad_weights[i] = 3.0 * c_weights[i] * row_scores[i] * row_scores[i];
5772 }
5773 let mut grad = Array1::<f64>::zeros(p);
5775 for col in 0..x_sparse.ncols() {
5776 let mut acc = 0.0_f64;
5777 for ptr in col_ptr[col]..col_ptr[col + 1] {
5778 acc += values[ptr] * quad_weights[row_idx[ptr]];
5779 }
5780 grad[col] = acc;
5781 }
5782 grad
5783 }
5784 None => {
5785 let x_dense = design.to_dense_cow();
5786 let x_dense = x_dense.as_ref();
5787 let rows = x_dense.nrows().min(c_weights.len());
5788 if rows == 0 {
5789 return Array1::<f64>::zeros(p);
5790 }
5791 let x_rows = x_dense.slice(s![..rows, ..]);
5799 let projections = fast_av(&x_rows, v);
5800 let mut quad_weights = Array1::<f64>::zeros(rows);
5801 for i in 0..rows {
5802 quad_weights[i] = 3.0 * c_weights[i] * projections[i] * projections[i];
5803 }
5804 fast_atv(&x_rows, &quad_weights)
5805 }
5806 }
5807}
5808
5809fn cubic_power_iteration_refinement(
5815 design: &DesignMatrix,
5816 c_weights: &Array1<f64>,
5817 evals: &Array1<f64>,
5818 evecs: &Array2<f64>,
5819 positive_mask: &[bool],
5820 n_pos: usize,
5821) -> f64 {
5822 let p = evals.len();
5823 let max_probes = 8;
5824 let max_iters = 5;
5825
5826 let to_original = |u: &Array1<f64>| -> Array1<f64> {
5829 let mut v = Array1::<f64>::zeros(p);
5830 let mut idx = 0;
5831 for r in 0..p {
5832 if positive_mask[r] {
5833 let scale = u[idx] / evals[r].sqrt();
5834 let col = evecs.column(r);
5835 for j in 0..p {
5836 v[j] += scale * col[j];
5837 }
5838 idx += 1;
5839 }
5840 }
5841 v
5842 };
5843
5844 let to_whitened = |g: &Array1<f64>| -> Array1<f64> {
5846 let mut u = Array1::<f64>::zeros(n_pos);
5847 let mut idx = 0;
5848 for r in 0..p {
5849 if positive_mask[r] {
5850 u[idx] = evals[r].sqrt() * evecs.column(r).dot(g);
5851 idx += 1;
5852 }
5853 }
5854 u
5855 };
5856
5857 let eval_gamma = |u: &Array1<f64>| -> f64 {
5859 let norm = u.dot(u).sqrt();
5860 if norm < 1e-30 {
5861 return 0.0;
5862 }
5863 let u_normed: Array1<f64> = u / norm;
5864 let v = to_original(&u_normed);
5865 let cubic = directional_cubic_contraction(design, c_weights, &v.view());
5867 if cubic.is_finite() { cubic.abs() } else { 0.0 }
5868 };
5869
5870 let refine_step = |u: &Array1<f64>| -> Array1<f64> {
5872 let norm = u.dot(u).sqrt();
5873 if norm < 1e-30 {
5874 return u.clone();
5875 }
5876 let u_normed: Array1<f64> = u / norm;
5877 let v = to_original(&u_normed);
5878 let grad_v = directional_cubic_gradient(design, c_weights, &v);
5880 let mut grad_u = to_whitened(&grad_v);
5882 let dot = grad_u.dot(&u_normed);
5884 grad_u.scaled_add(-dot, &u_normed);
5885 let cubic_val = directional_cubic_contraction(design, c_weights, &v.view());
5887 let sign = if cubic_val >= 0.0 { 1.0 } else { -1.0 };
5888 let step_size = 0.3;
5889 let mut u_new = &u_normed + &(&grad_u * (sign * step_size));
5890 let new_norm = u_new.dot(&u_new).sqrt();
5891 if new_norm > 1e-30 {
5892 u_new /= new_norm;
5893 }
5894 u_new
5895 };
5896
5897 let mut best = 0.0_f64;
5898
5899 let mut seeds: Vec<Array1<f64>> = Vec::with_capacity(max_probes);
5905
5906 let mut best_eig_idx = 0;
5909 let mut best_eig_gamma = 0.0_f64;
5910 for j in 0..n_pos {
5911 let mut u = Array1::<f64>::zeros(n_pos);
5912 u[j] = 1.0;
5913 let g = eval_gamma(&u);
5914 if g > best_eig_gamma {
5915 best_eig_gamma = g;
5916 best_eig_idx = j;
5917 }
5918 }
5919 best = best.max(best_eig_gamma);
5920 let mut u_best = Array1::<f64>::zeros(n_pos);
5921 u_best[best_eig_idx] = 1.0;
5922 seeds.push(u_best);
5923
5924 let n_top = n_pos.min(4);
5926 for i in 0..n_top {
5927 for j in (i + 1)..n_top {
5928 if seeds.len() >= max_probes {
5929 break;
5930 }
5931 let inv_sqrt2 = std::f64::consts::FRAC_1_SQRT_2;
5932 let mut u_plus = Array1::<f64>::zeros(n_pos);
5933 u_plus[i] = inv_sqrt2;
5934 u_plus[j] = inv_sqrt2;
5935 seeds.push(u_plus);
5936 if seeds.len() < max_probes {
5937 let mut u_minus = Array1::<f64>::zeros(n_pos);
5938 u_minus[i] = inv_sqrt2;
5939 u_minus[j] = -inv_sqrt2;
5940 seeds.push(u_minus);
5941 }
5942 }
5943 }
5944
5945 for seed in &seeds {
5947 let mut u = seed.clone();
5948 for _ in 0..max_iters {
5949 u = refine_step(&u);
5950 }
5951 let g = eval_gamma(&u);
5952 best = best.max(g);
5953 }
5954
5955 best
5956}
5957
5958pub use gam_problem::laplace_sampler_contract::{
5968 BLOCK_GH_MAX_DIM, BlockExcessTarget, BlockQuadratureMarginal, BlockQuadratureMoments,
5969 LaplaceTrustworthiness, laplace_skewness_threshold,
5970 laplace_trustworthiness_from_skewness,
5971};
5972
5973pub struct HmcIoLaplaceMarginalCorrector;
5978
5979impl gam_problem::laplace_sampler_contract::LaplaceMarginalCorrector
5980 for HmcIoLaplaceMarginalCorrector
5981{
5982 fn directional_cubic_diagnostic(
5983 &self,
5984 hessian: &Array2<f64>,
5985 design: &DesignMatrix,
5986 c_weights: &Array1<f64>,
5987 refine_supremum: bool,
5988 ) -> Result<(f64, Array1<f64>), String> {
5989 laplace_directional_cubic_diagnostic(hessian, design, c_weights, refine_supremum)
5990 }
5991
5992 fn block_quadrature_marginal_correction(
5993 &self,
5994 target: &dyn BlockExcessTarget,
5995 ) -> Result<BlockQuadratureMarginal, String> {
5996 block_quadrature_marginal_correction(target)
5997 }
5998}
5999
6000pub fn block_quadrature_marginal_correction<T: BlockExcessTarget + ?Sized>(
6028 target: &T,
6029) -> Result<BlockQuadratureMarginal, String> {
6030 let m = target.block_dim();
6031 let k = target.rho_dim();
6032 if m == 0 {
6033 return Ok(BlockQuadratureMarginal {
6034 value: 0.0,
6035 rho_gradient: Array1::zeros(k),
6036 quadrature_error: 0.0,
6037 node_count: 0,
6038 moments: None,
6039 });
6040 }
6041 let lambdas = target.block_curvatures();
6042 if lambdas.len() != m {
6043 return Err(format!(
6044 "block_quadrature_marginal_correction: block_curvatures len {} != block_dim {m}",
6045 lambdas.len()
6046 ));
6047 }
6048 let inv_sqrt_lambda: Array1<f64> = lambdas.mapv(|l| {
6049 if l > 0.0 {
6050 1.0 / l.sqrt()
6051 } else {
6052 f64::NAN
6056 }
6057 });
6058 if inv_sqrt_lambda.iter().any(|v| !v.is_finite()) {
6059 return Err(
6060 "block_quadrature_marginal_correction: non-positive block curvature (mode is not a \
6061 strict local minimum in an integrated direction)"
6062 .to_string(),
6063 );
6064 }
6065 if m > BLOCK_GH_MAX_DIM {
6066 return Err(format!(
6067 "block-local Gauss-Hermite correction supports at most {BLOCK_GH_MAX_DIM} \
6068 curvature-heavy directions, got {m}"
6069 ));
6070 }
6071
6072 let fine_rule = crate::rho_posterior::standard_normal_gh_rule(5)
6073 .expect("the five-node standard-normal Gauss-Hermite rule is built in");
6074 let mut fine_nodes = Vec::new();
6075 crate::rho_posterior::enumerate_gh_product(
6076 m,
6077 fine_rule,
6078 0,
6079 &mut Array1::zeros(m),
6080 0.0,
6081 &mut fine_nodes,
6082 );
6083 let node_count = fine_nodes.len();
6084
6085 let n_obs = target.base_neg_score()?.len();
6095 let mut max_lw = f64::NEG_INFINITY;
6096 let mut sum_w = 0.0_f64;
6097 let mut grad_acc = Array1::<f64>::zeros(k);
6098 let mut e_t_acc = Array1::<f64>::zeros(m);
6099 let mut e_tt_acc = Array2::<f64>::zeros((m, m));
6100 let mut e_ngs_acc = Array1::<f64>::zeros(n_obs);
6101 let mut e_t_ngs_acc = Array2::<f64>::zeros((n_obs, m));
6102
6103 let mut draws = Array2::<f64>::zeros((m, node_count));
6108 for (s, (z, _)) in fine_nodes.iter().enumerate() {
6109 for r in 0..m {
6110 draws[(r, s)] = z[r] * inv_sqrt_lambda[r];
6111 }
6112 }
6113 let batched = target.excess_with_displaced_neg_score_batch(&draws);
6114
6115 let mut t = Array1::<f64>::zeros(m);
6116 for (sidx, (excess, displaced_ngs)) in batched.into_iter().enumerate() {
6117 t.assign(&draws.column(sidx));
6118 if !excess.is_finite() {
6119 continue;
6120 }
6121 let Some(ngs) = displaced_ngs else {
6122 continue;
6124 };
6125 let lw = fine_nodes[sidx].1 - excess;
6126 if lw > max_lw {
6127 let rescale = (max_lw - lw).exp();
6130 sum_w *= rescale;
6131 grad_acc *= rescale;
6132 e_t_acc *= rescale;
6133 e_tt_acc *= rescale;
6134 e_ngs_acc *= rescale;
6135 e_t_ngs_acc *= rescale;
6136 max_lw = lw;
6137 }
6138 let w = (lw - max_lw).exp();
6139 sum_w += w;
6140 grad_acc.scaled_add(-w, &target.excess_rho_gradient(&t));
6142 if ngs.len() != n_obs {
6144 return Err(format!(
6145 "block_quadrature_marginal_correction: displaced_neg_score len {} != {n_obs}",
6146 ngs.len()
6147 ));
6148 }
6149 e_t_acc.scaled_add(w, &t);
6150 e_ngs_acc.scaled_add(w, &ngs);
6151 for r in 0..m {
6152 let wt_r = w * t[r];
6153 for q in 0..m {
6154 e_tt_acc[(q, r)] += wt_r * t[q];
6155 }
6156 e_t_ngs_acc.column_mut(r).scaled_add(wt_r, &ngs);
6157 }
6158 }
6159 if !max_lw.is_finite() {
6160 return Err(
6161 "block_quadrature_marginal_correction: all fine quadrature nodes were infeasible"
6162 .to_string(),
6163 );
6164 }
6165 let value = max_lw + sum_w.ln();
6166 let (rho_gradient, moments) = if sum_w > 0.0 {
6168 (
6169 grad_acc / sum_w,
6170 Some(BlockQuadratureMoments {
6171 e_t: e_t_acc / sum_w,
6172 e_tt: e_tt_acc / sum_w,
6173 e_neg_score: e_ngs_acc / sum_w,
6174 e_t_neg_score: e_t_ngs_acc / sum_w,
6175 }),
6176 )
6177 } else {
6178 (Array1::zeros(k), None)
6179 };
6180 let coarse_rule = crate::rho_posterior::standard_normal_gh_rule(3)
6184 .expect("the three-node standard-normal Gauss-Hermite rule is built in");
6185 let mut coarse_nodes = Vec::new();
6186 crate::rho_posterior::enumerate_gh_product(
6187 m,
6188 coarse_rule,
6189 0,
6190 &mut Array1::zeros(m),
6191 0.0,
6192 &mut coarse_nodes,
6193 );
6194 let mut coarse_draws = Array2::<f64>::zeros((m, coarse_nodes.len()));
6195 for (s, (z, _)) in coarse_nodes.iter().enumerate() {
6196 for r in 0..m {
6197 coarse_draws[(r, s)] = z[r] * inv_sqrt_lambda[r];
6198 }
6199 }
6200 let coarse_values = target.excess_batch(&coarse_draws);
6201 let coarse_max = coarse_values
6202 .iter()
6203 .enumerate()
6204 .filter_map(|(idx, excess)| {
6205 excess.is_finite().then_some(coarse_nodes[idx].1 - excess)
6206 })
6207 .fold(f64::NEG_INFINITY, f64::max);
6208 if !coarse_max.is_finite() {
6209 return Err(
6210 "block_quadrature_marginal_correction: every coarse quadrature node was infeasible"
6211 .to_string(),
6212 );
6213 }
6214 let coarse_sum = coarse_values
6215 .iter()
6216 .enumerate()
6217 .filter_map(|(idx, excess)| {
6218 excess
6219 .is_finite()
6220 .then_some((coarse_nodes[idx].1 - excess - coarse_max).exp())
6221 })
6222 .sum::<f64>();
6223 let coarse_value = coarse_max + coarse_sum.ln();
6224 let quadrature_error = (value - coarse_value).abs();
6225
6226 if !value.is_finite() || rho_gradient.iter().any(|v| !v.is_finite()) {
6227 return Err(
6228 "block_quadrature_marginal_correction: produced a non-finite correction or gradient"
6229 .to_string(),
6230 );
6231 }
6232 if let Some(mo) = moments.as_ref()
6233 && (mo.e_t.iter().any(|v| !v.is_finite())
6234 || mo.e_tt.iter().any(|v| !v.is_finite())
6235 || mo.e_neg_score.iter().any(|v| !v.is_finite())
6236 || mo.e_t_neg_score.iter().any(|v| !v.is_finite()))
6237 {
6238 return Err(
6239 "block_quadrature_marginal_correction: produced non-finite gradient-channel moments"
6240 .to_string(),
6241 );
6242 }
6243
6244 Ok(BlockQuadratureMarginal {
6245 value,
6246 rho_gradient,
6247 quadrature_error,
6248 node_count,
6249 moments,
6250 })
6251}
6252
6253#[derive(Clone, Debug)]
6255pub struct JointBetaRhoResult {
6256 pub beta_samples: Array2<f64>,
6258 pub rho_samples: Array2<f64>,
6260 pub beta_mean: Array1<f64>,
6262 pub link_param_samples: Array2<f64>,
6264 pub link_param_mean: Array1<f64>,
6266 pub rho_mean: Array1<f64>,
6268 pub rhat: f64,
6270 pub ess: f64,
6272 pub converged: bool,
6274 pub trigger_skewness: f64,
6276}
6277
6278struct JointBetaRhoPosterior {
6287 data: SharedData,
6288 chol: Array2<f64>,
6290 chol_t: Array2<f64>,
6292 likelihood: LikelihoodSpec,
6294 penalty_scale: f64,
6298 n_beta: usize,
6300 n_rho: usize,
6302 n_link_params: usize,
6304 link_param_mode: Array1<f64>,
6306 penalty_canonical: Vec<gam_terms::construction::CanonicalPenalty>,
6308 rho_prior: RhoPrior,
6310 rho_mode: Array1<f64>,
6312 firth_enabled: bool,
6315 penalty_logdet_cache: Mutex<Option<(u64, f64, Array1<f64>)>>,
6323}
6324
6325impl JointBetaRhoPosterior {
6326 fn new(
6327 x: ArrayView2<f64>,
6328 y: ArrayView1<f64>,
6329 weights: ArrayView1<f64>,
6330 mode: ArrayView1<f64>,
6331 hessian: ArrayView2<f64>,
6332 penalty_canonical: Vec<gam_terms::construction::CanonicalPenalty>,
6333 rho_mode: ArrayView1<f64>,
6334 likelihood: GlmLikelihoodSpec,
6335 dispersion: gam_solve::model_types::Dispersion,
6336 offset: Option<ArrayView1<f64>>,
6337 rho_prior: RhoPrior,
6338 firth_enabled: bool,
6339 ) -> Result<Self, String> {
6340 let n_samples = x.nrows();
6341 let n_beta = x.ncols();
6342 let n_rho = penalty_canonical.len();
6343
6344 if y.len() != n_samples
6345 || weights.len() != n_samples
6346 || mode.len() != n_beta
6347 || hessian.dim() != (n_beta, n_beta)
6348 {
6349 return Err(HmcError::DimensionMismatch {
6350 reason: format!(
6351 "Joint HMC geometry mismatch: X={}x{}, y={}, weights={}, mode={}, hessian={:?}",
6352 n_samples,
6353 n_beta,
6354 y.len(),
6355 weights.len(),
6356 mode.len(),
6357 hessian.dim(),
6358 ),
6359 }
6360 .into());
6361 }
6362 for (label, invalid) in [
6363 ("design", first_non_finite(x.iter())),
6364 ("mode", first_non_finite(mode.iter())),
6365 ("hessian", first_non_finite(hessian.iter())),
6366 ] {
6367 if let Some((index, value)) = invalid {
6368 return Err(HmcError::NonFiniteState {
6369 reason: format!(
6370 "Joint HMC {label} has non-finite value {value} at flat index {index}"
6371 ),
6372 }
6373 .into());
6374 }
6375 }
6376 if let Some((row, weight)) = weights
6377 .iter()
6378 .copied()
6379 .enumerate()
6380 .find(|(_, weight)| !(weight.is_finite() && *weight >= 0.0))
6381 {
6382 return Err(HmcError::InvalidConfig {
6383 reason: format!(
6384 "Joint HMC weight at row {row} must be finite and non-negative, got {weight}"
6385 ),
6386 }
6387 .into());
6388 }
6389 for (index, penalty) in penalty_canonical.iter().enumerate() {
6390 if penalty.total_dim != n_beta
6391 || penalty.col_range.end > n_beta
6392 || penalty.col_range.start > penalty.col_range.end
6393 || penalty.root.ncols() != penalty.col_range.len()
6394 {
6395 return Err(HmcError::DimensionMismatch {
6396 reason: format!(
6397 "Joint HMC penalty {index} has total_dim={}, range={:?}, root={:?}, expected total dimension {n_beta}",
6398 penalty.total_dim,
6399 penalty.col_range,
6400 penalty.root.dim(),
6401 ),
6402 }
6403 .into());
6404 }
6405 if let Some((flat_index, value)) = first_non_finite(penalty.root.iter()) {
6406 return Err(HmcError::NonFiniteState {
6407 reason: format!(
6408 "Joint HMC penalty {index} root has non-finite value {value} at flat index {flat_index}"
6409 ),
6410 }
6411 .into());
6412 }
6413 }
6414
6415 if let Some(offset) = offset.as_ref() {
6416 if offset.len() != n_samples {
6417 return Err(HmcError::DimensionMismatch {
6418 reason: format!(
6419 "Joint HMC offset length {} does not match {} observations",
6420 offset.len(),
6421 n_samples
6422 ),
6423 }
6424 .into());
6425 }
6426 if !offset.iter().all(|v| v.is_finite()) {
6427 return Err(HmcError::NonFiniteState {
6428 reason: "Joint HMC offset contains NaN or Inf values".to_string(),
6429 }
6430 .into());
6431 }
6432 }
6433
6434 if rho_mode.len() != n_rho {
6435 return Err(HmcError::DimensionMismatch {
6436 reason: format!(
6437 "rho_mode length {} != penalty count {}",
6438 rho_mode.len(),
6439 n_rho
6440 ),
6441 }
6442 .into());
6443 }
6444
6445 match (&likelihood.spec.response, &likelihood.spec.link) {
6446 (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::Logit)) => {}
6447 (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::Probit)) => {}
6448 (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::CLogLog)) => {}
6449 (ResponseFamily::Binomial, InverseLink::LatentCLogLog(_)) => {}
6450 (ResponseFamily::Binomial, InverseLink::Sas(_)) => {}
6451 (ResponseFamily::Binomial, InverseLink::BetaLogistic(_)) => {}
6452 (ResponseFamily::Binomial, InverseLink::Mixture(_)) => {}
6453 (ResponseFamily::Binomial, InverseLink::Standard(other)) => {
6454 return Err(HmcError::LinkMismatch {
6455 reason: format!(
6456 "Joint HMC binomial response requires a binomial-compatible inverse link; got {:?}",
6457 other
6458 ),
6459 }
6460 .into());
6461 }
6462 (ResponseFamily::Gaussian, InverseLink::Standard(StandardLink::Identity)) => {}
6463 (ResponseFamily::Gaussian, _) => {
6464 return Err(HmcError::LinkMismatch {
6465 reason: "Joint HMC Gaussian requires an identity inverse link".to_string(),
6466 }
6467 .into());
6468 }
6469 (
6470 ResponseFamily::Poisson
6471 | ResponseFamily::Tweedie { .. }
6472 | ResponseFamily::NegativeBinomial { .. }
6473 | ResponseFamily::Gamma,
6474 InverseLink::Standard(StandardLink::Log),
6475 ) => {}
6476 (
6477 ResponseFamily::Poisson
6478 | ResponseFamily::Tweedie { .. }
6479 | ResponseFamily::NegativeBinomial { .. }
6480 | ResponseFamily::Gamma,
6481 _,
6482 ) => {
6483 return Err(HmcError::LinkMismatch {
6484 reason: "Joint HMC log-link family requires a log inverse link".to_string(),
6485 }
6486 .into());
6487 }
6488 (ResponseFamily::Beta { .. }, InverseLink::Standard(StandardLink::Logit)) => {}
6489 (ResponseFamily::Beta { .. }, _) => {
6490 return Err(HmcError::LinkMismatch {
6491 reason: "Joint HMC Beta requires a logit inverse link".to_string(),
6492 }
6493 .into());
6494 }
6495 (ResponseFamily::RoystonParmar, _) => {
6496 return Err(HmcError::UnsupportedFamily {
6497 reason: "Joint HMC fallback is not implemented for RoystonParmar".to_string(),
6498 }
6499 .into());
6500 }
6501 }
6502
6503 validate_firth_likelihood_support(&likelihood.spec, firth_enabled).map_err(String::from)?;
6504 if matches!(
6505 likelihood.spec.response,
6506 ResponseFamily::NegativeBinomial { .. }
6507 ) {
6508 validate_count_responses("negative-binomial joint HMC", &y, &weights)
6509 .map_err(String::from)?;
6510 }
6511 if likelihood.spec.is_binomial() {
6512 validate_binary_responses("binomial joint HMC", &y, &weights).map_err(String::from)?;
6513 }
6514 let (likelihood, cov_scale) =
6515 resolve_hmc_likelihood(likelihood, dispersion).map_err(String::from)?;
6516 let mut eta_at_mode = x.dot(&mode);
6517 if let Some(offset) = offset.as_ref() {
6518 eta_at_mode += offset;
6519 }
6520 let mut score_at_mode = Array1::zeros(n_samples);
6521 gam_solve::pirls::eta_log_likelihood_value_and_score_into(
6522 y,
6523 &eta_at_mode,
6524 &likelihood,
6525 &likelihood.spec.link,
6526 weights,
6527 &mut score_at_mode,
6528 )
6529 .map_err(|error| format!("joint HMC likelihood is invalid at the fitted mode: {error}"))?;
6530
6531 let whitening = hessian_whitening_transform(
6532 hessian,
6533 n_beta,
6534 cov_scale,
6535 "Joint HMC: Hessian Cholesky failed",
6536 )?;
6537 let chol = whitening.chol;
6538 let chol_t = whitening.chol_t;
6539
6540 let data = SharedData {
6541 x: Arc::new(x.to_owned()),
6542 y: Arc::new(y.to_owned()),
6543 weights: Arc::new(weights.to_owned()),
6544 mode: Arc::new(mode.to_owned()),
6545 offset: offset.map(|o| Arc::new(o.to_owned())),
6553 likelihood: likelihood.clone(),
6554 n_samples,
6555 dim: n_beta,
6556 };
6557 let link_param_mode = Self::link_param_mode(&likelihood.spec.link);
6558
6559 Ok(Self {
6560 data,
6561 chol,
6562 chol_t,
6563 likelihood: likelihood.spec,
6564 penalty_scale: 1.0 / cov_scale,
6565 n_beta,
6566 n_rho,
6567 n_link_params: link_param_mode.len(),
6568 link_param_mode,
6569 penalty_canonical,
6570 rho_prior,
6571 rho_mode: rho_mode.to_owned(),
6572 firth_enabled,
6573 penalty_logdet_cache: Mutex::new(None),
6574 })
6575 }
6576
6577 #[inline]
6583 fn hash_rho(rho: ndarray::ArrayView1<f64>) -> u64 {
6584 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
6585 for &x in rho.iter() {
6586 h ^= x.to_bits();
6587 h = h.wrapping_mul(0x0000_0100_0000_01b3);
6588 }
6589 h
6590 }
6591
6592 fn link_param_mode(inverse_link: &InverseLink) -> Array1<f64> {
6593 match inverse_link {
6594 InverseLink::Sas(state) | InverseLink::BetaLogistic(state) => {
6595 Array1::from_vec(vec![state.epsilon, state.log_delta])
6596 }
6597 InverseLink::Mixture(state) => state.rho.clone(),
6598 InverseLink::Standard(_) | InverseLink::LatentCLogLog(_) => Array1::zeros(0),
6599 }
6600 }
6601
6602 fn inverse_link_with_params(
6603 &self,
6604 link_params: ndarray::ArrayView1<'_, f64>,
6605 ) -> Result<InverseLink, String> {
6606 match &self.likelihood.link {
6607 InverseLink::Sas(_) => {
6608 if link_params.len() != 2 {
6609 return Err(format!(
6610 "SAS link parameter length must be 2, got {}",
6611 link_params.len()
6612 ));
6613 }
6614 Ok(InverseLink::Sas(
6615 gam_solve::mixture_link::sas_link_state_from_raw(
6616 link_params[0],
6617 link_params[1],
6618 )?,
6619 ))
6620 }
6621 InverseLink::BetaLogistic(_) => {
6622 if link_params.len() != 2 || !link_params.iter().all(|v| v.is_finite()) {
6623 return Err(
6624 "Beta-Logistic link parameters must be finite with length 2".to_string()
6625 );
6626 }
6627 Ok(InverseLink::BetaLogistic(
6628 gam_problem::types::SasLinkState {
6629 epsilon: link_params[0],
6630 log_delta: link_params[1],
6631 delta: link_params[1].exp(),
6632 },
6633 ))
6634 }
6635 InverseLink::Mixture(state) => {
6636 let rho = link_params.to_owned();
6637 Ok(InverseLink::Mixture(gam_problem::types::MixtureLinkState {
6638 components: state.components.clone(),
6639 pi: softmax_last_fixedzero(&rho),
6640 rho,
6641 }))
6642 }
6643 InverseLink::Standard(_) | InverseLink::LatentCLogLog(_) => {
6644 Ok(self.likelihood.link.clone())
6645 }
6646 }
6647 }
6648
6649 fn compute_joint_logp_and_grad_into(
6662 &self,
6663 params: &Array1<f64>,
6664 out_grad: &mut Array1<f64>,
6665 ) -> f64 {
6666 let n_beta = self.n_beta;
6667 let n_rho = self.n_rho;
6668 let n_link_params = self.n_link_params;
6669
6670 let z = params.slice(ndarray::s![..n_beta]);
6673 let rho = params.slice(ndarray::s![n_beta..n_beta + n_rho]);
6674 let link_params = params.slice(ndarray::s![n_beta + n_rho..]);
6675 let lambdas = match gam_problem::checked_exp_log_strengths(rho.iter().copied()) {
6676 Ok(values) => Array1::from_vec(values),
6677 Err(_) => {
6678 out_grad.fill(0.0);
6679 return f64::NEG_INFINITY;
6680 }
6681 };
6682
6683 let inverse_link = match self.inverse_link_with_params(link_params) {
6684 Ok(link) => link,
6685 Err(err) => {
6686 log::warn!(
6687 "[Joint HMC] adaptive inverse-link parameters are invalid: {}",
6688 err
6689 );
6690 out_grad.fill(0.0);
6691 return f64::NEG_INFINITY;
6692 }
6693 };
6694
6695 let beta = self.data.mode.as_ref() + &self.chol.dot(&z);
6697
6698 let mut eta = gam_linalg::faer_ndarray::fast_av(self.data.x.as_ref(), &beta);
6700 if let Some(offset) = self.data.offset.as_ref() {
6701 eta += offset.as_ref();
6702 }
6703
6704 let step_likelihood = LikelihoodSpec {
6706 response: self.likelihood.response.clone(),
6707 link: inverse_link,
6708 };
6709 let (ll, mut grad_ll_beta, grad_link) = match joint_family_logp_grad_and_link_grad(
6710 &step_likelihood,
6711 &self.data,
6712 &eta,
6713 n_link_params,
6714 ) {
6715 Ok(value) => value,
6716 Err(err) => {
6717 log::warn!(
6718 "[Joint HMC] likelihood target became invalid at the current state: {}",
6719 err
6720 );
6721 out_grad.fill(0.0);
6722 return f64::NEG_INFINITY;
6723 }
6724 };
6725
6726 let mut firth_logdet = 0.0;
6727 if self.firth_enabled {
6728 match firth_jeffreys_logp_and_grad(&step_likelihood, &self.data, &eta) {
6734 Ok((value, grad_beta_firth)) => {
6735 firth_logdet = value;
6736 grad_ll_beta += &grad_beta_firth;
6737 }
6738 Err(err) => {
6739 log::warn!(
6740 "[Joint HMC/Firth] Jeffreys target became invalid at the current state: {}",
6741 err
6742 );
6743 out_grad.fill(0.0);
6744 return f64::NEG_INFINITY;
6745 }
6746 }
6747 }
6748
6749 let mut penalty_val = 0.0;
6753 let mut s_beta = Array1::<f64>::zeros(n_beta);
6754 let mut grad_rho = Array1::<f64>::zeros(n_rho);
6755
6756 let max_rank = self
6760 .penalty_canonical
6761 .iter()
6762 .map(|cp| cp.rank())
6763 .max()
6764 .unwrap_or(0);
6765 let mut r_beta_scratch = Array1::<f64>::zeros(max_rank);
6766
6767 for (k, cp) in self.penalty_canonical.iter().enumerate() {
6768 let r = &cp.col_range;
6770 let beta_block = beta.slice(ndarray::s![r.start..r.end]);
6771 let rank_k = cp.rank();
6772 gam_linalg::faer_ndarray::fast_av_view_into(
6773 &cp.root,
6774 &beta_block,
6775 r_beta_scratch.slice_mut(ndarray::s![..rank_k]),
6776 );
6777 let r_beta = r_beta_scratch.slice(ndarray::s![..rank_k]);
6778 let quad_k = r_beta.dot(&r_beta);
6779 penalty_val += 0.5 * self.penalty_scale * lambdas[k] * quad_k;
6780
6781 for a in 0..cp.block_dim() {
6783 let val: f64 = (0..rank_k).map(|row| cp.root[[row, a]] * r_beta[row]).sum();
6784 s_beta[r.start + a] += self.penalty_scale * lambdas[k] * val;
6785 }
6786
6787 grad_rho[k] = -0.5 * self.penalty_scale * lambdas[k] * quad_k;
6789 }
6790
6791 let log_det_s = if self.penalty_canonical.is_empty() {
6799 0.0
6800 } else {
6801 let rho_hash = Self::hash_rho(rho);
6802 let cached = self.penalty_logdet_cache.lock().ok().and_then(|guard| {
6803 guard.as_ref().and_then(|(h, v, g)| {
6804 if *h == rho_hash && g.len() == n_rho {
6805 for k in 0..n_rho {
6806 grad_rho[k] += 0.5 * g[k];
6807 }
6808 Some(*v)
6809 } else {
6810 None
6811 }
6812 })
6813 });
6814 if let Some(hit) = cached {
6815 hit
6816 } else {
6817 match PenaltyPseudologdet::from_penalties(
6818 &self.penalty_canonical,
6819 lambdas.as_slice().unwrap_or(&[]),
6820 0.0,
6821 n_beta,
6822 ) {
6823 Ok(pld) => {
6824 let (det1, _) = pld.rho_derivatives_from_penalties(
6825 &self.penalty_canonical,
6826 lambdas.as_slice().unwrap_or(&[]),
6827 );
6828 let value = pld.value();
6829 if let Ok(mut guard) = self.penalty_logdet_cache.lock() {
6830 *guard = Some((rho_hash, value, det1.clone()));
6831 }
6832 for k in 0..n_rho {
6833 grad_rho[k] += 0.5 * det1[k];
6834 }
6835 value
6836 }
6837 Err(err) => {
6838 log::warn!(
6839 "[Joint HMC] structural penalty logdet became invalid at the current state: {}",
6840 err
6841 );
6842 out_grad.fill(0.0);
6843 return f64::NEG_INFINITY;
6844 }
6845 }
6846 }
6847 };
6848
6849 let mut rho_prior = 0.0;
6851 match &self.rho_prior {
6852 RhoPrior::Flat => {}
6853 RhoPrior::Normal { mean, sd } => {
6854 let inv_var = 1.0 / (*sd * *sd);
6855 for k in 0..n_rho {
6856 let d = rho[k] - *mean;
6857 rho_prior -= 0.5 * inv_var * d * d;
6858 grad_rho[k] -= inv_var * d;
6859 }
6860 }
6861 RhoPrior::GammaPrecision { shape, rate } => {
6862 for k in 0..n_rho {
6863 let lambda = lambdas[k];
6864 rho_prior += *shape * rho[k] - *rate * lambda;
6866 grad_rho[k] += *shape - *rate * lambda;
6867 }
6868 }
6869 RhoPrior::PenalizedComplexity { upper, tail_prob } => {
6870 if !pc_prior_params_valid(*upper, *tail_prob) {
6871 out_grad.fill(0.0);
6872 return f64::NEG_INFINITY;
6873 }
6874 let theta = -tail_prob.ln() / *upper;
6875 for k in 0..n_rho {
6876 let e = (-0.5 * rho[k]).exp();
6878 rho_prior += -0.5 * rho[k] - theta * e;
6879 grad_rho[k] += -0.5 + 0.5 * theta * e;
6880 }
6881 }
6882 RhoPrior::Independent(priors) => {
6883 if priors.len() != n_rho {
6884 out_grad.fill(0.0);
6885 return f64::NEG_INFINITY;
6886 }
6887 for k in 0..n_rho {
6888 match &priors[k] {
6889 RhoPrior::Flat => {}
6890 RhoPrior::Normal { mean, sd } => {
6891 let inv_var = 1.0 / (*sd * *sd);
6892 let d = rho[k] - *mean;
6893 rho_prior -= 0.5 * inv_var * d * d;
6894 grad_rho[k] -= inv_var * d;
6895 }
6896 RhoPrior::GammaPrecision { shape, rate } => {
6897 let lambda = lambdas[k];
6898 rho_prior += *shape * rho[k] - *rate * lambda;
6900 grad_rho[k] += *shape - *rate * lambda;
6901 }
6902 RhoPrior::PenalizedComplexity { upper, tail_prob } => {
6903 if !pc_prior_params_valid(*upper, *tail_prob) {
6904 out_grad.fill(0.0);
6905 return f64::NEG_INFINITY;
6906 }
6907 let theta = -tail_prob.ln() / *upper;
6908 let e = (-0.5 * rho[k]).exp();
6909 rho_prior += -0.5 * rho[k] - theta * e;
6910 grad_rho[k] += -0.5 + 0.5 * theta * e;
6911 }
6912 RhoPrior::Independent(_) => {
6913 out_grad.fill(0.0);
6914 return f64::NEG_INFINITY;
6915 }
6916 }
6917 }
6918 }
6919 }
6920
6921 let logp = ll + firth_logdet - penalty_val + 0.5 * log_det_s + rho_prior;
6923
6924 let grad_beta = &grad_ll_beta - &s_beta;
6926
6927 gam_linalg::faer_ndarray::fast_av_view_into(
6929 &self.chol_t,
6930 &grad_beta,
6931 out_grad.slice_mut(ndarray::s![..n_beta]),
6932 );
6933 out_grad
6934 .slice_mut(ndarray::s![n_beta..n_beta + n_rho])
6935 .assign(&grad_rho);
6936 out_grad
6937 .slice_mut(ndarray::s![n_beta + n_rho..])
6938 .assign(&grad_link);
6939
6940 logp
6941 }
6942}
6943
6944fn pc_prior_params_valid(upper: f64, tail_prob: f64) -> bool {
6950 upper.is_finite() && upper > 0.0 && tail_prob.is_finite() && tail_prob > 0.0 && tail_prob < 1.0
6951}
6952
6953impl HamiltonianTarget<Array1<f64>> for JointBetaRhoPosterior {
6954 fn logp_and_grad(&self, position: &Array1<f64>, grad: &mut Array1<f64>) -> f64 {
6955 self.compute_joint_logp_and_grad_into(position, grad)
6956 }
6957}
6958
6959pub struct JointBetaRhoInputs<'a> {
6961 pub x: ArrayView2<'a, f64>,
6962 pub y: ArrayView1<'a, f64>,
6963 pub weights: ArrayView1<'a, f64>,
6964 pub likelihood: GlmLikelihoodSpec,
6965 pub dispersion: gam_solve::model_types::Dispersion,
6971 pub offset: Option<ArrayView1<'a, f64>>,
6975 pub mode: ArrayView1<'a, f64>,
6976 pub hessian: ArrayView2<'a, f64>,
6977 pub penalty_roots: Vec<CanonicalPenalty>,
6978 pub rho_mode: ArrayView1<'a, f64>,
6979 pub rho_prior: RhoPrior,
6980 pub firth_bias_reduction: bool,
6981 pub trigger_skewness: f64,
6983}
6984
6985pub fn run_joint_beta_rho_sampling(
6991 inputs: &JointBetaRhoInputs<'_>,
6992 config: &NutsConfig,
6993) -> Result<JointBetaRhoResult, String> {
6994 validate_firth_likelihood_support(&inputs.likelihood.spec, inputs.firth_bias_reduction)
6995 .map_err(String::from)?;
6996 validate_nuts_config(config).map_err(String::from)?;
6997 let n_beta = inputs.mode.len();
6998 let n_rho = inputs.penalty_roots.len();
6999 let n_link_params = JointBetaRhoPosterior::link_param_mode(&inputs.likelihood.spec.link).len();
7000 let total_dim = n_beta + n_rho + n_link_params;
7001
7002 log::info!(
7003 "[Joint HMC] Sampling (β, ρ, link) jointly: {} β-params + {} ρ-params + {} link-params = {} total (triggered by skewness {:.3})",
7004 n_beta,
7005 n_rho,
7006 n_link_params,
7007 total_dim,
7008 inputs.trigger_skewness,
7009 );
7010
7011 let target = JointBetaRhoPosterior::new(
7012 inputs.x,
7013 inputs.y,
7014 inputs.weights,
7015 inputs.mode,
7016 inputs.hessian,
7017 inputs.penalty_roots.clone(),
7018 inputs.rho_mode,
7019 inputs.likelihood.clone(),
7020 inputs.dispersion,
7021 inputs.offset,
7022 inputs.rho_prior.clone(),
7023 inputs.firth_bias_reduction,
7024 )?;
7025
7026 let chol = target.chol.clone();
7027 let mode_arr = target.data.mode.clone();
7028 let rho_mode = target.rho_mode.clone();
7029 let link_param_mode = target.link_param_mode.clone();
7030
7031 let initial_positions: Vec<Array1<f64>> = (0..config.n_chains)
7033 .map(|chain| {
7034 let mut rng =
7035 StdRng::seed_from_u64(chain_stream_seed(config.seed, chain, 0x9B51_6E37_F2D0_A48C));
7036 let mut pos = Array1::<f64>::zeros(total_dim);
7037 for j in 0..n_beta {
7039 pos[j] = sample_standard_normal(&mut rng) * 0.1;
7040 }
7041 for k in 0..n_rho {
7043 pos[n_beta + k] = rho_mode[k] + sample_standard_normal(&mut rng) * 0.2;
7044 }
7045 for k in 0..n_link_params {
7047 pos[n_beta + n_rho + k] =
7048 link_param_mode[k] + sample_standard_normal(&mut rng) * 0.05;
7049 }
7050 pos
7051 })
7052 .collect();
7053
7054 let mass_cfg = robust_mass_matrix_config(total_dim, config.nwarmup);
7057
7058 let (samples_array, run_stats) = run_whitened_nuts_samples(
7059 target,
7060 initial_positions,
7061 config,
7062 total_dim,
7063 mass_cfg,
7064 0x63AF_175B_D820_C94E,
7065 "Joint (β,ρ) NUTS sampling failed",
7066 )?;
7067 log::info!("[Joint HMC] Sampling complete: {}", run_stats);
7068
7069 let shape = samples_array.shape();
7071 let n_chains = shape[0];
7072 let n_samples_out = shape[1];
7073 let total_samples = n_chains * n_samples_out;
7074
7075 let beta_samples = unwhiten_samples(&samples_array, mode_arr.as_ref(), &chol, n_beta, 0);
7076 let mut rho_samples = Array2::<f64>::zeros((total_samples, n_rho));
7077 let mut link_param_samples = Array2::<f64>::zeros((total_samples, n_link_params));
7078
7079 for chain in 0..n_chains {
7080 for sample_i in 0..n_samples_out {
7081 let sample_idx = chain * n_samples_out + sample_i;
7082 let zview = samples_array.slice(ndarray::s![chain, sample_i, ..]);
7083
7084 let rho_slice = zview.slice(ndarray::s![n_beta..n_beta + n_rho]);
7086 rho_samples.row_mut(sample_idx).assign(&rho_slice);
7087 let link_slice = zview.slice(ndarray::s![n_beta + n_rho..]);
7088 link_param_samples.row_mut(sample_idx).assign(&link_slice);
7089 }
7090 }
7091
7092 let beta_mean = beta_samples
7093 .mean_axis(Axis(0))
7094 .unwrap_or_else(|| Array1::zeros(n_beta));
7095 let rho_mean = rho_samples
7096 .mean_axis(Axis(0))
7097 .unwrap_or_else(|| Array1::zeros(n_rho));
7098 let link_param_mean = link_param_samples
7099 .mean_axis(Axis(0))
7100 .unwrap_or_else(|| Array1::zeros(n_link_params));
7101
7102 let (rhat, ess) = compute_split_rhat_and_ess(&samples_array);
7103
7104 let converged = NutsConvergenceThresholds {
7105 max_rhat: 1.1,
7106 min_ess: Some(50.0),
7107 }
7108 .converged(rhat, ess);
7109 if !converged {
7110 log::warn!(
7111 "[Joint HMC] Convergence warning: R-hat={:.3}, ESS={:.1}",
7112 rhat,
7113 ess,
7114 );
7115 }
7116
7117 Ok(JointBetaRhoResult {
7118 beta_samples,
7119 rho_samples,
7120 beta_mean,
7121 link_param_samples,
7122 link_param_mean,
7123 rho_mean,
7124 rhat,
7125 ess,
7126 converged,
7127 trigger_skewness: inputs.trigger_skewness,
7128 })
7129}
7130
7131mod survival_hmc {
7136 use super::*;
7137 use gam_models::survival::{
7138 PenaltyBlocks, SurvivalEngineInputs, SurvivalMonotonicityPenalty, SurvivalSpec,
7139 WorkingModelSurvival,
7140 };
7141
7142 #[derive(Clone)]
7144 struct SharedSurvivalData {
7145 base_model: Arc<WorkingModelSurvival>,
7147 mode: Arc<Array1<f64>>,
7149 }
7150
7151 #[derive(Clone)]
7153 pub struct SurvivalPosterior {
7154 data: SharedSurvivalData,
7156 chol: Array2<f64>,
7158 chol_t: Array2<f64>,
7160 }
7161
7162 impl SurvivalPosterior {
7163 pub fn new(
7165 age_entry: ArrayView1<'_, f64>,
7166 age_exit: ArrayView1<'_, f64>,
7167 event_target: ArrayView1<'_, u8>,
7168 event_competing: ArrayView1<'_, u8>,
7169 sampleweight: ArrayView1<'_, f64>,
7170 x_entry: ArrayView2<'_, f64>,
7171 x_exit: ArrayView2<'_, f64>,
7172 x_derivative: ArrayView2<'_, f64>,
7173 offset_eta_entry: Option<ArrayView1<'_, f64>>,
7174 offset_eta_exit: Option<ArrayView1<'_, f64>>,
7175 offset_derivative_exit: Option<ArrayView1<'_, f64>>,
7176 penalties: PenaltyBlocks,
7177 monotonicity: SurvivalMonotonicityPenalty,
7178 spec: SurvivalSpec,
7179 structurally_monotonic: bool,
7180 structural_time_columns: usize,
7181 mode: ArrayView1<f64>,
7182 hessian: ArrayView2<f64>,
7183 ) -> Result<Self, String> {
7184 let n = age_entry.len();
7185 let off_eta_entry = offset_eta_entry
7186 .map(|v| v.to_owned())
7187 .unwrap_or_else(|| Array1::zeros(n));
7188 let off_eta_exit = offset_eta_exit
7189 .map(|v| v.to_owned())
7190 .unwrap_or_else(|| Array1::zeros(n));
7191 let off_deriv_exit = offset_derivative_exit
7192 .map(|v| v.to_owned())
7193 .unwrap_or_else(|| Array1::zeros(n));
7194
7195 let mut base_model = WorkingModelSurvival::from_engine_inputswith_offsets(
7196 SurvivalEngineInputs {
7197 age_entry,
7198 age_exit,
7199 event_target,
7200 event_competing,
7201 sampleweight,
7202 x_entry,
7203 x_exit,
7204 x_derivative,
7205 monotonicity_constraint_rows: None,
7206 monotonicity_constraint_offsets: None,
7207 },
7208 Some(gam_models::survival::SurvivalBaselineOffsets {
7209 eta_entry: off_eta_entry.view(),
7210 eta_exit: off_eta_exit.view(),
7211 derivative_exit: off_deriv_exit.view(),
7212 }),
7213 penalties,
7214 monotonicity,
7215 spec,
7216 )
7217 .map_err(|e| format!("Survival state construction failed: {:?}", e))?;
7218 if structurally_monotonic {
7219 base_model
7220 .set_structural_monotonicity(true, structural_time_columns)
7221 .map_err(|e| {
7222 format!("Failed to enable structural monotonicity in survival HMC: {e}")
7223 })?;
7224 }
7225
7226 let sampler_mode = mode.to_owned();
7227 let dim = sampler_mode.len();
7228
7229 let whitening = hessian_whitening_transform(
7230 hessian,
7231 dim,
7232 1.0,
7233 "Hessian Cholesky decomposition failed",
7234 )?;
7235 let chol = whitening.chol;
7236 let chol_t = whitening.chol_t;
7237
7238 let data = SharedSurvivalData {
7239 base_model: Arc::new(base_model),
7240 mode: Arc::new(sampler_mode),
7241 };
7242
7243 Ok(Self { data, chol, chol_t })
7244 }
7245
7246 fn compute_logp_and_grad_into(
7247 &self,
7248 z: &Array1<f64>,
7249 grad: &mut Array1<f64>,
7250 ) -> Result<f64, String> {
7251 let sampler_position = self.data.mode.as_ref() + &self.chol.dot(z);
7252 let state = self
7253 .data
7254 .base_model
7255 .update_state(&sampler_position)
7256 .map_err(|e| format!("Survival state update failed: {:?}", e))?;
7257 let logp = state.log_likelihood - state.penalty_term;
7258 let grad_beta = state.gradient.mapv(|g| -g);
7259 fast_av_into(&self.chol_t, &grad_beta, grad);
7260 Ok(logp)
7261 }
7262
7263 pub fn chol(&self) -> &Array2<f64> {
7265 &self.chol
7266 }
7267
7268 pub fn mode(&self) -> &Array1<f64> {
7270 &self.data.mode
7271 }
7272 }
7273
7274 impl HamiltonianTarget<Array1<f64>> for SurvivalPosterior {
7275 fn logp_and_grad(&self, position: &Array1<f64>, grad: &mut Array1<f64>) -> f64 {
7276 match self.compute_logp_and_grad_into(position, grad) {
7277 Ok(logp) => logp,
7278 Err(e) => {
7279 log::warn!("Survival posterior evaluation failed: {}", e);
7280 grad.fill(0.0);
7281 f64::NEG_INFINITY
7282 }
7283 }
7284 }
7285 }
7286
7287 pub(crate) fn run_survival_nuts_sampling(
7289 age_entry: ArrayView1<'_, f64>,
7290 age_exit: ArrayView1<'_, f64>,
7291 event_target: ArrayView1<'_, u8>,
7292 event_competing: ArrayView1<'_, u8>,
7293 sampleweight: ArrayView1<'_, f64>,
7294 x_entry: ArrayView2<'_, f64>,
7295 x_exit: ArrayView2<'_, f64>,
7296 x_derivative: ArrayView2<'_, f64>,
7297 eta_offset_entry: Option<ArrayView1<'_, f64>>,
7298 eta_offset_exit: Option<ArrayView1<'_, f64>>,
7299 derivative_offset_exit: Option<ArrayView1<'_, f64>>,
7300 penalties: PenaltyBlocks,
7301 monotonicity: SurvivalMonotonicityPenalty,
7302 spec: SurvivalSpec,
7303 structurally_monotonic: bool,
7304 structural_time_columns: usize,
7305 mode: ArrayView1<f64>,
7306 hessian: ArrayView2<f64>,
7307 config: &NutsConfig,
7308 ) -> Result<NutsResult, String> {
7309 validate_nuts_config(config).map_err(String::from)?;
7310 let target = SurvivalPosterior::new(
7312 age_entry,
7313 age_exit,
7314 event_target,
7315 event_competing,
7316 sampleweight,
7317 x_entry,
7318 x_exit,
7319 x_derivative,
7320 eta_offset_entry,
7321 eta_offset_exit,
7322 derivative_offset_exit,
7323 penalties,
7324 monotonicity,
7325 spec,
7326 structurally_monotonic,
7327 structural_time_columns,
7328 mode,
7329 hessian,
7330 )?;
7331
7332 let chol = target.chol().clone();
7334 let mode_arr = target.mode().clone();
7335 let dim = mode_arr.len();
7336
7337 let initial_positions = jittered_initial_positions(config, dim, 0.1, 0xEC2D_7A9B_4051_F638);
7338
7339 let mass_cfg = robust_survival_mass_matrix_config(dim, config.nwarmup);
7340 let (result, run_stats) = run_whitened_nuts_result(
7341 target,
7342 &mode_arr,
7343 &chol,
7344 initial_positions,
7345 config,
7346 dim,
7347 mass_cfg,
7348 0x731B_60D4_AE52_9C8F,
7349 "NUTS sampling failed",
7350 Array1::zeros(dim),
7351 NutsConvergenceThresholds {
7352 max_rhat: 1.1,
7353 min_ess: None,
7354 },
7355 )?;
7356
7357 log::info!("Survival NUTS sampling complete: {}", run_stats);
7358
7359 Ok(result)
7360 }
7361}
7362
7363pub fn run_survival_nuts_sampling_flattened<'a>(
7365 flat: SurvivalFlatInputs<'a>,
7366 penalties: gam_models::survival::PenaltyBlocks,
7367 monotonicity: gam_models::survival::SurvivalMonotonicityPenalty,
7368 spec: gam_models::survival::SurvivalSpec,
7369 structurally_monotonic: bool,
7370 structural_time_columns: usize,
7371 mode: ArrayView1<'a, f64>,
7372 hessian: ArrayView2<'a, f64>,
7373 config: &NutsConfig,
7374) -> Result<NutsResult, String> {
7375 run_nuts_sampling_flattened_family(
7376 LikelihoodSpec {
7377 response: ResponseFamily::RoystonParmar,
7378 link: InverseLink::Standard(StandardLink::Identity),
7379 },
7380 FamilyNutsInputs::Survival(Box::new(SurvivalNutsInputs {
7381 flat,
7382 penalties,
7383 monotonicity,
7384 spec,
7385 structurally_monotonic,
7386 structural_time_columns,
7387 mode,
7388 hessian,
7389 })),
7390 config,
7391 )
7392}