Skip to main content

gam_models/
family_runtime.rs

1use crate::inference::generative::NoiseModel;
2use crate::model_types::{EstimationError, FittedLinkState, UnifiedFitResult};
3use crate::quadrature::{
4    IntegratedMomentsJet, QuadratureContext, cloglog_posterior_meanvariance,
5    integrated_family_moments_jet, integrated_inverse_link_jetwith_state,
6    integrated_inverse_link_mean_and_derivative, logit_posterior_meanvariance,
7    normal_expectation_1d_adaptive, normal_expectation_1d_adaptive_pair,
8    probit_posterior_meanvariance, survival_posterior_mean, survival_posterior_meanvariance,
9};
10use crate::survival::lognormal_kernel::latent_cloglog_inverse_link_jet;
11use gam_problem::{
12    InverseLink, LikelihoodSpec, LinkFunction, ResponseFamily,
13    StandardLink,
14};
15use gam_solve::mixture_link::{
16    InverseLinkJet, inverse_link_jet_for_family_public, mixture_inverse_link_jet,
17};
18use ndarray::{Array1, ArrayView1};
19
20/// Floor on the Bernoulli posterior variance `p(1 - p)`. Keeps the reported
21/// variance strictly positive when the integrated probability saturates at 0
22/// or 1, so downstream weighting / standard-error code never divides by zero.
23/// Matches the `PROB_EPS` floor used for the same `mean·(1 - mean)` variance
24/// in `crate::inference::quadrature`.
25const PROB_VARIANCE_FLOOR: f64 = 1e-12;
26
27/// Runtime family behavior carrier built from a `LikelihoodSpec` (response
28/// distribution + parameterized inverse-link).
29pub trait FamilyStrategy: std::fmt::Debug + Send + Sync {
30    fn name(&self) -> &'static str;
31
32    fn family(&self) -> LikelihoodSpec;
33
34    fn link_function(&self) -> LinkFunction;
35
36    fn inverse_link(&self, eta: f64) -> Result<f64, EstimationError>;
37
38    fn inverse_link_array(&self, eta: ArrayView1<'_, f64>) -> Result<Array1<f64>, EstimationError>;
39
40    fn inverse_link_jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError>;
41
42    fn posterior_mean(
43        &self,
44        quadctx: &QuadratureContext,
45        eta: f64,
46        se_eta: f64,
47    ) -> Result<f64, EstimationError>;
48
49    fn posterior_meanvariance(
50        &self,
51        quadctx: &QuadratureContext,
52        eta: f64,
53        se_eta: f64,
54    ) -> Result<(f64, f64), EstimationError>;
55
56    fn simulate_noise(
57        &self,
58        mean: &Array1<f64>,
59        gaussian_scale: Option<f64>,
60    ) -> Result<NoiseModel, EstimationError>;
61
62    fn integrated_moments(
63        &self,
64        quadctx: &QuadratureContext,
65        eta: f64,
66        se_eta: f64,
67    ) -> Result<IntegratedMomentsJet, EstimationError>;
68}
69
70/// Default `FamilyStrategy` implementation: stores a `LikelihoodSpec`
71/// (response distribution + parameterized inverse-link state).  Trait
72/// methods dispatch on `spec.response` / `spec.link`: `inverse_link_*`
73/// routes through the parameterized link; `posterior_*` integrates
74/// `p(η) | η ~ N(eta, se_eta²)` via the appropriate exact / quadrature
75/// path; `simulate_noise` extracts the dispersion parameter from
76/// `gaussian_scale` (or rejects when the family needs one and it is
77/// missing).
78#[derive(Clone, Debug)]
79pub struct ResolvedFamilyStrategy {
80    spec: LikelihoodSpec,
81}
82
83/// Build a `LikelihoodSpec` from a response/link spec plus an optional
84/// fitted `InverseLink` state. The supplied `InverseLink` is preferred;
85/// when absent the original spec is retained unchanged.
86fn spec_from_family(family: LikelihoodSpec, inverse_link: Option<&InverseLink>) -> LikelihoodSpec {
87    if let Some(link) = inverse_link {
88        return LikelihoodSpec {
89            response: family.response,
90            link: link.clone(),
91        };
92    }
93    family
94}
95
96/// Construct a `ResolvedFamilyStrategy` from a family identifier and an
97/// optional inverse-link state (cloned).  No validation is performed —
98/// the strategy methods will return `EstimationError::InvalidInput`
99/// later if they need state that this constructor did not supply.
100#[inline]
101pub fn strategy_for_family(
102    family: LikelihoodSpec,
103    inverse_link: Option<&InverseLink>,
104) -> ResolvedFamilyStrategy {
105    ResolvedFamilyStrategy {
106        spec: spec_from_family(family, inverse_link),
107    }
108}
109
110/// Construct a `ResolvedFamilyStrategy` directly from a `LikelihoodSpec`.
111/// Mirrors `strategy_for_family` but takes the modern (response, link)
112/// representation without any legacy-enum round-trip. The spec is cloned
113/// into the resulting strategy.
114#[inline]
115pub fn strategy_for_spec(spec: &LikelihoodSpec) -> ResolvedFamilyStrategy {
116    ResolvedFamilyStrategy { spec: spec.clone() }
117}
118
119/// Build a `ResolvedFamilyStrategy` from a fitted result, lifting the
120/// fitted link state (`FittedLinkState`) into an `InverseLink` variant
121/// suitable for predict-time evaluation.  Returns an error when the
122/// recorded link state and the supplied `family` are mutually
123/// inconsistent (propagated from `fit.fitted_link_state`).
124pub fn strategy_from_fit(
125    family: &LikelihoodSpec,
126    fit: &UnifiedFitResult,
127) -> Result<ResolvedFamilyStrategy, EstimationError> {
128    let inverse_link = match fit.fitted_link_state(family)? {
129        FittedLinkState::Standard(Some(link)) => Some(InverseLink::Standard(link)),
130        FittedLinkState::Standard(None) => None,
131        FittedLinkState::LatentCLogLog { state } => Some(InverseLink::LatentCLogLog(state)),
132        FittedLinkState::Sas { state, .. } => Some(InverseLink::Sas(state)),
133        FittedLinkState::BetaLogistic { state, .. } => Some(InverseLink::BetaLogistic(state)),
134        FittedLinkState::Mixture { state, .. } => Some(InverseLink::Mixture(state)),
135    };
136    let spec = if let Some(link) = inverse_link {
137        LikelihoodSpec::new(family.response.clone(), link)
138    } else {
139        family.clone()
140    };
141    Ok(strategy_for_spec(&spec))
142}
143
144impl ResolvedFamilyStrategy {
145    #[inline]
146    fn mixture_state(&self) -> Option<&gam_problem::MixtureLinkState> {
147        self.spec.link.mixture_state()
148    }
149
150    #[inline]
151    fn sas_state(&self) -> Option<&gam_problem::SasLinkState> {
152        self.spec.link.sas_state()
153    }
154
155    #[inline]
156    fn latent_cloglog_state(&self) -> Option<&gam_problem::LatentCLogLogState> {
157        self.spec.link.latent_cloglog_state()
158    }
159
160    #[inline]
161    fn require_latent_cloglog_state(
162        &self,
163    ) -> Result<&gam_problem::LatentCLogLogState, EstimationError> {
164        self.latent_cloglog_state()
165            .ok_or_else(|| missing_state(&self.spec, "latent cloglog"))
166    }
167
168    #[inline]
169    fn require_sas_state(&self) -> Result<&gam_problem::SasLinkState, EstimationError> {
170        self.sas_state()
171            .ok_or_else(|| missing_state(&self.spec, "SAS link"))
172    }
173
174    #[inline]
175    fn require_mixture_state(&self) -> Result<&gam_problem::MixtureLinkState, EstimationError> {
176        self.mixture_state()
177            .ok_or_else(|| missing_state(&self.spec, "mixture link"))
178    }
179}
180
181#[cold]
182fn missing_state(spec: &LikelihoodSpec, what: &str) -> EstimationError {
183    EstimationError::InvalidInput(format!(
184        "{} requires fitted {} state",
185        spec.pretty_name(),
186        what
187    ))
188}
189
190/// Compute `(mean, variance)` of a Bernoulli probability `p(η)` integrated
191/// against `η ~ N(eta, se_eta²)` via the joint `(p, p²)` adaptive Gauss-Hermite
192/// rule. Both SAS and beta-logistic posterior-mean-variance branches share
193/// this exact shape — only the probability kernel differs.
194#[inline]
195fn posterior_mv_from_prob_kernel<F>(
196    quadctx: &QuadratureContext,
197    eta: f64,
198    se_eta: f64,
199    prob: F,
200) -> (f64, f64)
201where
202    F: Fn(f64) -> f64,
203{
204    let (m1, m2) = normal_expectation_1d_adaptive_pair(quadctx, eta, se_eta, |x| {
205        let p = prob(x);
206        (p, p * p)
207    });
208    (m1, (m2 - m1 * m1).max(0.0))
209}
210
211impl FamilyStrategy for ResolvedFamilyStrategy {
212    fn name(&self) -> &'static str {
213        self.spec.name()
214    }
215
216    fn family(&self) -> LikelihoodSpec {
217        self.spec.clone()
218    }
219
220    fn link_function(&self) -> LinkFunction {
221        self.spec.link.link_function()
222    }
223
224    fn inverse_link(&self, eta: f64) -> Result<f64, EstimationError> {
225        self.inverse_link_jet(eta).map(|jet| jet.mu)
226    }
227
228    fn inverse_link_array(&self, eta: ArrayView1<'_, f64>) -> Result<Array1<f64>, EstimationError> {
229        let mut out = Array1::<f64>::zeros(eta.len());
230        for i in 0..eta.len() {
231            out[i] = self.inverse_link(eta[i])?;
232        }
233        Ok(out)
234    }
235
236    fn inverse_link_jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
237        // Public response-scale surface: use the EXACT inverse-link jet so the
238        // log link reports `exp(eta)` wherever IEEE-754 can represent it. The
239        // shared solver derivative seam instead refuses eta outside its declared
240        // inclusive [-700, 700] domain; PIRLS working-state conditioning remains
241        // a separate concern. Within the solver domain both paths are the same
242        // exact exponential (issue #963).
243        inverse_link_jet_for_family_public(&self.spec, eta)
244    }
245
246    fn posterior_mean(
247        &self,
248        quadctx: &QuadratureContext,
249        eta: f64,
250        se_eta: f64,
251    ) -> Result<f64, EstimationError> {
252        match (&self.spec.response, &self.spec.link) {
253            (ResponseFamily::Gaussian, _) => Ok(eta),
254            (ResponseFamily::Binomial, InverseLink::Standard(_)) => {
255                integrated_inverse_link_mean_and_derivative(
256                    quadctx,
257                    self.link_function(),
258                    eta,
259                    se_eta,
260                )
261                .map(|v| v.mean)
262            }
263            (ResponseFamily::Binomial, InverseLink::LatentCLogLog(_)) => {
264                let state = self.require_latent_cloglog_state()?;
265                latent_cloglog_inverse_link_jet(quadctx, eta, se_eta.hypot(state.latent_sd))
266                    .map(|v| v.mean)
267            }
268            (ResponseFamily::Binomial, InverseLink::Sas(_))
269            | (ResponseFamily::Binomial, InverseLink::BetaLogistic(_)) => {
270                integrated_inverse_link_jetwith_state(
271                    quadctx,
272                    self.link_function(),
273                    eta,
274                    se_eta,
275                    self.mixture_state(),
276                    self.sas_state(),
277                )
278                .map(|v| v.mean)
279            }
280            (ResponseFamily::Binomial, InverseLink::Mixture(_)) => {
281                let state = self.require_mixture_state()?;
282                let likelihood = gam_problem::GlmLikelihoodSpec::canonical(
283                    LikelihoodSpec::binomial_mixture(state.clone()),
284                );
285                integrated_family_moments_jet(
286                    quadctx,
287                    &likelihood,
288                    eta,
289                    se_eta,
290                )
291                .map(|v| v.mean)
292            }
293            (ResponseFamily::Poisson, _)
294            | (ResponseFamily::Tweedie { .. }, _)
295            | (ResponseFamily::NegativeBinomial { .. }, _)
296            | (ResponseFamily::Gamma, _) => {
297                // E[exp(η)] where η ~ N(eta, se²) = exp(eta + se²/2)
298                // (log-normal MGF). When the exponent exceeds the f64 range the
299                // posterior mean genuinely overflows; `exp` then returns +inf,
300                // which IS the correctly rounded value of the integral. Earlier
301                // revisions substituted the plug-in `exp(η)` (or f64::MAX) here
302                // to keep the FFI finite, silently turning an unbounded
303                // posterior mean into an innocuous value (η = 0, se = 40 →
304                // exponent 800 reported as 1). Honesty over convenience:
305                // return the exact, possibly infinite, mean and let callers
306                // decide how to present it.
307                Ok((eta + 0.5 * se_eta * se_eta).exp())
308            }
309            (ResponseFamily::Beta { .. }, _) => {
310                Ok(logit_posterior_meanvariance(quadctx, eta, se_eta).0)
311            }
312            (ResponseFamily::RoystonParmar, _) => Ok(survival_posterior_mean(quadctx, eta, se_eta)),
313        }
314    }
315
316    fn posterior_meanvariance(
317        &self,
318        quadctx: &QuadratureContext,
319        eta: f64,
320        se_eta: f64,
321    ) -> Result<(f64, f64), EstimationError> {
322        match (&self.spec.response, &self.spec.link) {
323            (ResponseFamily::Gaussian, _) => Ok((eta, (se_eta * se_eta).max(0.0))),
324            (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::Logit)) => {
325                Ok(logit_posterior_meanvariance(quadctx, eta, se_eta))
326            }
327            (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::Probit)) => {
328                Ok(probit_posterior_meanvariance(quadctx, eta, se_eta))
329            }
330            (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::CLogLog)) => {
331                Ok(cloglog_posterior_meanvariance(quadctx, eta, se_eta))
332            }
333            (ResponseFamily::Binomial, InverseLink::Standard(_)) => {
334                // Remaining standard binomial links (LogLog, Cauchit, ...):
335                // integrate the family's ACTUAL inverse link through the shared
336                // probability-kernel quadrature. The historical fallback
337                // integrated the logistic kernel here, so LogLog/Cauchit
338                // response moments were computed for the wrong link (at
339                // se_eta = 0, η = 1: exact Cauchit mean 0.75, exact LogLog mean
340                // exp(-exp(-1)) ≈ 0.6922, logistic 0.7311).
341                Ok(posterior_mv_from_prob_kernel(quadctx, eta, se_eta, |x| {
342                    inverse_link_jet_for_family_public(&self.spec, x)
343                        .map(|jet| jet.mu)
344                        .unwrap_or(f64::NAN)
345                }))
346            }
347            (ResponseFamily::Binomial, InverseLink::LatentCLogLog(_)) => {
348                let state = self.require_latent_cloglog_state()?;
349                let total_sigma = se_eta.hypot(state.latent_sd);
350                let m1 = latent_cloglog_inverse_link_jet(quadctx, eta, total_sigma)?.mean;
351                let m2 = normal_expectation_1d_adaptive(quadctx, eta, se_eta, |x| {
352                    latent_cloglog_inverse_link_jet(quadctx, x, state.latent_sd)
353                        .map(|jet| {
354                            let p = jet.mean;
355                            p * p
356                        })
357                        .unwrap_or(f64::NAN)
358                });
359                Ok((m1, (m2 - m1 * m1).max(0.0)))
360            }
361            (ResponseFamily::Binomial, InverseLink::Sas(_)) => {
362                let state = self.require_sas_state()?;
363                Ok(posterior_mv_from_prob_kernel(quadctx, eta, se_eta, |x| {
364                    gam_solve::mixture_link::sas_inverse_link_jet(x, state.epsilon, state.log_delta)
365                        .expect("normal quadrature nodes must be finite")
366                        .mu
367                }))
368            }
369            (ResponseFamily::Binomial, InverseLink::BetaLogistic(_)) => {
370                let state = self.require_sas_state()?;
371                Ok(posterior_mv_from_prob_kernel(quadctx, eta, se_eta, |x| {
372                    gam_solve::mixture_link::beta_logistic_inverse_link_jet(
373                        x,
374                        state.log_delta,
375                        state.epsilon,
376                    )
377                    .mu
378                }))
379            }
380            (ResponseFamily::Binomial, InverseLink::Mixture(_)) => {
381                let state = self.require_mixture_state()?;
382                let likelihood = gam_problem::GlmLikelihoodSpec::canonical(
383                    LikelihoodSpec::binomial_mixture(state.clone()),
384                );
385                let m1 = integrated_family_moments_jet(
386                    quadctx,
387                    &likelihood,
388                    eta,
389                    se_eta,
390                )?
391                .mean;
392                let m2 = normal_expectation_1d_adaptive(quadctx, eta, se_eta, |x| {
393                    let p = mixture_inverse_link_jet(state, x).mu;
394                    p * p
395                });
396                Ok((m1, (m2 - m1 * m1).max(0.0)))
397            }
398            (ResponseFamily::Poisson, _)
399            | (ResponseFamily::Tweedie { .. }, _)
400            | (ResponseFamily::NegativeBinomial { .. }, _)
401            | (ResponseFamily::Gamma, _) => {
402                // Log-normal moments: E[exp(η)] = exp(μ + σ²/2),
403                // Var[exp(η)] = exp(2μ + σ²)·expm1(σ²). `expm1` keeps the
404                // variance factor exact for tiny σ² (σ² = 1e-20: exp(σ²) - 1
405                // rounds to 0, expm1 returns 1e-20), so small but nonzero
406                // posterior uncertainty is never reported as exactly zero.
407                let s2 = se_eta * se_eta;
408                let m1 = (eta + 0.5 * s2).exp();
409                let m2 = (2.0 * eta + s2).exp() * s2.exp_m1();
410                Ok((m1, m2.max(0.0)))
411            }
412            (ResponseFamily::Beta { .. }, _) => {
413                Ok(logit_posterior_meanvariance(quadctx, eta, se_eta))
414            }
415            (ResponseFamily::RoystonParmar, _) => {
416                Ok(survival_posterior_meanvariance(quadctx, eta, se_eta))
417            }
418        }
419    }
420
421    fn simulate_noise(
422        &self,
423        mean: &Array1<f64>,
424        gaussian_scale: Option<f64>,
425    ) -> Result<NoiseModel, EstimationError> {
426        // Thin adapter over the single canonical likelihood -> noise-model
427        // mapping shared with generative inference, so simulation and
428        // inference can never disagree on supported likelihoods or how
429        // dispersion parameters are interpreted.
430        NoiseModel::from_likelihood(&self.spec, mean.len(), gaussian_scale)
431    }
432
433    fn integrated_moments(
434        &self,
435        quadctx: &QuadratureContext,
436        eta: f64,
437        se_eta: f64,
438    ) -> Result<IntegratedMomentsJet, EstimationError> {
439        if let Some(state) = self.latent_cloglog_state() {
440            let jet = latent_cloglog_inverse_link_jet(quadctx, eta, se_eta.hypot(state.latent_sd))?;
441            let mean = jet.mean;
442            return Ok(IntegratedMomentsJet {
443                mean,
444                variance: (mean * (1.0 - mean)).max(PROB_VARIANCE_FLOOR),
445                d1: jet.d1,
446                d2: jet.d2,
447                d3: jet.d3,
448                mode: jet.mode,
449            });
450        }
451        // The observation-model variance for Tweedie/Gamma depends on the
452        // exponential-dispersion metadata (Tweedie φ, Gamma shape). The strategy
453        // carries the (response, link) spec, so supply that spec's scale metadata
454        // — for Gamma/Tweedie this is the estimated-dispersion variant (seeded at
455        // the unit value, refined during fitting), never a silent hardcoded φ = 1
456        // baked into the integrator (issue #953).
457        let likelihood = gam_problem::GlmLikelihoodSpec::canonical(self.spec.clone());
458        integrated_family_moments_jet(quadctx, &likelihood, eta, se_eta)
459    }
460}
461
462#[cfg(test)]
463mod log_link_public_jet_tests {
464    use super::*;
465    use gam_problem::LikelihoodSpec;
466    use gam_solve::mixture_link::inverse_link_jet_for_family;
467    use ndarray::Array1;
468
469    /// The PUBLIC predict surface for a log-link family (Poisson/Gamma/Tweedie/
470    /// NB) accepts representable eta beyond the solver derivative domain. This
471    /// drives the exact funnel the predict path uses —
472    /// `FamilyStrategy::inverse_link` / `inverse_link_array` /
473    /// `inverse_link_jet` — and pins a finite eta the solver correctly refuses.
474    #[test]
475    fn public_predict_log_inverse_link_is_exact_exp_at_boundary() {
476        let strategy = strategy_for_spec(&LikelihoodSpec::poisson_log());
477
478        // eta = 705 is outside the solver derivative domain but exact exp(705)
479        // is representable and therefore valid on this public surface.
480        let exact = 705.0_f64.exp();
481        assert!(exact.is_finite(), "exp(705) must be representable in f64");
482        let jet = strategy.inverse_link_jet(705.0).expect("jet");
483        assert_eq!(jet.mu, exact, "predict mean must be exact exp(705)");
484        // All derivatives of exp are exp; the delta-method SE reads `d1`.
485        assert_eq!(jet.d1, exact, "predict dmu/deta must be exact exp(705)");
486        assert_eq!(jet.d2, exact);
487        assert_eq!(jet.d3, exact);
488        let historical_projection = 700.0_f64.exp();
489        assert!(
490            jet.mu > historical_projection * 100.0,
491            "exact exp(705) must not regress to the historical exp(700) projection"
492        );
493
494        // Array entry point used by `predict_plugin_response`/`response`.
495        let arr = strategy
496            .inverse_link_array(Array1::from(vec![705.0]).view())
497            .expect("array");
498        assert_eq!(arr[0], exact, "inverse_link_array must be exact exp(705)");
499
500        // eta = -720 is likewise valid on the public response transform.
501        let exact_neg = (-720.0_f64).exp();
502        let jet = strategy.inverse_link_jet(-720.0).expect("jet");
503        assert_eq!(jet.mu, exact_neg, "predict mean must be exact exp(-720)");
504        let historical_projection_neg = (-700.0_f64).exp();
505        assert!(
506            jet.mu < historical_projection_neg,
507            "exact exp(-720) must not regress to the historical exp(-700) projection"
508        );
509
510        // True IEEE limits honored exactly on the public surface.
511        let over = strategy.inverse_link_jet(710.0).expect("jet");
512        assert!(over.mu.is_infinite() && over.mu > 0.0, "exp(710) -> +inf");
513        let under = strategy.inverse_link_jet(-746.0).expect("jet");
514        assert_eq!(under.mu, 0.0, "exp(-746) -> 0.0");
515    }
516
517    /// On the inclusive solver domain, the public and solver jets are
518    /// byte-identical exact exponentials across the value and all derivatives.
519    #[test]
520    fn public_predict_log_jet_is_byte_identical_on_solver_domain() {
521        let spec = LikelihoodSpec::poisson_log();
522        let strategy = strategy_for_spec(&spec);
523        for &eta in &[
524            -700.0, -300.0, -12.5, -1.0, -0.25, 0.0, 0.25, 1.0, 12.5, 300.0, 700.0,
525        ] {
526            let public_jet = strategy.inverse_link_jet(eta).expect("public jet");
527            let solver_jet = inverse_link_jet_for_family(&spec, eta).expect("solver jet");
528            assert_eq!(
529                public_jet.mu.to_bits(),
530                solver_jet.mu.to_bits(),
531                "mu must be byte-identical in range at eta={eta}"
532            );
533            assert_eq!(
534                public_jet.d1.to_bits(),
535                solver_jet.d1.to_bits(),
536                "d1 must be byte-identical in range at eta={eta}"
537            );
538            assert_eq!(public_jet.d2.to_bits(), solver_jet.d2.to_bits());
539            assert_eq!(public_jet.d3.to_bits(), solver_jet.d3.to_bits());
540        }
541    }
542}