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