1use crate::inference::predict_io::PredictResult;
2use gam_custom_family::{CustomFamily, ParameterBlockState};
3use gam_problem::types::{
4 LikelihoodScaleMetadata, LikelihoodSpec, ResponseFamily, is_valid_tweedie_power,
5};
6use gam_solve::estimate::EstimationError;
7use ndarray::{Array1, Array2};
8
9pub fn family_noise_parameter(
40 scale: LikelihoodScaleMetadata,
41 standard_deviation: f64,
42 likelihood: &LikelihoodSpec,
43) -> Option<f64> {
44 match likelihood.response {
45 ResponseFamily::Tweedie { .. } => scale.fixed_phi().or(Some(1.0)),
50 ResponseFamily::NegativeBinomial { theta, .. } => scale.negbin_theta().or(Some(theta)),
53 ResponseFamily::Beta { phi } => scale.fixed_phi().or(Some(phi)),
56 ResponseFamily::Gamma => scale.gamma_shape().or(Some(standard_deviation)),
59 _ => Some(standard_deviation),
62 }
63}
64
65#[derive(Clone, Debug)]
67pub enum NoiseModel {
68 Gaussian {
69 sigma: Array1<f64>,
71 },
72 Poisson,
73 Tweedie {
74 p: f64,
75 phi: Array1<f64>,
79 },
80 NegativeBinomial {
81 theta: Array1<f64>,
83 },
84 Beta {
85 phi: Array1<f64>,
87 },
88 Gamma {
89 shape: Array1<f64>,
92 },
93 Bernoulli,
94 TransformationNormalQuantile {
107 grid_y: Array1<f64>,
109 h_grid: Array2<f64>,
112 },
113}
114
115fn invert_monotone_grid(
122 grid_y: &Array1<f64>,
123 h_row: ndarray::ArrayView1<'_, f64>,
124 target: f64,
125) -> f64 {
126 let g = grid_y.len();
127 if target <= h_row[0] {
128 return grid_y[0];
129 }
130 if target >= h_row[g - 1] {
131 return grid_y[g - 1];
132 }
133 let mut lo = 0usize;
134 let mut hi = g - 1;
135 while hi - lo > 1 {
136 let mid = (lo + hi) / 2;
137 if h_row[mid] <= target {
138 lo = mid;
139 } else {
140 hi = mid;
141 }
142 }
143 let t = (target - h_row[lo]) / (h_row[hi] - h_row[lo]);
144 grid_y[lo] + t * (grid_y[hi] - grid_y[lo])
145}
146
147#[derive(Clone, Debug)]
149pub struct GenerativeSpec {
150 pub mean: Array1<f64>,
151 pub noise: NoiseModel,
152}
153
154impl GenerativeSpec {
155 pub fn nobs(&self) -> usize {
158 self.mean.len()
159 }
160}
161
162pub fn generativespec_from_predict(
164 prediction: PredictResult,
165 likelihood: LikelihoodSpec,
166 gaussian_scale: Option<f64>,
167 prior_weights: Option<&Array1<f64>>,
168) -> Result<GenerativeSpec, EstimationError> {
169 let mut noise =
170 NoiseModel::from_likelihood(&likelihood, prediction.mean.len(), gaussian_scale)?;
171 if let (NoiseModel::Gaussian { sigma }, Some(weights)) = (&mut noise, prior_weights) {
180 scale_gaussian_sigma_by_prior_weights(sigma, weights)?;
181 }
182 Ok(GenerativeSpec {
183 mean: prediction.mean,
184 noise,
185 })
186}
187
188fn scale_gaussian_sigma_by_prior_weights(
194 sigma: &mut Array1<f64>,
195 weights: &Array1<f64>,
196) -> Result<(), EstimationError> {
197 if weights.len() != sigma.len() {
198 crate::bail_invalid_estim!(
199 "prior weights length {} does not match observation count {}",
200 weights.len(),
201 sigma.len()
202 );
203 }
204 for (s, &w) in sigma.iter_mut().zip(weights.iter()) {
205 if !(w.is_finite() && w > 0.0) {
206 crate::bail_invalid_estim!(
207 "Gaussian replicate prior weights must be finite and > 0; got {w}"
208 );
209 }
210 *s /= w.sqrt();
211 }
212 Ok(())
213}
214
215impl NoiseModel {
216 pub fn from_likelihood(
228 likelihood: &LikelihoodSpec,
229 nobs: usize,
230 gaussian_scale: Option<f64>,
231 ) -> Result<NoiseModel, EstimationError> {
232 match &likelihood.response {
233 ResponseFamily::Gaussian => {
234 let sigma =
235 Self::require_noise_parameter(likelihood, "Gaussian sigma", gaussian_scale)?;
236 if sigma < 0.0 {
237 crate::bail_invalid_estim!(
238 "{} generative sampling requires Gaussian sigma >= 0; got {sigma}",
239 likelihood.pretty_name()
240 );
241 }
242 Ok(NoiseModel::Gaussian {
243 sigma: Array1::from_elem(nobs, sigma),
244 })
245 }
246 ResponseFamily::Binomial => Ok(NoiseModel::Bernoulli),
247 ResponseFamily::Poisson => Ok(NoiseModel::Poisson),
248 ResponseFamily::Tweedie { p } => {
249 let p = *p;
250 if !is_valid_tweedie_power(p) {
251 crate::bail_invalid_estim!(
252 "Tweedie variance power must be finite and strictly between 1 and 2; got {p}"
253 );
254 }
255 let phi = Self::require_positive_noise_parameter(
256 likelihood,
257 "Tweedie dispersion phi",
258 gaussian_scale,
259 )?;
260 Ok(NoiseModel::Tweedie {
261 p,
262 phi: Array1::from_elem(nobs, phi),
266 })
267 }
268 ResponseFamily::NegativeBinomial { theta, .. } => {
269 let theta = gaussian_scale.unwrap_or(*theta);
277 if !(theta.is_finite() && theta > 0.0) {
278 crate::bail_invalid_estim!(
279 "negative-binomial theta must be finite and > 0; got {theta}"
280 );
281 }
282 Ok(NoiseModel::NegativeBinomial {
283 theta: Array1::from_elem(nobs, theta),
284 })
285 }
286 ResponseFamily::Beta { phi } => {
287 let phi = gaussian_scale.unwrap_or(*phi);
301 if !(phi.is_finite() && phi > 0.0) {
302 crate::bail_invalid_estim!(
303 "beta-regression phi must be finite and > 0; got {phi}"
304 );
305 }
306 Ok(NoiseModel::Beta {
307 phi: Array1::from_elem(nobs, phi),
308 })
309 }
310 ResponseFamily::Gamma => {
311 let shape = Self::require_positive_noise_parameter(
312 likelihood,
313 "Gamma shape",
314 gaussian_scale,
315 )?;
316 Ok(NoiseModel::Gamma {
317 shape: Array1::from_elem(nobs, shape),
318 })
319 }
320 ResponseFamily::RoystonParmar => Err(EstimationError::InvalidInput(
321 "RoystonParmar generative sampling is not exposed via generic generation"
322 .to_string(),
323 )),
324 }
325 }
326
327 pub fn from_likelihood_with_per_row_dispersion(
336 likelihood: &LikelihoodSpec,
337 dispersion: Array1<f64>,
338 ) -> Result<NoiseModel, EstimationError> {
339 match &likelihood.response {
340 ResponseFamily::Tweedie { p } => {
341 let p = *p;
342 if !is_valid_tweedie_power(p) {
343 crate::bail_invalid_estim!(
344 "Tweedie variance power must be finite and strictly between 1 and 2; got {p}"
345 );
346 }
347 Ok(NoiseModel::Tweedie { p, phi: dispersion })
348 }
349 ResponseFamily::NegativeBinomial { .. } => {
350 Ok(NoiseModel::NegativeBinomial { theta: dispersion })
351 }
352 ResponseFamily::Beta { .. } => Ok(NoiseModel::Beta { phi: dispersion }),
353 ResponseFamily::Gamma => Ok(NoiseModel::Gamma { shape: dispersion }),
354 other => Err(EstimationError::InvalidInput(format!(
355 "per-row dispersion generative sampling is only defined for the dispersion \
356 location-scale families (Gamma/NegativeBinomial/Beta/Tweedie); got {other:?}"
357 ))),
358 }
359 }
360
361 fn require_noise_parameter(
362 likelihood: &LikelihoodSpec,
363 parameter_name: &str,
364 value: Option<f64>,
365 ) -> Result<f64, EstimationError> {
366 let value = value.ok_or_else(|| {
367 EstimationError::InvalidInput(format!(
368 "{} generative sampling requires fitted {parameter_name}",
369 likelihood.pretty_name()
370 ))
371 })?;
372 if value.is_finite() {
373 Ok(value)
374 } else {
375 Err(EstimationError::InvalidInput(format!(
376 "{} generative sampling requires finite {parameter_name}; got {value}",
377 likelihood.pretty_name()
378 )))
379 }
380 }
381
382 fn require_positive_noise_parameter(
383 likelihood: &LikelihoodSpec,
384 parameter_name: &str,
385 value: Option<f64>,
386 ) -> Result<f64, EstimationError> {
387 let value = Self::require_noise_parameter(likelihood, parameter_name, value)?;
388 if value > 0.0 {
389 Ok(value)
390 } else {
391 Err(EstimationError::InvalidInput(format!(
392 "{} generative sampling requires {parameter_name} > 0; got {value}",
393 likelihood.pretty_name()
394 )))
395 }
396 }
397}
398
399fn check_dispersion_len(
403 dispersion: &Array1<f64>,
404 nobs: usize,
405 name: &str,
406) -> Result<(), EstimationError> {
407 if dispersion.len() != nobs {
408 crate::bail_invalid_estim!(
409 "{name} length {} does not match mean length {nobs}",
410 dispersion.len()
411 );
412 }
413 Ok(())
414}
415
416pub fn sampleobservations<R: rand::Rng + ?Sized>(
418 spec: &GenerativeSpec,
419 rng: &mut R,
420) -> Result<Array1<f64>, EstimationError> {
421 if spec.mean.iter().any(|m| !m.is_finite()) {
422 crate::bail_invalid_estim!("generative mean contains non-finite values");
423 }
424 match &spec.noise {
425 NoiseModel::Gaussian { sigma } => {
426 if sigma.len() != spec.mean.len() {
427 crate::bail_invalid_estim!(
428 "Gaussian sigma length {} does not match mean length {}",
429 sigma.len(),
430 spec.mean.len()
431 );
432 }
433 let mut y = spec.mean.clone();
434 for i in 0..y.len() {
435 let sd = sigma[i].max(0.0);
436 if sd == 0.0 {
437 continue;
438 }
439 let dist = rand_distr::Normal::new(0.0, sd).map_err(|e| {
440 EstimationError::InvalidInput(format!("invalid Gaussian noise scale {sd}: {e}"))
441 })?;
442 y[i] += rand_distr::Distribution::sample(&dist, rng);
443 }
444 Ok(y)
445 }
446 NoiseModel::Poisson => {
447 let mut y = Array1::<f64>::zeros(spec.mean.len());
448 for i in 0..y.len() {
449 let lam = spec.mean[i].max(1e-12);
450 let dist = rand_distr::Poisson::new(lam).map_err(|e| {
451 EstimationError::InvalidInput(format!("invalid Poisson rate {lam}: {e}"))
452 })?;
453 let draw = rand_distr::Distribution::sample(&dist, rng);
454 y[i] = draw;
455 }
456 Ok(y)
457 }
458 NoiseModel::Tweedie { p, phi } => {
459 if !(p.is_finite() && *p >= 1.0 && *p <= 2.0) {
460 crate::bail_invalid_estim!("invalid Tweedie power p: {p}");
461 }
462 check_dispersion_len(phi, spec.mean.len(), "Tweedie dispersion phi")?;
463 for (i, &phi_i) in phi.iter().enumerate() {
464 if !(phi_i.is_finite() && phi_i > 0.0) {
465 crate::bail_invalid_estim!(
466 "invalid Tweedie dispersion phi at row {i}: {phi_i}"
467 );
468 }
469 }
470 let mut y = Array1::<f64>::zeros(spec.mean.len());
471 if (*p - 1.0).abs() <= 1.0e-12 {
472 for i in 0..y.len() {
473 let phi_i = phi[i];
474 let lam = (spec.mean[i] / phi_i).max(1e-12);
475 let dist = rand_distr::Poisson::new(lam).map_err(|e| {
476 EstimationError::InvalidInput(format!(
477 "invalid Tweedie-Poisson rate {lam}: {e}"
478 ))
479 })?;
480 y[i] = phi_i * rand_distr::Distribution::sample(&dist, rng);
481 }
482 return Ok(y);
483 }
484 if (*p - 2.0).abs() <= 1.0e-12 {
485 for i in 0..y.len() {
486 let phi_i = phi[i];
487 let shape = (1.0 / phi_i).max(1e-12);
488 let mu = spec.mean[i].max(1e-12);
489 let scale = (mu * phi_i).max(1e-12);
490 let dist = rand_distr::Gamma::new(shape, scale).map_err(|e| {
491 EstimationError::InvalidInput(format!(
492 "invalid Tweedie-Gamma params shape={shape} scale={scale}: {e}"
493 ))
494 })?;
495 y[i] = rand_distr::Distribution::sample(&dist, rng);
496 }
497 return Ok(y);
498 }
499 let alpha = (2.0 - *p) / (*p - 1.0);
500 for i in 0..y.len() {
501 let phi_i = phi[i];
502 let mu = spec.mean[i].max(1e-12);
503 let lambda = (mu.powf(2.0 - *p) / (phi_i * (2.0 - *p))).max(1e-12);
504 let scale = (phi_i * (*p - 1.0) * mu.powf(*p - 1.0)).max(1e-12);
505 let count_dist = rand_distr::Poisson::new(lambda).map_err(|e| {
506 EstimationError::InvalidInput(format!(
507 "invalid Tweedie compound-Poisson rate {lambda}: {e}"
508 ))
509 })?;
510 let count = rand_distr::Distribution::sample(&count_dist, rng) as usize;
511 if count == 0 {
512 continue;
513 }
514 let jump_dist = rand_distr::Gamma::new(alpha, scale).map_err(|e| {
515 EstimationError::InvalidInput(format!(
516 "invalid Tweedie jump params shape={alpha} scale={scale}: {e}"
517 ))
518 })?;
519 y[i] = (0..count)
520 .map(|_| rand_distr::Distribution::sample(&jump_dist, rng))
521 .sum();
522 }
523 Ok(y)
524 }
525 NoiseModel::NegativeBinomial { theta } => {
526 check_dispersion_len(theta, spec.mean.len(), "NegativeBinomial theta")?;
527 let mut y = Array1::<f64>::zeros(spec.mean.len());
528 for i in 0..y.len() {
529 let theta_i = theta[i];
530 if !(theta_i.is_finite() && theta_i > 0.0) {
531 crate::bail_invalid_estim!(
532 "invalid negative-binomial theta at row {i}: {theta_i}"
533 );
534 }
535 let mu = spec.mean[i].max(1e-12);
536 let scale = (mu / theta_i).max(1e-12);
537 let gamma = rand_distr::Gamma::new(theta_i, scale).map_err(|e| {
538 EstimationError::InvalidInput(format!(
539 "invalid NegativeBinomial gamma mixture params theta={theta_i} scale={scale}: {e}"
540 ))
541 })?;
542 let lambda = rand_distr::Distribution::sample(&gamma, rng).max(1e-12);
543 let poisson = rand_distr::Poisson::new(lambda).map_err(|e| {
544 EstimationError::InvalidInput(format!(
545 "invalid NegativeBinomial Poisson rate {lambda}: {e}"
546 ))
547 })?;
548 y[i] = rand_distr::Distribution::sample(&poisson, rng);
549 }
550 Ok(y)
551 }
552 NoiseModel::Beta { phi } => {
553 check_dispersion_len(phi, spec.mean.len(), "Beta phi")?;
554 let mut y = Array1::<f64>::zeros(spec.mean.len());
555 for i in 0..y.len() {
556 let phi_i = phi[i];
557 if !(phi_i.is_finite() && phi_i > 0.0) {
558 crate::bail_invalid_estim!("invalid beta-regression phi at row {i}: {phi_i}");
559 }
560 let mu = spec.mean[i].clamp(1e-12, 1.0 - 1e-12);
561 let alpha = (mu * phi_i).max(1e-12);
562 let beta = ((1.0 - mu) * phi_i).max(1e-12);
563 let dist = rand_distr::Beta::new(alpha, beta).map_err(|e| {
564 EstimationError::InvalidInput(format!(
565 "invalid Beta params alpha={alpha} beta={beta}: {e}"
566 ))
567 })?;
568 y[i] = rand_distr::Distribution::sample(&dist, rng);
569 }
570 Ok(y)
571 }
572 NoiseModel::Gamma { shape } => {
573 check_dispersion_len(shape, spec.mean.len(), "Gamma shape")?;
574 let mut y = Array1::<f64>::zeros(spec.mean.len());
575 for i in 0..y.len() {
576 let shape_i = shape[i];
577 if !shape_i.is_finite() || shape_i <= 0.0 {
578 crate::bail_invalid_estim!("invalid Gamma shape at row {i}: {shape_i}");
579 }
580 let mu = spec.mean[i].max(1e-12);
581 let scale = (mu / shape_i).max(1e-12);
582 let dist = rand_distr::Gamma::new(shape_i, scale).map_err(|e| {
583 EstimationError::InvalidInput(format!(
584 "invalid Gamma params shape={shape_i} scale={scale}: {e}"
585 ))
586 })?;
587 y[i] = rand_distr::Distribution::sample(&dist, rng);
588 }
589 Ok(y)
590 }
591 NoiseModel::Bernoulli => {
592 let mut y = Array1::<f64>::zeros(spec.mean.len());
593 for i in 0..y.len() {
594 let p = spec.mean[i];
595 let dist = rand_distr::Bernoulli::new(p).map_err(|e| {
596 EstimationError::InvalidInput(format!("invalid Bernoulli probability {p}: {e}"))
597 })?;
598 y[i] = if rand_distr::Distribution::sample(&dist, rng) {
599 1.0
600 } else {
601 0.0
602 };
603 }
604 Ok(y)
605 }
606 NoiseModel::TransformationNormalQuantile { grid_y, h_grid } => {
607 let n = spec.mean.len();
608 if h_grid.nrows() != n {
609 crate::bail_invalid_estim!(
610 "transformation-normal h_grid has {} rows but mean length is {n}",
611 h_grid.nrows()
612 );
613 }
614 let g = grid_y.len();
615 if g < 2 || h_grid.ncols() != g {
616 crate::bail_invalid_estim!(
617 "transformation-normal grid is degenerate: grid_y len {g}, h_grid cols {}",
618 h_grid.ncols()
619 );
620 }
621 let dist = rand_distr::Normal::new(0.0, 1.0).map_err(|e| {
625 EstimationError::InvalidInput(format!(
626 "invalid standard-normal latent sampler: {e}"
627 ))
628 })?;
629 let mut y = Array1::<f64>::zeros(n);
630 for i in 0..n {
631 let z: f64 = rand_distr::Distribution::sample(&dist, rng);
632 y[i] = invert_monotone_grid(grid_y, h_grid.row(i), z);
633 }
634 Ok(y)
635 }
636 }
637}
638
639pub fn sampleobservation_replicates<R: rand::Rng + ?Sized>(
641 spec: &GenerativeSpec,
642 n_draws: usize,
643 rng: &mut R,
644) -> Result<Array2<f64>, EstimationError> {
645 let n = spec.nobs();
646 let mut out = Array2::<f64>::zeros((n_draws, n));
647 for d in 0..n_draws {
648 let draw = sampleobservations(spec, rng)?;
649 out.row_mut(d).assign(&draw);
650 }
651 Ok(out)
652}
653
654pub trait CustomFamilyGenerative: CustomFamily {
657 fn generativespec(
658 &self,
659 block_states: &[ParameterBlockState],
660 ) -> Result<GenerativeSpec, String>;
661}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666 use crate::family_runtime::{FamilyStrategy, strategy_for_spec};
667
668 #[test]
676 fn transformation_normal_quantile_sampler_is_inverse_transform() {
677 use rand::SeedableRng;
678
679 let g = 801usize;
680 let (y_lo, y_hi) = (-12.0_f64, 12.0_f64);
681 let grid_y =
682 Array1::from_shape_fn(g, |k| y_lo + (y_hi - y_lo) * (k as f64) / ((g - 1) as f64));
683 let centers = [-1.0_f64, 2.0_f64];
685 let slopes = [2.0_f64, 4.0_f64];
686 let mut h_grid = Array2::<f64>::zeros((2, g));
687 for i in 0..2 {
688 for k in 0..g {
689 h_grid[[i, k]] = slopes[i] * (grid_y[k] - centers[i]);
690 }
691 }
692 let spec = GenerativeSpec {
693 mean: Array1::from_vec(vec![centers[0], centers[1]]),
694 noise: NoiseModel::TransformationNormalQuantile {
695 grid_y: grid_y.clone(),
696 h_grid,
697 },
698 };
699
700 let mut rng = rand::rngs::StdRng::seed_from_u64(20240613);
701 let n_draws = 40_000usize;
702 let draws = sampleobservation_replicates(&spec, n_draws, &mut rng).unwrap();
703 assert_eq!(draws.shape(), &[n_draws, 2]);
704
705 let mut row_means = [0.0_f64; 2];
706 for i in 0..2 {
707 let col = draws.column(i);
708 let mean = col.sum() / (n_draws as f64);
709 let var = col.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / (n_draws as f64);
710 let sd = var.sqrt();
711 row_means[i] = mean;
712 assert!(
713 (mean - centers[i]).abs() < 0.02,
714 "row {i} draw mean {mean:.4} should be the response-scale center {:.4}",
715 centers[i]
716 );
717 let expected_sd = 1.0 / slopes[i];
718 assert!(
719 (sd - expected_sd).abs() < 0.02,
720 "row {i} draw sd {sd:.4} should be the response-scale 1/slope {expected_sd:.4}, \
721 not the latent ≈1 of the old Gaussian-noise path"
722 );
723 }
724 assert!(
727 row_means[1] > row_means[0],
728 "draw means must increase with center: row0={:.4} row1={:.4}",
729 row_means[0],
730 row_means[1]
731 );
732 }
733
734 #[test]
741 fn family_noise_parameter_reads_fitted_dispersion_not_seed() {
742 let nb = LikelihoodSpec::negative_binomial_log(1.0);
744 assert_eq!(
745 family_noise_parameter(
746 LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: 2.97 },
747 0.0,
748 &nb,
749 ),
750 Some(2.97),
751 "NB picker must read theta_hat (#1124), not the seed theta=1"
752 );
753
754 let tw = LikelihoodSpec::tweedie_log(1.5);
757 assert_eq!(
758 family_noise_parameter(
759 LikelihoodScaleMetadata::EstimatedTweediePhi { phi: 7.25 },
760 0.0,
761 &tw,
762 ),
763 Some(7.25),
764 "Tweedie picker must read phi_hat (#771), not the variance power p"
765 );
766
767 let beta = LikelihoodSpec::beta_logit(1.0);
769 assert_eq!(
770 family_noise_parameter(
771 LikelihoodScaleMetadata::EstimatedBetaPhi { phi: 12.0 },
772 0.0,
773 &beta,
774 ),
775 Some(12.0),
776 "Beta picker must read phi_hat (#770), not the seed phi=1"
777 );
778
779 let gamma = LikelihoodSpec::gamma_log();
781 assert_eq!(
782 family_noise_parameter(
783 LikelihoodScaleMetadata::EstimatedGammaShape { shape: 4.5 },
784 0.123,
785 &gamma,
786 ),
787 Some(4.5),
788 "Gamma picker must read shape_hat (#678), not the residual-scale fallback"
789 );
790 }
791
792 #[test]
797 fn family_noise_parameter_falls_back_to_seed_when_unfitted() {
798 let none = LikelihoodScaleMetadata::ProfiledGaussian;
801 assert_eq!(
802 family_noise_parameter(none, 0.0, &LikelihoodSpec::negative_binomial_log(3.5)),
803 Some(3.5),
804 "NB picker must fall back to the spec seed theta"
805 );
806 assert_eq!(
807 family_noise_parameter(none, 0.0, &LikelihoodSpec::beta_logit(8.0)),
808 Some(8.0),
809 "Beta picker must fall back to the spec seed phi"
810 );
811 assert_eq!(
812 family_noise_parameter(none, 0.0, &LikelihoodSpec::tweedie_log(1.5)),
813 Some(1.0),
814 "Tweedie picker must fall back to unit dispersion"
815 );
816 assert_eq!(
817 family_noise_parameter(none, 2.0, &LikelihoodSpec::gamma_log()),
818 Some(2.0),
819 "Gamma picker must fall back to the residual scale"
820 );
821 }
822
823 #[test]
829 fn picker_then_from_likelihood_threads_fitted_nb_theta() {
830 let nobs = 6usize;
831 let seed_spec = LikelihoodSpec::negative_binomial_log(1.0);
832 let scale = LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: 2.751 };
833 let picked = family_noise_parameter(scale, 0.0, &seed_spec);
834 let noise =
835 NoiseModel::from_likelihood(&seed_spec, nobs, picked).expect("NB noise model builds");
836 let NoiseModel::NegativeBinomial { theta } = noise else {
837 panic!("expected an NB observation noise model");
838 };
839 assert!(
840 theta.len() == nobs && theta.iter().all(|&t| (t - 2.751).abs() < 1e-12),
841 "NB generate composes the seed theta=1 instead of theta_hat (#1124): {theta:?}"
842 );
843 }
844
845 #[test]
852 fn gaussian_generativespec_scales_sigma_by_prior_weights() {
853 let sigma_hat = 2.0_f64;
854 let weights = Array1::from(vec![1.0, 4.0, 0.25]);
855 let mean = Array1::from(vec![0.0, 1.0, -1.0]);
856 let prediction = PredictResult {
857 eta: mean.clone(),
858 mean: mean.clone(),
859 };
860 let spec = generativespec_from_predict(
861 prediction,
862 LikelihoodSpec::gaussian_identity(),
863 Some(sigma_hat),
864 Some(&weights),
865 )
866 .expect("weighted Gaussian generative spec builds");
867 let NoiseModel::Gaussian { sigma } = spec.noise else {
868 panic!("expected Gaussian observation noise");
869 };
870 let expected = [2.0_f64, 1.0, 4.0];
872 for (i, (&got, &want)) in sigma.iter().zip(expected.iter()).enumerate() {
873 assert!(
874 (got - want).abs() < 1e-12,
875 "row {i}: sigma must be sigma_hat/sqrt(w_i)={want}, got {got} \
876 (flat sigma_hat={sigma_hat} drops the prior weights, #2025)"
877 );
878 }
879 assert!(
880 sigma.iter().any(|&s| (s - sigma_hat).abs() > 1e-9),
881 "sigma is flat at the pooled scalar; prior weights were dropped (#2025)"
882 );
883
884 let unit = Array1::from_elem(3, 1.0_f64);
886 let unweighted = generativespec_from_predict(
887 PredictResult {
888 eta: mean.clone(),
889 mean,
890 },
891 LikelihoodSpec::gaussian_identity(),
892 Some(sigma_hat),
893 Some(&unit),
894 )
895 .expect("unit-weight Gaussian generative spec builds");
896 let NoiseModel::Gaussian { sigma: flat } = unweighted.noise else {
897 panic!("expected Gaussian observation noise");
898 };
899 assert!(
900 flat.iter().all(|&s| (s - sigma_hat).abs() < 1e-12),
901 "unit prior weights must leave sigma at the pooled scalar sigma_hat"
902 );
903 }
904
905 fn noise_models_match(a: &NoiseModel, b: &NoiseModel) -> bool {
909 match (a, b) {
910 (NoiseModel::Gaussian { sigma: sa }, NoiseModel::Gaussian { sigma: sb }) => sa == sb,
911 (NoiseModel::Poisson, NoiseModel::Poisson) => true,
912 (NoiseModel::Bernoulli, NoiseModel::Bernoulli) => true,
913 (NoiseModel::Tweedie { p: pa, phi: pha }, NoiseModel::Tweedie { p: pb, phi: phb }) => {
914 pa == pb && pha == phb
915 }
916 (
917 NoiseModel::NegativeBinomial { theta: ta },
918 NoiseModel::NegativeBinomial { theta: tb },
919 ) => ta == tb,
920 (NoiseModel::Beta { phi: pa }, NoiseModel::Beta { phi: pb }) => pa == pb,
921 (NoiseModel::Gamma { shape: sa }, NoiseModel::Gamma { shape: sb }) => sa == sb,
922 _ => false,
923 }
924 }
925
926 #[test]
932 fn from_likelihood_matches_simulate_noise_for_each_family() {
933 let nobs = 5usize;
934 let mean = Array1::from_elem(nobs, 0.5_f64);
935
936 let cases: [(LikelihoodSpec, Option<f64>, NoiseModel); 7] = [
938 (
939 LikelihoodSpec::gaussian_identity(),
940 Some(0.7),
941 NoiseModel::Gaussian {
942 sigma: Array1::from_elem(nobs, 0.7),
943 },
944 ),
945 (
946 LikelihoodSpec::binomial_logit(),
947 None,
948 NoiseModel::Bernoulli,
949 ),
950 (LikelihoodSpec::poisson_log(), None, NoiseModel::Poisson),
951 (
952 LikelihoodSpec::tweedie_log(1.4),
953 Some(0.9),
954 NoiseModel::Tweedie {
955 p: 1.4,
956 phi: Array1::from_elem(nobs, 0.9),
957 },
958 ),
959 (
960 LikelihoodSpec::negative_binomial_log(2.5),
961 None,
962 NoiseModel::NegativeBinomial {
963 theta: Array1::from_elem(nobs, 2.5),
964 },
965 ),
966 (
967 LikelihoodSpec::beta_logit(3.0),
968 None,
969 NoiseModel::Beta {
970 phi: Array1::from_elem(nobs, 3.0),
971 },
972 ),
973 (
974 LikelihoodSpec::gamma_log(),
975 Some(1.5),
976 NoiseModel::Gamma {
977 shape: Array1::from_elem(nobs, 1.5),
978 },
979 ),
980 ];
981
982 for (spec, scale, expected) in cases {
983 let from_helper = NoiseModel::from_likelihood(&spec, nobs, scale)
984 .expect("canonical mapping must accept a supported family");
985 let from_strategy = strategy_for_spec(&spec)
986 .simulate_noise(&mean, scale)
987 .expect("simulation adapter must accept a supported family");
988
989 assert!(
990 noise_models_match(&from_helper, &expected),
991 "{} canonical mapping produced an unexpected NoiseModel",
992 spec.pretty_name()
993 );
994 assert!(
995 noise_models_match(&from_helper, &from_strategy),
996 "{} simulation and inference disagree on the NoiseModel",
997 spec.pretty_name()
998 );
999 }
1000 }
1001
1002 #[test]
1006 fn royston_parmar_rejected_on_both_paths() {
1007 let spec = LikelihoodSpec::royston_parmar();
1008 let mean = Array1::from_elem(3, 0.0_f64);
1009 assert!(NoiseModel::from_likelihood(&spec, 3, None).is_err());
1010 assert!(
1011 strategy_for_spec(&spec)
1012 .simulate_noise(&mean, None)
1013 .is_err()
1014 );
1015 }
1016
1017 #[test]
1020 fn invalid_dispersion_rejected_on_both_paths() {
1021 let mean = Array1::from_elem(4, 0.0_f64);
1022
1023 let gauss = LikelihoodSpec::gaussian_identity();
1025 assert!(NoiseModel::from_likelihood(&gauss, 4, None).is_err());
1026 assert!(
1027 strategy_for_spec(&gauss)
1028 .simulate_noise(&mean, None)
1029 .is_err()
1030 );
1031
1032 let bad_tweedie = LikelihoodSpec::tweedie_log(2.5);
1034 assert!(NoiseModel::from_likelihood(&bad_tweedie, 4, Some(0.5)).is_err());
1035 assert!(
1036 strategy_for_spec(&bad_tweedie)
1037 .simulate_noise(&mean, Some(0.5))
1038 .is_err()
1039 );
1040
1041 let gamma = LikelihoodSpec::gamma_log();
1043 assert!(NoiseModel::from_likelihood(&gamma, 4, Some(-1.0)).is_err());
1044 assert!(
1045 strategy_for_spec(&gamma)
1046 .simulate_noise(&mean, Some(-1.0))
1047 .is_err()
1048 );
1049 }
1050}