1use crate::inference::predict_io::PredictResult;
2use crate::transformation_normal::CtnTransformTable;
3use gam_custom_family::{CustomFamily, ParameterBlockState};
4use gam_problem::types::{
5 LikelihoodScaleMetadata, LikelihoodSpec, ResponseFamily, is_valid_tweedie_power,
6};
7use gam_solve::estimate::EstimationError;
8use ndarray::{Array1, Array2};
9use rand::RngExt as _;
10
11pub fn family_noise_parameter(
41 scale: LikelihoodScaleMetadata,
42 standard_deviation: f64,
43 likelihood: &LikelihoodSpec,
44) -> Result<Option<f64>, EstimationError> {
45 let invalid = |reason: String| {
46 EstimationError::InvalidInput(format!(
47 "{} generative scale is unresolved: {reason}",
48 likelihood.pretty_name()
49 ))
50 };
51 let positive = |name: &str, value: f64| {
52 if value.is_finite() && value > 0.0 {
53 Ok(Some(value))
54 } else {
55 Err(invalid(format!(
56 "{name} must be finite and strictly positive, got {value}"
57 )))
58 }
59 };
60 match (&likelihood.response, scale) {
61 (ResponseFamily::Gaussian, LikelihoodScaleMetadata::ProfiledGaussian) => {
62 if standard_deviation.is_finite() && standard_deviation >= 0.0 {
63 Ok(Some(if standard_deviation == 0.0 {
64 0.0
65 } else {
66 standard_deviation
67 }))
68 } else {
69 Err(invalid(format!(
70 "profiled Gaussian sigma must be finite and non-negative, got {standard_deviation}"
71 )))
72 }
73 }
74 (ResponseFamily::Gaussian, LikelihoodScaleMetadata::FixedDispersion { phi }) => {
75 positive("fixed Gaussian dispersion", phi).map(|_| Some(phi.sqrt()))
76 }
77 (
82 ResponseFamily::Tweedie { .. },
83 LikelihoodScaleMetadata::EstimatedTweediePhi { phi }
84 | LikelihoodScaleMetadata::FixedDispersion { phi },
85 ) => positive("Tweedie dispersion phi", phi),
86 (
89 ResponseFamily::NegativeBinomial {
90 theta: _,
91 theta_fixed: false,
92 },
93 LikelihoodScaleMetadata::EstimatedNegBinTheta {
94 theta: metadata_theta,
95 },
96 )
97 | (
98 ResponseFamily::NegativeBinomial {
99 theta: _,
100 theta_fixed: true,
101 },
102 LikelihoodScaleMetadata::FixedNegBinTheta {
103 theta: metadata_theta,
104 },
105 ) => positive("negative-binomial theta", metadata_theta),
106 (
109 ResponseFamily::Beta { .. },
110 LikelihoodScaleMetadata::EstimatedBetaPhi { phi: metadata_phi },
111 ) => positive("Beta precision phi", metadata_phi),
112 (ResponseFamily::Gamma, LikelihoodScaleMetadata::FixedGammaShape { shape })
115 | (ResponseFamily::Gamma, LikelihoodScaleMetadata::EstimatedGammaShape { shape }) => {
116 positive("Gamma shape", shape)
117 }
118 (
121 ResponseFamily::Binomial | ResponseFamily::Poisson,
122 LikelihoodScaleMetadata::FixedDispersion { phi },
123 ) if phi.to_bits() == 1.0_f64.to_bits() => Ok(None),
124 (ResponseFamily::RoystonParmar, _) => Err(invalid(
125 "Royston-Parmar has no generic scalar generative noise parameter".to_string(),
126 )),
127 (_, metadata) => Err(invalid(format!(
128 "family and likelihood-scale metadata are inconsistent: {metadata:?}"
129 ))),
130 }
131}
132
133#[derive(Clone, Debug)]
135pub enum NoiseModel {
136 Gaussian {
137 sigma: Array1<f64>,
139 },
140 Poisson,
141 Tweedie {
142 p: f64,
143 phi: Array1<f64>,
147 },
148 NegativeBinomial {
149 theta: Array1<f64>,
151 },
152 Beta {
153 phi: Array1<f64>,
155 },
156 Gamma {
157 shape: Array1<f64>,
160 },
161 Bernoulli,
162 Categorical {
169 probabilities: Array2<f64>,
170 labels: Array1<f64>,
171 },
172 TransformationNormalQuantile {
193 table: CtnTransformTable,
195 },
196}
197
198#[derive(Clone, Debug)]
200pub struct GenerativeSpec {
201 pub mean: Array1<f64>,
202 pub noise: NoiseModel,
203}
204
205impl GenerativeSpec {
206 pub fn nobs(&self) -> usize {
209 self.mean.len()
210 }
211}
212
213pub fn generativespec_from_predict(
215 prediction: PredictResult,
216 likelihood: LikelihoodSpec,
217 gaussian_scale: Option<f64>,
218 prior_weights: Option<&Array1<f64>>,
219) -> Result<GenerativeSpec, EstimationError> {
220 let mut noise =
221 NoiseModel::from_likelihood(&likelihood, prediction.mean.len(), gaussian_scale)?;
222 if let (NoiseModel::Gaussian { sigma }, Some(weights)) = (&mut noise, prior_weights) {
231 scale_gaussian_sigma_by_prior_weights(sigma, weights)?;
232 }
233 Ok(GenerativeSpec {
234 mean: prediction.mean,
235 noise,
236 })
237}
238
239fn scale_gaussian_sigma_by_prior_weights(
245 sigma: &mut Array1<f64>,
246 weights: &Array1<f64>,
247) -> Result<(), EstimationError> {
248 if weights.len() != sigma.len() {
249 crate::bail_invalid_estim!(
250 "prior weights length {} does not match observation count {}",
251 weights.len(),
252 sigma.len()
253 );
254 }
255 for (s, &w) in sigma.iter_mut().zip(weights.iter()) {
256 if !(w.is_finite() && w > 0.0) {
257 crate::bail_invalid_estim!(
258 "Gaussian replicate prior weights must be finite and > 0; got {w}"
259 );
260 }
261 *s /= w.sqrt();
262 }
263 Ok(())
264}
265
266impl NoiseModel {
267 pub fn from_likelihood(
279 likelihood: &LikelihoodSpec,
280 nobs: usize,
281 gaussian_scale: Option<f64>,
282 ) -> Result<NoiseModel, EstimationError> {
283 match &likelihood.response {
284 ResponseFamily::Gaussian => {
285 let sigma =
286 Self::require_noise_parameter(likelihood, "Gaussian sigma", gaussian_scale)?;
287 if sigma < 0.0 {
288 crate::bail_invalid_estim!(
289 "{} generative sampling requires Gaussian sigma >= 0; got {sigma}",
290 likelihood.pretty_name()
291 );
292 }
293 Ok(NoiseModel::Gaussian {
294 sigma: Array1::from_elem(nobs, sigma),
295 })
296 }
297 ResponseFamily::Binomial => Ok(NoiseModel::Bernoulli),
298 ResponseFamily::Poisson => Ok(NoiseModel::Poisson),
299 ResponseFamily::Tweedie { p } => {
300 let p = *p;
301 if !is_valid_tweedie_power(p) {
302 crate::bail_invalid_estim!(
303 "Tweedie variance power must be finite and strictly between 1 and 2; got {p}"
304 );
305 }
306 let phi = Self::require_positive_noise_parameter(
307 likelihood,
308 "Tweedie dispersion phi",
309 gaussian_scale,
310 )?;
311 Ok(NoiseModel::Tweedie {
312 p,
313 phi: Array1::from_elem(nobs, phi),
317 })
318 }
319 ResponseFamily::NegativeBinomial { .. } => {
320 let theta = Self::require_positive_noise_parameter(
328 likelihood,
329 "negative-binomial theta",
330 gaussian_scale,
331 )?;
332 Ok(NoiseModel::NegativeBinomial {
333 theta: Array1::from_elem(nobs, theta),
334 })
335 }
336 ResponseFamily::Beta { .. } => {
337 let phi = Self::require_positive_noise_parameter(
366 likelihood,
367 "beta-regression phi",
368 gaussian_scale.or_else(|| likelihood.fixed_dispersion()),
369 )?;
370 Ok(NoiseModel::Beta {
371 phi: Array1::from_elem(nobs, phi),
372 })
373 }
374 ResponseFamily::Gamma => {
375 let shape = Self::require_positive_noise_parameter(
376 likelihood,
377 "Gamma shape",
378 gaussian_scale,
379 )?;
380 Ok(NoiseModel::Gamma {
381 shape: Array1::from_elem(nobs, shape),
382 })
383 }
384 ResponseFamily::RoystonParmar => Err(EstimationError::InvalidInput(
385 "RoystonParmar generative sampling is not exposed via generic generation"
386 .to_string(),
387 )),
388 }
389 }
390
391 pub fn from_likelihood_with_per_row_dispersion(
400 likelihood: &LikelihoodSpec,
401 dispersion: Array1<f64>,
402 ) -> Result<NoiseModel, EstimationError> {
403 for (index, &value) in dispersion.iter().enumerate() {
404 if !(value.is_finite() && value > 0.0) {
405 crate::bail_invalid_estim!(
406 "{} per-row generative dispersion at index {index} must be finite and strictly positive, got {value}",
407 likelihood.pretty_name()
408 );
409 }
410 }
411 match &likelihood.response {
412 ResponseFamily::Tweedie { p } => {
413 let p = *p;
414 if !is_valid_tweedie_power(p) {
415 crate::bail_invalid_estim!(
416 "Tweedie variance power must be finite and strictly between 1 and 2; got {p}"
417 );
418 }
419 Ok(NoiseModel::Tweedie { p, phi: dispersion })
420 }
421 ResponseFamily::NegativeBinomial { .. } => {
422 Ok(NoiseModel::NegativeBinomial { theta: dispersion })
423 }
424 ResponseFamily::Beta { .. } => Ok(NoiseModel::Beta { phi: dispersion }),
425 ResponseFamily::Gamma => Ok(NoiseModel::Gamma { shape: dispersion }),
426 other => Err(EstimationError::InvalidInput(format!(
427 "per-row dispersion generative sampling is only defined for the dispersion \
428 location-scale families (Gamma/NegativeBinomial/Beta/Tweedie); got {other:?}"
429 ))),
430 }
431 }
432
433 fn require_noise_parameter(
434 likelihood: &LikelihoodSpec,
435 parameter_name: &str,
436 value: Option<f64>,
437 ) -> Result<f64, EstimationError> {
438 let value = value.ok_or_else(|| {
439 EstimationError::InvalidInput(format!(
440 "{} generative sampling requires fitted {parameter_name}",
441 likelihood.pretty_name()
442 ))
443 })?;
444 if value.is_finite() {
445 Ok(value)
446 } else {
447 Err(EstimationError::InvalidInput(format!(
448 "{} generative sampling requires finite {parameter_name}; got {value}",
449 likelihood.pretty_name()
450 )))
451 }
452 }
453
454 fn require_positive_noise_parameter(
455 likelihood: &LikelihoodSpec,
456 parameter_name: &str,
457 value: Option<f64>,
458 ) -> Result<f64, EstimationError> {
459 let value = Self::require_noise_parameter(likelihood, parameter_name, value)?;
460 if value > 0.0 {
461 Ok(value)
462 } else {
463 Err(EstimationError::InvalidInput(format!(
464 "{} generative sampling requires {parameter_name} > 0; got {value}",
465 likelihood.pretty_name()
466 )))
467 }
468 }
469}
470
471fn check_dispersion_len(
475 dispersion: &Array1<f64>,
476 nobs: usize,
477 name: &str,
478) -> Result<(), EstimationError> {
479 if dispersion.len() != nobs {
480 crate::bail_invalid_estim!(
481 "{name} length {} does not match mean length {nobs}",
482 dispersion.len()
483 );
484 }
485 Ok(())
486}
487
488pub fn sampleobservations<R: rand::Rng + ?Sized>(
490 spec: &GenerativeSpec,
491 rng: &mut R,
492) -> Result<Array1<f64>, EstimationError> {
493 if spec.mean.iter().any(|m| !m.is_finite()) {
494 crate::bail_invalid_estim!("generative mean contains non-finite values");
495 }
496 match &spec.noise {
497 NoiseModel::Gaussian { sigma } => {
498 if sigma.len() != spec.mean.len() {
499 crate::bail_invalid_estim!(
500 "Gaussian sigma length {} does not match mean length {}",
501 sigma.len(),
502 spec.mean.len()
503 );
504 }
505 let mut y = spec.mean.clone();
506 for i in 0..y.len() {
507 let sd = sigma[i];
508 if !(sd.is_finite() && sd >= 0.0) {
509 crate::bail_invalid_estim!(
510 "Gaussian sigma at row {i} must be finite and non-negative, got {sd}"
511 );
512 }
513 if sd == 0.0 {
514 continue;
515 }
516 let dist = rand_distr::Normal::new(0.0, sd).map_err(|e| {
517 EstimationError::InvalidInput(format!("invalid Gaussian noise scale {sd}: {e}"))
518 })?;
519 y[i] += rand_distr::Distribution::sample(&dist, rng);
520 }
521 Ok(y)
522 }
523 NoiseModel::Poisson => {
524 let mut y = Array1::<f64>::zeros(spec.mean.len());
525 for i in 0..y.len() {
526 let lam = spec.mean[i];
527 if lam < 0.0 {
528 crate::bail_invalid_estim!(
529 "Poisson mean at row {i} must be non-negative, got {lam}"
530 );
531 }
532 if lam == 0.0 {
533 continue;
534 }
535 let dist = rand_distr::Poisson::new(lam).map_err(|e| {
536 EstimationError::InvalidInput(format!("invalid Poisson rate {lam}: {e}"))
537 })?;
538 let draw = rand_distr::Distribution::sample(&dist, rng);
539 y[i] = draw;
540 }
541 Ok(y)
542 }
543 NoiseModel::Tweedie { p, phi } => {
544 if !(p.is_finite() && *p >= 1.0 && *p <= 2.0) {
545 crate::bail_invalid_estim!("invalid Tweedie power p: {p}");
546 }
547 check_dispersion_len(phi, spec.mean.len(), "Tweedie dispersion phi")?;
548 for (i, &phi_i) in phi.iter().enumerate() {
549 if !(phi_i.is_finite() && phi_i > 0.0) {
550 crate::bail_invalid_estim!(
551 "invalid Tweedie dispersion phi at row {i}: {phi_i}"
552 );
553 }
554 }
555 let mut y = Array1::<f64>::zeros(spec.mean.len());
556 if (*p - 1.0).abs() <= 1.0e-12 {
557 for i in 0..y.len() {
558 let phi_i = phi[i];
559 let mu = spec.mean[i];
560 if mu < 0.0 {
561 crate::bail_invalid_estim!(
562 "Tweedie-Poisson mean at row {i} must be non-negative, got {mu}"
563 );
564 }
565 if mu == 0.0 {
566 continue;
567 }
568 let lam = mu / phi_i;
569 if !(lam.is_finite() && lam > 0.0) {
570 crate::bail_invalid_estim!(
571 "Tweedie-Poisson rate at row {i} is not representable: {mu}/{phi_i}"
572 );
573 }
574 let dist = rand_distr::Poisson::new(lam).map_err(|e| {
575 EstimationError::InvalidInput(format!(
576 "invalid Tweedie-Poisson rate {lam}: {e}"
577 ))
578 })?;
579 y[i] = phi_i * rand_distr::Distribution::sample(&dist, rng);
580 }
581 return Ok(y);
582 }
583 if (*p - 2.0).abs() <= 1.0e-12 {
584 for i in 0..y.len() {
585 let phi_i = phi[i];
586 let mu = spec.mean[i];
587 if mu < 0.0 {
588 crate::bail_invalid_estim!(
589 "Tweedie-Gamma mean at row {i} must be non-negative, got {mu}"
590 );
591 }
592 if mu == 0.0 {
593 continue;
594 }
595 let shape = 1.0 / phi_i;
596 let scale = mu * phi_i;
597 if !(shape.is_finite() && shape > 0.0) {
598 crate::bail_invalid_estim!(
599 "Tweedie-Gamma reciprocal dispersion at row {i} is not representable: 1/{phi_i}"
600 );
601 }
602 if !(scale.is_finite() && scale > 0.0) {
603 crate::bail_invalid_estim!(
604 "Tweedie-Gamma scale at row {i} is not representable: {mu}*{phi_i}"
605 );
606 }
607 let dist = rand_distr::Gamma::new(shape, scale).map_err(|e| {
608 EstimationError::InvalidInput(format!(
609 "invalid Tweedie-Gamma params shape={shape} scale={scale}: {e}"
610 ))
611 })?;
612 y[i] = rand_distr::Distribution::sample(&dist, rng);
613 }
614 return Ok(y);
615 }
616 let alpha = (2.0 - *p) / (*p - 1.0);
617 for i in 0..y.len() {
618 let phi_i = phi[i];
619 let mu = spec.mean[i];
620 if mu < 0.0 {
621 crate::bail_invalid_estim!(
622 "Tweedie mean at row {i} must be non-negative, got {mu}"
623 );
624 }
625 if mu == 0.0 {
626 continue;
627 }
628 let log_lambda = (2.0 - *p) * mu.ln() - phi_i.ln() - (2.0 - *p).ln();
629 let log_scale = phi_i.ln() + (*p - 1.0).ln() + (*p - 1.0) * mu.ln();
630 let lambda = log_lambda.exp();
631 let scale = log_scale.exp();
632 if !(lambda.is_finite() && lambda > 0.0) {
633 crate::bail_invalid_estim!(
634 "Tweedie compound-Poisson rate at row {i} is not representable (log rate {log_lambda})"
635 );
636 }
637 if !(scale.is_finite() && scale > 0.0) {
638 crate::bail_invalid_estim!(
639 "Tweedie jump scale at row {i} is not representable (log scale {log_scale})"
640 );
641 }
642 let count_dist = rand_distr::Poisson::new(lambda).map_err(|e| {
643 EstimationError::InvalidInput(format!(
644 "invalid Tweedie compound-Poisson rate {lambda}: {e}"
645 ))
646 })?;
647 let count = rand_distr::Distribution::sample(&count_dist, rng) as usize;
648 if count == 0 {
649 continue;
650 }
651 let jump_dist = rand_distr::Gamma::new(alpha, scale).map_err(|e| {
652 EstimationError::InvalidInput(format!(
653 "invalid Tweedie jump params shape={alpha} scale={scale}: {e}"
654 ))
655 })?;
656 y[i] = (0..count)
657 .map(|_| rand_distr::Distribution::sample(&jump_dist, rng))
658 .sum();
659 }
660 Ok(y)
661 }
662 NoiseModel::NegativeBinomial { theta } => {
663 check_dispersion_len(theta, spec.mean.len(), "NegativeBinomial theta")?;
664 let mut y = Array1::<f64>::zeros(spec.mean.len());
665 for i in 0..y.len() {
666 let theta_i = theta[i];
667 if !(theta_i.is_finite() && theta_i > 0.0) {
668 crate::bail_invalid_estim!(
669 "invalid negative-binomial theta at row {i}: {theta_i}"
670 );
671 }
672 let mu = spec.mean[i];
673 if mu < 0.0 {
674 crate::bail_invalid_estim!(
675 "negative-binomial mean at row {i} must be non-negative, got {mu}"
676 );
677 }
678 if mu == 0.0 {
679 continue;
680 }
681 let scale = mu / theta_i;
682 if !(scale.is_finite() && scale > 0.0) {
683 crate::bail_invalid_estim!(
684 "negative-binomial Gamma-mixture scale at row {i} is not representable: {mu}/{theta_i}"
685 );
686 }
687 let gamma = rand_distr::Gamma::new(theta_i, scale).map_err(|e| {
688 EstimationError::InvalidInput(format!(
689 "invalid NegativeBinomial gamma mixture params theta={theta_i} scale={scale}: {e}"
690 ))
691 })?;
692 let lambda = rand_distr::Distribution::sample(&gamma, rng);
693 if lambda == 0.0 {
694 continue;
695 }
696 if !lambda.is_finite() {
697 crate::bail_invalid_estim!(
698 "negative-binomial latent Poisson rate at row {i} is non-finite"
699 );
700 }
701 let poisson = rand_distr::Poisson::new(lambda).map_err(|e| {
702 EstimationError::InvalidInput(format!(
703 "invalid NegativeBinomial Poisson rate {lambda}: {e}"
704 ))
705 })?;
706 y[i] = rand_distr::Distribution::sample(&poisson, rng);
707 }
708 Ok(y)
709 }
710 NoiseModel::Beta { phi } => {
711 check_dispersion_len(phi, spec.mean.len(), "Beta phi")?;
712 let mut y = Array1::<f64>::zeros(spec.mean.len());
713 for i in 0..y.len() {
714 let phi_i = phi[i];
715 if !(phi_i.is_finite() && phi_i > 0.0) {
716 crate::bail_invalid_estim!("invalid beta-regression phi at row {i}: {phi_i}");
717 }
718 let mu = spec.mean[i];
719 if !(mu > 0.0 && mu < 1.0) {
720 crate::bail_invalid_estim!(
721 "Beta mean at row {i} must lie strictly in (0, 1), got {mu}"
722 );
723 }
724 let alpha = mu * phi_i;
725 let beta = (1.0 - mu) * phi_i;
726 if !(alpha.is_finite() && alpha > 0.0 && beta.is_finite() && beta > 0.0) {
727 crate::bail_invalid_estim!(
728 "Beta shape parameters at row {i} are not representable: alpha={alpha}, beta={beta}"
729 );
730 }
731 let dist = rand_distr::Beta::new(alpha, beta).map_err(|e| {
732 EstimationError::InvalidInput(format!(
733 "invalid Beta params alpha={alpha} beta={beta}: {e}"
734 ))
735 })?;
736 y[i] = rand_distr::Distribution::sample(&dist, rng);
737 }
738 Ok(y)
739 }
740 NoiseModel::Gamma { shape } => {
741 check_dispersion_len(shape, spec.mean.len(), "Gamma shape")?;
742 let mut y = Array1::<f64>::zeros(spec.mean.len());
743 for i in 0..y.len() {
744 let shape_i = shape[i];
745 if !shape_i.is_finite() || shape_i <= 0.0 {
746 crate::bail_invalid_estim!("invalid Gamma shape at row {i}: {shape_i}");
747 }
748 let mu = spec.mean[i];
749 if !(mu > 0.0) {
750 crate::bail_invalid_estim!(
751 "Gamma mean at row {i} must be strictly positive, got {mu}"
752 );
753 }
754 let scale = mu / shape_i;
755 if !(scale.is_finite() && scale > 0.0) {
756 crate::bail_invalid_estim!(
757 "Gamma scale at row {i} is not representable: {mu}/{shape_i}"
758 );
759 }
760 let dist = rand_distr::Gamma::new(shape_i, scale).map_err(|e| {
761 EstimationError::InvalidInput(format!(
762 "invalid Gamma params shape={shape_i} scale={scale}: {e}"
763 ))
764 })?;
765 y[i] = rand_distr::Distribution::sample(&dist, rng);
766 }
767 Ok(y)
768 }
769 NoiseModel::Bernoulli => {
770 let mut y = Array1::<f64>::zeros(spec.mean.len());
771 for i in 0..y.len() {
772 let p = spec.mean[i];
773 let dist = rand_distr::Bernoulli::new(p).map_err(|e| {
774 EstimationError::InvalidInput(format!("invalid Bernoulli probability {p}: {e}"))
775 })?;
776 y[i] = if rand_distr::Distribution::sample(&dist, rng) {
777 1.0
778 } else {
779 0.0
780 };
781 }
782 Ok(y)
783 }
784 NoiseModel::Categorical {
785 probabilities,
786 labels,
787 } => {
788 let n = spec.mean.len();
789 if probabilities.nrows() != n {
790 crate::bail_invalid_estim!(
791 "categorical probability rows {} do not match mean length {n}",
792 probabilities.nrows()
793 );
794 }
795 if labels.is_empty() || probabilities.ncols() != labels.len() {
796 crate::bail_invalid_estim!(
797 "categorical label/probability width mismatch: labels={}, columns={}",
798 labels.len(),
799 probabilities.ncols()
800 );
801 }
802 if labels.iter().any(|label| !label.is_finite()) {
803 crate::bail_invalid_estim!("categorical labels must be finite");
804 }
805 let mut y = Array1::<f64>::zeros(n);
806 for row in 0..n {
807 let probability_row = probabilities.row(row);
808 let mut total = 0.0_f64;
809 for (category, &probability) in probability_row.iter().enumerate() {
810 if !(probability.is_finite() && probability >= 0.0) {
811 crate::bail_invalid_estim!(
812 "categorical probability at row {row}, category {category} must be finite and non-negative, got {probability}"
813 );
814 }
815 total += probability;
816 }
817 let tolerance = 64.0 * f64::EPSILON * labels.len().max(1) as f64;
818 if !(total.is_finite() && (total - 1.0).abs() <= tolerance) {
819 crate::bail_invalid_estim!(
820 "categorical probabilities at row {row} sum to {total}, expected one within {tolerance}"
821 );
822 }
823 let uniform = rng.random::<f64>();
824 let mut cumulative = 0.0_f64;
825 let mut selected = labels.len() - 1;
826 for category in 0..labels.len() - 1 {
827 cumulative += probability_row[category];
828 if uniform < cumulative {
829 selected = category;
830 break;
831 }
832 }
833 y[row] = labels[selected];
834 }
835 Ok(y)
836 }
837 NoiseModel::TransformationNormalQuantile { table } => {
838 let n = spec.mean.len();
839 if table.nrows() != n {
840 crate::bail_invalid_estim!(
841 "transformation-normal transform table has {} rows but mean length is {n}",
842 table.nrows()
843 );
844 }
845 let dist = rand_distr::Normal::new(0.0, 1.0).map_err(|e| {
851 EstimationError::InvalidInput(format!(
852 "invalid standard-normal latent sampler: {e}"
853 ))
854 })?;
855 let mut y = Array1::<f64>::zeros(n);
856 for i in 0..n {
857 let z: f64 = rand_distr::Distribution::sample(&dist, rng);
858 y[i] = table.invert(i, z);
859 }
860 Ok(y)
861 }
862 }
863}
864
865pub fn sampleobservation_replicate_chunks<R, F>(
874 spec: &GenerativeSpec,
875 n_draws: usize,
876 chunk_draws: usize,
877 rng: &mut R,
878 mut consume: F,
879) -> Result<(), EstimationError>
880where
881 R: rand::Rng + ?Sized,
882 F: for<'a> FnMut(usize, ndarray::ArrayView2<'a, f64>) -> Result<(), EstimationError>,
883{
884 if chunk_draws == 0 {
885 crate::bail_invalid_estim!("replicate chunk size must be strictly positive");
886 }
887 if n_draws == 0 {
888 return Ok(());
889 }
890 let n = spec.nobs();
891 let capacity = chunk_draws.min(n_draws);
892 let mut chunk = Array2::<f64>::zeros((capacity, n));
893 let mut start = 0usize;
894 while start < n_draws {
895 let len = (n_draws - start).min(capacity);
896 for local_draw in 0..len {
897 let draw = sampleobservations(spec, rng)?;
898 chunk.row_mut(local_draw).assign(&draw);
899 }
900 consume(start, chunk.slice(ndarray::s![..len, ..]))?;
901 start += len;
902 }
903 Ok(())
904}
905
906#[inline]
914fn indexed_replicate_seed(seed: u64, draw_index: u64) -> u64 {
915 let mut value =
916 seed.wrapping_add(0x9E3779B97F4A7C15_u64.wrapping_mul(draw_index.wrapping_add(1)));
917 value = (value ^ (value >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
918 value = (value ^ (value >> 27)).wrapping_mul(0x94D049BB133111EB);
919 value ^ (value >> 31)
920}
921
922pub fn sampleobservation_seeded_replicate_chunks<F>(
929 spec: &GenerativeSpec,
930 draw_start: usize,
931 n_draws: usize,
932 chunk_draws: usize,
933 seed: u64,
934 mut consume: F,
935) -> Result<(), EstimationError>
936where
937 F: for<'a> FnMut(usize, ndarray::ArrayView2<'a, f64>) -> Result<(), EstimationError>,
938{
939 use rand::SeedableRng;
940
941 if chunk_draws == 0 {
942 crate::bail_invalid_estim!("replicate chunk size must be strictly positive");
943 }
944 let draw_end = draw_start.checked_add(n_draws).ok_or_else(|| {
945 EstimationError::InvalidInput(format!(
946 "replicate draw range overflows usize: start={draw_start}, count={n_draws}"
947 ))
948 })?;
949 if n_draws == 0 {
950 return Ok(());
951 }
952 let n = spec.nobs();
953 let capacity = chunk_draws.min(n_draws);
954 let mut chunk = Array2::<f64>::zeros((capacity, n));
955 let mut start = draw_start;
956 while start < draw_end {
957 let len = (draw_end - start).min(capacity);
958 for local_draw in 0..len {
959 let global_draw = start + local_draw;
960 let global_draw_u64 = u64::try_from(global_draw).map_err(|_| {
961 EstimationError::InvalidInput(format!(
962 "replicate draw index {global_draw} is not representable as u64"
963 ))
964 })?;
965 let mut rng =
966 rand::rngs::StdRng::seed_from_u64(indexed_replicate_seed(seed, global_draw_u64));
967 let draw = sampleobservations(spec, &mut rng)?;
968 chunk.row_mut(local_draw).assign(&draw);
969 }
970 consume(start, chunk.slice(ndarray::s![..len, ..]))?;
971 start += len;
972 }
973 Ok(())
974}
975
976pub fn sampleobservation_seeded_replicates(
978 spec: &GenerativeSpec,
979 draw_start: usize,
980 n_draws: usize,
981 seed: u64,
982) -> Result<Array2<f64>, EstimationError> {
983 let mut out = Array2::<f64>::zeros((n_draws, spec.nobs()));
984 sampleobservation_seeded_replicate_chunks(
985 spec,
986 draw_start,
987 n_draws,
988 n_draws.max(1),
989 seed,
990 |global_start, chunk| {
991 let local_start = global_start - draw_start;
992 let local_end = local_start + chunk.nrows();
993 out.slice_mut(ndarray::s![local_start..local_end, ..])
994 .assign(&chunk);
995 Ok(())
996 },
997 )?;
998 Ok(out)
999}
1000
1001pub fn sampleobservation_replicates<R: rand::Rng + ?Sized>(
1006 spec: &GenerativeSpec,
1007 n_draws: usize,
1008 rng: &mut R,
1009) -> Result<Array2<f64>, EstimationError> {
1010 let n = spec.nobs();
1011 let mut out = Array2::<f64>::zeros((n_draws, n));
1012 sampleobservation_replicate_chunks(spec, n_draws, n_draws.max(1), rng, |start, chunk| {
1013 let end = start + chunk.nrows();
1014 out.slice_mut(ndarray::s![start..end, ..]).assign(&chunk);
1015 Ok(())
1016 })?;
1017 Ok(out)
1018}
1019
1020pub trait CustomFamilyGenerative: CustomFamily {
1023 fn generativespec(
1024 &self,
1025 block_states: &[ParameterBlockState],
1026 ) -> Result<GenerativeSpec, String>;
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031 use super::*;
1032 use crate::family_runtime::{FamilyStrategy, strategy_for_spec};
1033
1034 #[test]
1035 fn categorical_sampler_draws_only_persisted_labels() {
1036 use rand::SeedableRng;
1037
1038 let spec = GenerativeSpec {
1039 mean: ndarray::array![1.5, 0.0],
1040 noise: NoiseModel::Categorical {
1041 probabilities: ndarray::array![[0.25, 0.75], [1.0, 0.0]],
1042 labels: ndarray::array![0.0, 2.0],
1043 },
1044 };
1045 let mut rng = rand::rngs::StdRng::seed_from_u64(2300);
1046 let draws = sampleobservation_replicates(&spec, 2_000, &mut rng).unwrap();
1047 assert!(
1048 draws
1049 .column(0)
1050 .iter()
1051 .all(|value| *value == 0.0 || *value == 2.0)
1052 );
1053 assert!(draws.column(1).iter().all(|value| *value == 0.0));
1054 let first_mean = draws.column(0).sum() / draws.nrows() as f64;
1055 assert!((first_mean - 1.5).abs() < 0.08, "mean={first_mean}");
1056 }
1057
1058 #[test]
1059 fn replicate_chunk_size_does_not_change_seeded_draw_stream() {
1060 use rand::SeedableRng;
1061
1062 let spec = GenerativeSpec {
1063 mean: ndarray::array![0.2, 0.7, 0.95],
1064 noise: NoiseModel::Bernoulli,
1065 };
1066 let collect = |chunk_draws: usize| {
1067 let mut rng = rand::rngs::StdRng::seed_from_u64(91);
1068 let mut values = Vec::<f64>::new();
1069 sampleobservation_replicate_chunks(&spec, 257, chunk_draws, &mut rng, |_, chunk| {
1070 values.extend(chunk.iter().copied());
1071 Ok(())
1072 })
1073 .unwrap();
1074 values
1075 };
1076 assert_eq!(collect(1), collect(7));
1077 assert_eq!(collect(7), collect(256));
1078 }
1079
1080 #[test]
1081 fn seekable_seeded_ranges_concatenate_bit_exactly() {
1082 let spec = GenerativeSpec {
1083 mean: ndarray::array![1.5, 4.0],
1084 noise: NoiseModel::Poisson,
1085 };
1086 let whole = sampleobservation_seeded_replicates(&spec, 0, 257, 2300).unwrap();
1087 let first = sampleobservation_seeded_replicates(&spec, 0, 91, 2300).unwrap();
1088 let second = sampleobservation_seeded_replicates(&spec, 91, 166, 2300).unwrap();
1089 assert_eq!(whole.slice(ndarray::s![..91, ..]), first.view());
1090 assert_eq!(whole.slice(ndarray::s![91.., ..]), second.view());
1091
1092 let mut streamed = Vec::<f64>::new();
1093 sampleobservation_seeded_replicate_chunks(&spec, 0, 257, 13, 2300, |_, chunk| {
1094 streamed.extend(chunk.iter().copied());
1095 Ok(())
1096 })
1097 .unwrap();
1098 assert_eq!(streamed, whole.iter().copied().collect::<Vec<_>>());
1099 }
1100
1101 #[test]
1109 fn transformation_normal_quantile_sampler_is_inverse_transform() {
1110 use rand::SeedableRng;
1111
1112 let g = 801usize;
1113 let (y_lo, y_hi) = (-12.0_f64, 12.0_f64);
1114 let grid_y =
1115 Array1::from_shape_fn(g, |k| y_lo + (y_hi - y_lo) * (k as f64) / ((g - 1) as f64));
1116 let centers = [-1.0_f64, 2.0_f64];
1118 let slopes = [2.0_f64, 4.0_f64];
1119 let mut h_grid = Array2::<f64>::zeros((2, g));
1120 for i in 0..2 {
1121 for k in 0..g {
1122 h_grid[[i, k]] = slopes[i] * (grid_y[k] - centers[i]);
1123 }
1124 }
1125 let slope_grid = Array2::from_shape_fn((2, g), |(i, _)| slopes[i]);
1127 let table = CtnTransformTable::new(grid_y.clone(), h_grid, slope_grid)
1128 .expect("affine transform table");
1129 let spec = GenerativeSpec {
1130 mean: Array1::from_vec(vec![centers[0], centers[1]]),
1131 noise: NoiseModel::TransformationNormalQuantile { table },
1132 };
1133
1134 let mut rng = rand::rngs::StdRng::seed_from_u64(20240613);
1135 let n_draws = 40_000usize;
1136 let draws = sampleobservation_replicates(&spec, n_draws, &mut rng).unwrap();
1137 assert_eq!(draws.shape(), &[n_draws, 2]);
1138
1139 let mut row_means = [0.0_f64; 2];
1140 for i in 0..2 {
1141 let col = draws.column(i);
1142 let mean = col.sum() / (n_draws as f64);
1143 let var = col.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / (n_draws as f64);
1144 let sd = var.sqrt();
1145 row_means[i] = mean;
1146 assert!(
1147 (mean - centers[i]).abs() < 0.02,
1148 "row {i} draw mean {mean:.4} should be the response-scale center {:.4}",
1149 centers[i]
1150 );
1151 let expected_sd = 1.0 / slopes[i];
1152 assert!(
1153 (sd - expected_sd).abs() < 0.02,
1154 "row {i} draw sd {sd:.4} should be the response-scale 1/slope {expected_sd:.4}, \
1155 not the latent ≈1 of the old Gaussian-noise path"
1156 );
1157 }
1158 assert!(
1161 row_means[1] > row_means[0],
1162 "draw means must increase with center: row0={:.4} row1={:.4}",
1163 row_means[0],
1164 row_means[1]
1165 );
1166 }
1167
1168 #[test]
1175 fn family_noise_parameter_reads_fitted_dispersion_not_seed() {
1176 let nb = LikelihoodSpec::negative_binomial_log(1.0);
1178 assert_eq!(
1179 family_noise_parameter(
1180 LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: 2.97 },
1181 0.0,
1182 &nb,
1183 )
1184 .unwrap(),
1185 Some(2.97),
1186 "NB picker must read theta_hat (#1124), not the seed theta=1"
1187 );
1188
1189 let tw = LikelihoodSpec::tweedie_log(1.5);
1192 assert_eq!(
1193 family_noise_parameter(
1194 LikelihoodScaleMetadata::EstimatedTweediePhi { phi: 7.25 },
1195 0.0,
1196 &tw,
1197 )
1198 .unwrap(),
1199 Some(7.25),
1200 "Tweedie picker must read phi_hat (#771), not the variance power p"
1201 );
1202
1203 let beta = LikelihoodSpec::beta_logit(1.0);
1205 assert_eq!(
1206 family_noise_parameter(
1207 LikelihoodScaleMetadata::EstimatedBetaPhi { phi: 12.0 },
1208 0.0,
1209 &beta,
1210 )
1211 .unwrap(),
1212 Some(12.0),
1213 "Beta picker must read phi_hat (#770), not the seed phi=1"
1214 );
1215
1216 let gamma = LikelihoodSpec::gamma_log();
1218 assert_eq!(
1219 family_noise_parameter(
1220 LikelihoodScaleMetadata::EstimatedGammaShape { shape: 4.5 },
1221 0.123,
1222 &gamma,
1223 )
1224 .unwrap(),
1225 Some(4.5),
1226 "Gamma picker must read shape_hat (#678), not the residual-scale fallback"
1227 );
1228 }
1229
1230 #[test]
1233 fn family_noise_parameter_rejects_unresolved_fit_metadata() {
1234 let none = LikelihoodScaleMetadata::ProfiledGaussian;
1235 assert!(
1236 family_noise_parameter(none, 0.0, &LikelihoodSpec::negative_binomial_log(3.5)).is_err()
1237 );
1238 assert!(family_noise_parameter(none, 0.0, &LikelihoodSpec::beta_logit(8.0)).is_err());
1239 assert!(family_noise_parameter(none, 0.0, &LikelihoodSpec::tweedie_log(1.5)).is_err());
1240 assert!(family_noise_parameter(none, 2.0, &LikelihoodSpec::gamma_log()).is_err());
1241 }
1242
1243 #[test]
1249 fn picker_then_from_likelihood_threads_fitted_nb_theta() {
1250 let nobs = 6usize;
1251 let seed_spec = LikelihoodSpec::negative_binomial_log(1.0);
1252 let scale = LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: 2.751 };
1253 let picked = family_noise_parameter(scale, 0.0, &seed_spec).unwrap();
1254 let noise =
1255 NoiseModel::from_likelihood(&seed_spec, nobs, picked).expect("NB noise model builds");
1256 let NoiseModel::NegativeBinomial { theta } = noise else {
1257 panic!("expected an NB observation noise model");
1258 };
1259 assert!(
1260 theta.len() == nobs && theta.iter().all(|&t| (t - 2.751).abs() < 1e-12),
1261 "NB generate composes the seed theta=1 instead of theta_hat (#1124): {theta:?}"
1262 );
1263 }
1264
1265 #[test]
1272 fn gaussian_generativespec_scales_sigma_by_prior_weights() {
1273 let sigma_hat = 2.0_f64;
1274 let weights = Array1::from(vec![1.0, 4.0, 0.25]);
1275 let mean = Array1::from(vec![0.0, 1.0, -1.0]);
1276 let prediction = PredictResult {
1277 eta: mean.clone(),
1278 mean: mean.clone(),
1279 };
1280 let spec = generativespec_from_predict(
1281 prediction,
1282 LikelihoodSpec::gaussian_identity(),
1283 Some(sigma_hat),
1284 Some(&weights),
1285 )
1286 .expect("weighted Gaussian generative spec builds");
1287 let NoiseModel::Gaussian { sigma } = spec.noise else {
1288 panic!("expected Gaussian observation noise");
1289 };
1290 let expected = [2.0_f64, 1.0, 4.0];
1292 for (i, (&got, &want)) in sigma.iter().zip(expected.iter()).enumerate() {
1293 assert!(
1294 (got - want).abs() < 1e-12,
1295 "row {i}: sigma must be sigma_hat/sqrt(w_i)={want}, got {got} \
1296 (flat sigma_hat={sigma_hat} drops the prior weights, #2025)"
1297 );
1298 }
1299 assert!(
1300 sigma.iter().any(|&s| (s - sigma_hat).abs() > 1e-9),
1301 "sigma is flat at the pooled scalar; prior weights were dropped (#2025)"
1302 );
1303
1304 let unit = Array1::from_elem(3, 1.0_f64);
1306 let unweighted = generativespec_from_predict(
1307 PredictResult {
1308 eta: mean.clone(),
1309 mean,
1310 },
1311 LikelihoodSpec::gaussian_identity(),
1312 Some(sigma_hat),
1313 Some(&unit),
1314 )
1315 .expect("unit-weight Gaussian generative spec builds");
1316 let NoiseModel::Gaussian { sigma: flat } = unweighted.noise else {
1317 panic!("expected Gaussian observation noise");
1318 };
1319 assert!(
1320 flat.iter().all(|&s| (s - sigma_hat).abs() < 1e-12),
1321 "unit prior weights must leave sigma at the pooled scalar sigma_hat"
1322 );
1323 }
1324
1325 fn noise_models_match(a: &NoiseModel, b: &NoiseModel) -> bool {
1329 match (a, b) {
1330 (NoiseModel::Gaussian { sigma: sa }, NoiseModel::Gaussian { sigma: sb }) => sa == sb,
1331 (NoiseModel::Poisson, NoiseModel::Poisson) => true,
1332 (NoiseModel::Bernoulli, NoiseModel::Bernoulli) => true,
1333 (NoiseModel::Tweedie { p: pa, phi: pha }, NoiseModel::Tweedie { p: pb, phi: phb }) => {
1334 pa == pb && pha == phb
1335 }
1336 (
1337 NoiseModel::NegativeBinomial { theta: ta },
1338 NoiseModel::NegativeBinomial { theta: tb },
1339 ) => ta == tb,
1340 (NoiseModel::Beta { phi: pa }, NoiseModel::Beta { phi: pb }) => pa == pb,
1341 (NoiseModel::Gamma { shape: sa }, NoiseModel::Gamma { shape: sb }) => sa == sb,
1342 _ => false,
1343 }
1344 }
1345
1346 #[test]
1352 fn from_likelihood_matches_simulate_noise_for_each_family() {
1353 let nobs = 5usize;
1354 let mean = Array1::from_elem(nobs, 0.5_f64);
1355
1356 let cases: [(LikelihoodSpec, Option<f64>, NoiseModel); 7] = [
1367 (
1368 LikelihoodSpec::gaussian_identity(),
1369 Some(0.7),
1370 NoiseModel::Gaussian {
1371 sigma: Array1::from_elem(nobs, 0.7),
1372 },
1373 ),
1374 (
1375 LikelihoodSpec::binomial_logit(),
1376 None,
1377 NoiseModel::Bernoulli,
1378 ),
1379 (LikelihoodSpec::poisson_log(), None, NoiseModel::Poisson),
1380 (
1381 LikelihoodSpec::tweedie_log(1.4),
1382 Some(0.9),
1383 NoiseModel::Tweedie {
1384 p: 1.4,
1385 phi: Array1::from_elem(nobs, 0.9),
1386 },
1387 ),
1388 (
1389 LikelihoodSpec::negative_binomial_log(2.5),
1390 Some(2.5),
1391 NoiseModel::NegativeBinomial {
1392 theta: Array1::from_elem(nobs, 2.5),
1393 },
1394 ),
1395 (
1396 LikelihoodSpec::beta_logit(3.0),
1397 Some(3.0),
1398 NoiseModel::Beta {
1399 phi: Array1::from_elem(nobs, 3.0),
1400 },
1401 ),
1402 (
1403 LikelihoodSpec::gamma_log(),
1404 Some(1.5),
1405 NoiseModel::Gamma {
1406 shape: Array1::from_elem(nobs, 1.5),
1407 },
1408 ),
1409 ];
1410
1411 for (spec, scale, expected) in cases {
1412 let from_helper = NoiseModel::from_likelihood(&spec, nobs, scale)
1413 .expect("canonical mapping must accept a supported family");
1414 let from_strategy = strategy_for_spec(&spec)
1415 .simulate_noise(&mean, scale)
1416 .expect("simulation adapter must accept a supported family");
1417
1418 assert!(
1419 noise_models_match(&from_helper, &expected),
1420 "{} canonical mapping produced an unexpected NoiseModel",
1421 spec.pretty_name()
1422 );
1423 assert!(
1424 noise_models_match(&from_helper, &from_strategy),
1425 "{} simulation and inference disagree on the NoiseModel",
1426 spec.pretty_name()
1427 );
1428 }
1429 }
1430
1431 #[test]
1435 fn royston_parmar_rejected_on_both_paths() {
1436 let spec = LikelihoodSpec::royston_parmar();
1437 let mean = Array1::from_elem(3, 0.0_f64);
1438 assert!(NoiseModel::from_likelihood(&spec, 3, None).is_err());
1439 assert!(
1440 strategy_for_spec(&spec)
1441 .simulate_noise(&mean, None)
1442 .is_err()
1443 );
1444 }
1445
1446 #[test]
1449 fn invalid_dispersion_rejected_on_both_paths() {
1450 let mean = Array1::from_elem(4, 0.0_f64);
1451
1452 let gauss = LikelihoodSpec::gaussian_identity();
1454 assert!(NoiseModel::from_likelihood(&gauss, 4, None).is_err());
1455 assert!(
1456 strategy_for_spec(&gauss)
1457 .simulate_noise(&mean, None)
1458 .is_err()
1459 );
1460
1461 let bad_tweedie = LikelihoodSpec::tweedie_log(2.5);
1463 assert!(NoiseModel::from_likelihood(&bad_tweedie, 4, Some(0.5)).is_err());
1464 assert!(
1465 strategy_for_spec(&bad_tweedie)
1466 .simulate_noise(&mean, Some(0.5))
1467 .is_err()
1468 );
1469
1470 let gamma = LikelihoodSpec::gamma_log();
1472 assert!(NoiseModel::from_likelihood(&gamma, 4, Some(-1.0)).is_err());
1473 assert!(
1474 strategy_for_spec(&gamma)
1475 .simulate_noise(&mean, Some(-1.0))
1476 .is_err()
1477 );
1478 }
1479}