gam_spec/lib.rs
1use ndarray::{Array1, ArrayView1};
2use serde::{Deserialize, Serialize};
3
4/// Hyperprior placed on a coefficient group's precision / log-precision.
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
6pub enum CoefficientGroupPrior {
7 Flat,
8 NormalLogPrecision {
9 mean: f64,
10 sd: f64,
11 },
12 GammaPrecision {
13 shape: f64,
14 rate: f64,
15 },
16 /// Penalized-complexity prior calibrated by `P(exp(-rho/2) > upper) =
17 /// tail_prob`; see [`RhoPrior::PenalizedComplexity`].
18 PenalizedComplexity {
19 upper: f64,
20 tail_prob: f64,
21 },
22}
23
24impl CoefficientGroupPrior {
25 pub fn to_rho_prior(&self) -> RhoPrior {
26 match *self {
27 Self::Flat => RhoPrior::Flat,
28 Self::NormalLogPrecision { mean, sd } => RhoPrior::Normal { mean, sd },
29 Self::GammaPrecision { shape, rate } => RhoPrior::GammaPrecision { shape, rate },
30 Self::PenalizedComplexity { upper, tail_prob } => {
31 RhoPrior::PenalizedComplexity { upper, tail_prob }
32 }
33 }
34 }
35
36 pub fn validate(&self, context: &str) -> Result<(), String> {
37 match *self {
38 Self::Flat => Ok(()),
39 Self::NormalLogPrecision { mean, sd } => {
40 if !mean.is_finite() {
41 return Err(format!(
42 "{context} Normal log-precision prior requires finite mean, got {mean}"
43 ));
44 }
45 if !sd.is_finite() || sd <= 0.0 {
46 return Err(format!(
47 "{context} Normal log-precision prior requires sd > 0, got {sd}"
48 ));
49 }
50 Ok(())
51 }
52 Self::GammaPrecision { shape, rate } => {
53 if !shape.is_finite() || shape <= 0.0 {
54 return Err(format!(
55 "{context} Gamma precision prior requires shape > 0, got {shape}"
56 ));
57 }
58 if !rate.is_finite() || rate < 0.0 {
59 return Err(format!(
60 "{context} Gamma precision prior requires rate >= 0, got {rate}"
61 ));
62 }
63 Ok(())
64 }
65 Self::PenalizedComplexity { upper, tail_prob } => {
66 if !upper.is_finite() || upper <= 0.0 {
67 return Err(format!(
68 "{context} penalized-complexity prior requires upper > 0, got {upper}"
69 ));
70 }
71 if !tail_prob.is_finite() || tail_prob <= 0.0 || tail_prob >= 1.0 {
72 return Err(format!(
73 "{context} penalized-complexity prior requires tail probability in (0, 1), got {tail_prob}"
74 ));
75 }
76 Ok(())
77 }
78 }
79 }
80}
81
82/// Shared default for monotone wiggle/deviation blocks. Formula DSL defaults,
83/// workflow configs, and runtime deviation blocks should all derive from this
84/// type so reproducible presets do not drift across layers.
85#[derive(Clone, Debug, Serialize, Deserialize)]
86pub struct WigglePenaltyConfig {
87 pub degree: usize,
88 pub num_internal_knots: usize,
89 pub penalty_orders: Vec<usize>,
90 pub double_penalty: bool,
91 pub monotonicity_eps: f64,
92}
93
94impl WigglePenaltyConfig {
95 pub fn cubic_triple_operator_default() -> Self {
96 Self {
97 degree: 3,
98 num_internal_knots: 8,
99 penalty_orders: vec![1, 2, 3],
100 double_penalty: true,
101 monotonicity_eps: 1e-4,
102 }
103 }
104}
105
106/// Shared engine-level link selector for generalized models. This is the
107/// "wide" link descriptor: CLI parsing, formula DSL, and the projection from
108/// `InverseLink::link_function()` all live in this enum, so it carries every
109/// link kind the engine knows about — including the state-bearing
110/// `Sas` / `BetaLogistic` cases.
111///
112/// `LinkFunction` is *not* the right type for the state-less `InverseLink::Standard`
113/// cell. Use [`StandardLink`] there: the type system then refuses to construct
114/// a state-less `Standard(Sas)` / `Standard(BetaLogistic)` placeholder.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116pub enum LinkFunction {
117 Logit,
118 Probit,
119 CLogLog,
120 Sas,
121 BetaLogistic,
122 Identity,
123 Log,
124}
125
126impl LinkFunction {
127 #[inline]
128 pub const fn name(self) -> &'static str {
129 match self {
130 Self::Logit => "logit",
131 Self::Probit => "probit",
132 Self::CLogLog => "cloglog",
133 Self::Sas => "sas",
134 Self::BetaLogistic => "beta-logistic",
135 Self::Identity => "identity",
136 Self::Log => "log",
137 }
138 }
139}
140
141/// Legal-only link descriptor for the state-less `InverseLink::Standard` cell.
142///
143/// `Sas` / `BetaLogistic` are state-bearing and live in their own
144/// `InverseLink::Sas(_)` / `InverseLink::BetaLogistic(_)` variants. The type
145/// system enforces that fact by omitting them here, so the historical
146/// "state-less placeholder" pattern (`InverseLink::Standard(LinkFunction::Sas)`)
147/// no longer compiles.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149pub enum StandardLink {
150 Logit,
151 Probit,
152 CLogLog,
153 Identity,
154 Log,
155}
156
157impl StandardLink {
158 #[inline]
159 pub const fn name(self) -> &'static str {
160 self.as_link_function().name()
161 }
162
163 #[inline]
164 pub const fn as_link_function(self) -> LinkFunction {
165 match self {
166 Self::Logit => LinkFunction::Logit,
167 Self::Probit => LinkFunction::Probit,
168 Self::CLogLog => LinkFunction::CLogLog,
169 Self::Identity => LinkFunction::Identity,
170 Self::Log => LinkFunction::Log,
171 }
172 }
173}
174
175impl From<StandardLink> for LinkFunction {
176 #[inline]
177 fn from(link: StandardLink) -> Self {
178 link.as_link_function()
179 }
180}
181
182/// Error returned when narrowing a wide [`LinkFunction`] into a [`StandardLink`].
183/// `Sas` and `BetaLogistic` are state-bearing and have no legal `Standard(_)`
184/// representation; they must be routed through `InverseLink::Sas` /
185/// `InverseLink::BetaLogistic`.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub struct StateBearingLinkInStandardSlot(pub LinkFunction);
188
189impl std::fmt::Display for StateBearingLinkInStandardSlot {
190 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191 write!(
192 f,
193 "state-bearing link `{}` cannot be carried by `InverseLink::Standard`; \
194 route through `InverseLink::Sas` / `InverseLink::BetaLogistic`",
195 self.0.name()
196 )
197 }
198}
199
200impl std::error::Error for StateBearingLinkInStandardSlot {}
201
202impl TryFrom<LinkFunction> for StandardLink {
203 type Error = StateBearingLinkInStandardSlot;
204
205 #[inline]
206 fn try_from(link: LinkFunction) -> Result<Self, Self::Error> {
207 match link {
208 LinkFunction::Logit => Ok(Self::Logit),
209 LinkFunction::Probit => Ok(Self::Probit),
210 LinkFunction::CLogLog => Ok(Self::CLogLog),
211 LinkFunction::Identity => Ok(Self::Identity),
212 LinkFunction::Log => Ok(Self::Log),
213 LinkFunction::Sas | LinkFunction::BetaLogistic => {
214 Err(StateBearingLinkInStandardSlot(link))
215 }
216 }
217 }
218}
219
220/// Supported inverse-link components for convex blended inverse links.
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
222pub enum LinkComponent {
223 Probit,
224 Logit,
225 CLogLog,
226 LogLog,
227 Cauchit,
228}
229
230impl LinkComponent {
231 #[inline]
232 pub const fn name(self) -> &'static str {
233 match self {
234 Self::Probit => "probit",
235 Self::Logit => "logit",
236 Self::CLogLog => "cloglog",
237 Self::LogLog => "loglog",
238 Self::Cauchit => "cauchit",
239 }
240 }
241}
242
243/// User-facing configuration for a blended inverse link.
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
245pub struct MixtureLinkSpec {
246 pub components: Vec<LinkComponent>,
247 /// Free logits for components [0..K-2]. The final component logit is fixed at 0.
248 pub initial_rho: Array1<f64>,
249}
250
251/// Runtime blended-link state with precomputed softmax weights.
252#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
253pub struct MixtureLinkState {
254 pub components: Vec<LinkComponent>,
255 /// Free logits for components [0..K-2]. The final component logit is fixed at 0.
256 pub rho: Array1<f64>,
257 /// Softmax-normalized component weights (length K).
258 pub pi: Array1<f64>,
259}
260
261/// User-facing configuration for the continuous sinh-arcsinh inverse link.
262#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
263pub struct SasLinkSpec {
264 pub initial_epsilon: f64,
265 pub initial_log_delta: f64,
266}
267
268/// Runtime state shared by the two-parameter `Sas` and `BetaLogistic` links:
269/// an `epsilon` skew/asymmetry term plus a raw log-scale parameter (`log_delta`)
270/// and its derived positive companion (`delta`). The `delta` field's meaning is
271/// link-specific — see its doc — so derivative kernels must consume `log_delta`,
272/// never `delta`.
273#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
274pub struct SasLinkState {
275 pub epsilon: f64,
276 /// Raw optimization parameter. For `Sas` this is the pre-bound log-tail; for
277 /// `BetaLogistic` it is the unconstrained log geometric-mean beta shape (the
278 /// `log_shape_center` the beta-logistic kernels expect).
279 pub log_delta: f64,
280 /// Derived positive companion of `log_delta`. Its meaning depends on the link:
281 /// - `Sas`: effective tail parameter `delta = exp(B * tanh(log_delta / B))`,
282 /// `B = SAS_LOG_DELTA_BOUND`.
283 /// - `BetaLogistic`: geometric-mean beta shape `exp(log_delta) = sqrt(a*b)`.
284 /// The beta-logistic derivative kernels take `log_delta` (the log center), so
285 /// passing this exponentiated `delta` to them would be off by an `exp`.
286 pub delta: f64,
287}
288
289/// Fixed latent Gaussian scale for the exact marginal cloglog family.
290#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
291pub struct LatentCLogLogState {
292 pub latent_sd: f64,
293}
294
295impl LatentCLogLogState {
296 #[inline]
297 pub fn new(latent_sd: f64) -> Result<Self, String> {
298 if !latent_sd.is_finite() || latent_sd < 0.0 {
299 return Err(format!(
300 "latent cloglog standard deviation must be finite and >= 0, got {latent_sd}"
301 ));
302 }
303 Ok(Self { latent_sd })
304 }
305}
306
307/// Whether the inverse link exposes the Fisher-weight jet that the Firth
308/// penalty's higher-order correction consumes. Inlined here (issue #1521) so
309/// `LikelihoodSpec::supports_firth` has no upward dependency on
310/// `solver::mixture_link` — the match is over link variants all defined in this
311/// module, so the predicate is self-contained. The canonical jet evaluation
312/// still lives in `solver::mixture_link`; this is purely the classifier.
313#[inline]
314fn inverse_link_has_fisher_weight_jet(link: &InverseLink) -> bool {
315 matches!(
316 link,
317 InverseLink::Standard(StandardLink::Logit | StandardLink::Probit | StandardLink::CLogLog,)
318 | InverseLink::LatentCLogLog(_)
319 | InverseLink::Sas(_)
320 | InverseLink::BetaLogistic(_)
321 | InverseLink::Mixture(_)
322 )
323}
324
325/// Parameterized inverse-link selector used where mu/derivatives are evaluated.
326#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
327pub enum InverseLink {
328 Standard(StandardLink),
329 LatentCLogLog(LatentCLogLogState),
330 Sas(SasLinkState),
331 BetaLogistic(SasLinkState),
332 Mixture(MixtureLinkState),
333}
334
335impl InverseLink {
336 #[inline]
337 pub const fn link_function(&self) -> LinkFunction {
338 match self {
339 Self::Standard(link) => link.as_link_function(),
340 Self::LatentCLogLog(_) => LinkFunction::CLogLog,
341 Self::Sas(_) => LinkFunction::Sas,
342 Self::BetaLogistic(_) => LinkFunction::BetaLogistic,
343 Self::Mixture(_) => LinkFunction::Logit,
344 }
345 }
346
347 #[inline]
348 pub const fn mixture_state(&self) -> Option<&MixtureLinkState> {
349 match self {
350 Self::Mixture(state) => Some(state),
351 _ => None,
352 }
353 }
354
355 #[inline]
356 pub const fn sas_state(&self) -> Option<&SasLinkState> {
357 match self {
358 Self::Sas(state) | Self::BetaLogistic(state) => Some(state),
359 _ => None,
360 }
361 }
362
363 #[inline]
364 pub const fn latent_cloglog_state(&self) -> Option<&LatentCLogLogState> {
365 match self {
366 Self::LatentCLogLog(state) => Some(state),
367 _ => None,
368 }
369 }
370}
371
372/// Fixed prior family for smoothing parameters in joint HMC refinement.
373#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
374pub enum RhoPrior {
375 Flat,
376 Normal {
377 mean: f64,
378 sd: f64,
379 },
380 /// Gamma(shape, rate) conjugate hyperprior on the precision lambda = exp(rho).
381 ///
382 /// The deterministic REML/LAML objective uses the MAP-in-lambda convention
383 /// and is minimized, so this contributes `rate * exp(rho) - (shape - 1) * rho`
384 /// up to an additive constant. Samplers over rho include the +rho Jacobian
385 /// from lambda = exp(rho), so their log-density contribution is
386 /// `shape * rho - rate * exp(rho)`. For a block with effective dimension n_p
387 /// and centered quadratic
388 /// `(beta - mu)'S_p(beta - mu)`, the conditional posterior is
389 /// `Gamma(shape + n_p/2, rate + quadratic/2)` and the closed-form MAP
390 /// precision is `(shape + n_p/2 - 1) / (rate + quadratic/2)`.
391 /// `Gamma(1, 0)` is the explicit flat/default case and reproduces the
392 /// current MacKay/Tipping fixed point.
393 GammaPrecision {
394 shape: f64,
395 rate: f64,
396 },
397 /// Penalized-complexity (PC) prior on the smoothing parameter
398 /// (Simpson, Rue, Riebler, Martins, Sørbye, *Statistical Science* 2017).
399 ///
400 /// A PC prior fixes a *base* model (here the infinitely-smooth limit, where
401 /// the penalized component collapses to its null space) and puts an
402 /// exponential prior on the distance away from it. For a Gaussian smooth
403 /// with precision `λ = exp(ρ)` the relevant distance is the marginal
404 /// standard-deviation scale `d = λ^{-1/2} = exp(-ρ/2)`, and a constant-rate
405 /// penalization `p(d) = θ exp(-θ d)` induces the closed-form log-prior
406 ///
407 /// ```text
408 /// log p(ρ) = ln(θ/2) − ρ/2 − θ exp(−ρ/2).
409 /// ```
410 ///
411 /// The rate `θ` is calibrated by the single interpretable tail statement
412 /// `P(d > upper) = tail_prob`, i.e. `θ = −ln(tail_prob) / upper`. The prior
413 /// is reparameterization-invariant and shrinks toward the simpler model
414 /// (an exponential wall against under-smoothing, only a gentle linear pull
415 /// toward over-smoothing), which is exactly the Occam behaviour wanted for
416 /// high-variance flexible components. The REML/LAML objective is minimized,
417 /// so this contributes `ρ/2 + θ exp(−ρ/2)` (up to an additive constant) to
418 /// the cost, with gradient `1/2 − (θ/2) exp(−ρ/2)` and (always positive)
419 /// curvature `(θ/4) exp(−ρ/2)`.
420 PenalizedComplexity {
421 /// Upper bound `U` on the distance scale `d = exp(-ρ/2)` (the marginal
422 /// SD scale of the penalized component) in the tail statement
423 /// `P(d > U) = tail_prob`. Must be finite and strictly positive.
424 upper: f64,
425 /// Tail probability `α` in `P(d > U) = α`. Must satisfy `0 < α < 1`.
426 tail_prob: f64,
427 },
428 /// Coordinate-specific priors for models whose smoothing parameters do
429 /// not share one prior family, such as nested coefficient groups.
430 Independent(Vec<RhoPrior>),
431}
432
433impl Default for RhoPrior {
434 fn default() -> Self {
435 Self::Normal { mean: 0.0, sd: 3.0 }
436 }
437}
438
439// ---------------------------------------------------------------------------
440// Unified likelihood specification
441// ---------------------------------------------------------------------------
442//
443// `LikelihoodSpec { response: ResponseFamily, link: InverseLink }` is the
444// canonical likelihood selector. `ResponseFamily` is a pure response-
445// distribution selector that carries the per-family scalars
446// (`Tweedie { p }`, `NegativeBinomial { theta }`, `Beta { phi }`); `InverseLink`
447// is the parameterized inverse-link selector. Splitting (response, link)
448// removes the drift bug that the former flat likelihood enum allowed
449// when its variant disagreed with a separately-stored `InverseLink`.
450
451/// Pure response distribution selector — no link information.
452#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
453pub enum ResponseFamily {
454 Gaussian,
455 Binomial,
456 Poisson,
457 Tweedie {
458 p: f64,
459 },
460 NegativeBinomial {
461 theta: f64,
462 /// `true` when `theta` was supplied by the user as a held-fixed value
463 /// (`--negative-binomial-theta`, issue #983): the fit must use exactly
464 /// this overdispersion — `Var(y) = μ + μ²/θ`, IRLS weight
465 /// `W = μθ/(θ+μ)`, coefficients/covariance/SEs all reflect it — and
466 /// the inner solver must never overwrite it. `false` means `theta` is
467 /// the running seed/estimate refined from the data each inner solve
468 /// (the #802 default). Carried on the family variant — the canonical
469 /// `theta` store — so the estimated-vs-fixed contract can never desync
470 /// from the value itself; `default_scale_metadata` derives the
471 /// matching scale variant.
472 theta_fixed: bool,
473 },
474 Beta {
475 phi: f64,
476 },
477 Gamma,
478 RoystonParmar,
479}
480
481impl ResponseFamily {
482 #[inline]
483 pub const fn name(&self) -> &'static str {
484 match self {
485 Self::Gaussian => "gaussian",
486 Self::Binomial => "binomial",
487 Self::Poisson => "poisson",
488 Self::Tweedie { .. } => "tweedie",
489 Self::NegativeBinomial { .. } => "negative-binomial",
490 Self::Beta { .. } => "beta",
491 Self::Gamma => "gamma",
492 Self::RoystonParmar => "royston-parmar",
493 }
494 }
495
496 /// Closed-interval bounds for the mean (response-scale) of this family.
497 ///
498 /// Used by predict-side CI clamps that need to keep transformed bounds
499 /// within the support of the response. Beta uses strict-open `(1e-10, 1 − 1e-10)`
500 /// to avoid logit singularities; Binomial / Royston-Parmar use the closed
501 /// `[0, 1]` since they are evaluated post-transformation. Unbounded
502 /// (continuous-real or non-negative-real) families return `None` — the
503 /// caller should not clamp.
504 #[inline]
505 pub fn mean_clamp_bounds(&self) -> Option<(f64, f64)> {
506 match self {
507 Self::Binomial | Self::RoystonParmar => Some((0.0, 1.0)),
508 Self::Beta { .. } => Some((1e-10, 1.0 - 1e-10)),
509 Self::Gaussian
510 | Self::Poisson
511 | Self::Tweedie { .. }
512 | Self::NegativeBinomial { .. }
513 | Self::Gamma => None,
514 }
515 }
516
517 /// Closed numeric bounds of the **response support** — the closure of the
518 /// set of values a single observation `Y` can take — used to clamp the
519 /// *observation (prediction) interval* so a predictive band never reports
520 /// values the response can never attain.
521 ///
522 /// This is deliberately distinct from [`Self::mean_clamp_bounds`], which
523 /// governs the *mean* (confidence) interval. `mean_clamp_bounds` returns
524 /// `None` for the non-negative-real families (Poisson / Tweedie /
525 /// NegativeBinomial / Gamma) because their default mean interval is built
526 /// by transforming the η endpoints through a positive inverse link, which
527 /// cannot escape the support. The observation interval, by contrast, is the
528 /// symmetric response-scale band `μ ± z·σ_pred`; for a small fitted mean its
529 /// lower endpoint crosses below the support floor (e.g. a Poisson count band
530 /// going negative), so it must be floored at the response support here.
531 ///
532 /// The lower edge is the infimum of the support (`0` for every non-negative
533 /// family, including the open-at-zero Gamma, whose predictive lower bound is
534 /// reported at the boundary `0`). The upper edge is `+∞` where the response
535 /// is unbounded above, which leaves the upper band untouched, or `1` for the
536 /// `[0, 1]`-valued families. `None` means the response is supported on the
537 /// whole real line (Gaussian) or has its support enforced downstream
538 /// (Royston–Parmar), and the predictive band is passed through unclamped.
539 ///
540 /// The match arms mirror [`Self::response_support_contains`]: a new family
541 /// must update both together so the support a value is validated against and
542 /// the support a predictive band is clamped to stay consistent.
543 #[inline]
544 pub fn response_support_bounds(&self) -> Option<(f64, f64)> {
545 match self {
546 Self::Gamma | Self::Poisson | Self::NegativeBinomial { .. } | Self::Tweedie { .. } => {
547 Some((0.0, f64::INFINITY))
548 }
549 Self::Beta { .. } | Self::Binomial => Some((0.0, 1.0)),
550 Self::Gaussian | Self::RoystonParmar => None,
551 }
552 }
553
554 /// Per-family textual description of the response-support requirement.
555 /// `None` means the family is supported on the entire real line at the
556 /// validation layer (Gaussian) or has its support enforced by a downstream
557 /// pathway (RoystonParmar via the survival pipeline).
558 ///
559 /// `Binomial` accepts Bernoulli observations and grouped-binomial
560 /// proportions. With prior/trial weights folded into the row weight, the
561 /// per-unit log-likelihood is `ℓ(η) = y·η − log(1 + exp(η))`, which is
562 /// bounded exactly for `0 ≤ y ≤ 1`; outside that interval one tail is
563 /// unbounded and the binomial deviance leaves its domain. Strict `{0, 1}`
564 /// binarity remains an auto-inference policy, not the support of an
565 /// explicitly requested binomial model.
566 #[inline]
567 pub fn response_support_requirement(&self) -> Option<&'static str> {
568 match self {
569 Self::Gamma => Some("strictly positive response values (y > 0)"),
570 Self::Poisson | Self::NegativeBinomial { .. } | Self::Tweedie { .. } => {
571 Some("non-negative response values (y ≥ 0)")
572 }
573 Self::Beta { .. } => Some(
574 "response values strictly in the open interval (0, 1) \
575 (a binary {0, 1} response is a Binomial GLM, not Beta; route it through the Binomial family instead)",
576 ),
577 Self::Binomial => Some("response values in the closed interval [0, 1]"),
578 Self::Gaussian | Self::RoystonParmar => None,
579 }
580 }
581
582 /// Predicate that returns `true` iff `yi` lies in this family's response
583 /// support. Only meaningful for families with a non-trivial domain
584 /// constraint at the validation layer; `validate_response_support` calls
585 /// this only after `response_support_requirement` returns `Some`, so the
586 /// "unconstrained" families (Gaussian / RoystonParmar) never hit this code
587 /// path.
588 ///
589 /// `Binomial` accepts Bernoulli outcomes and grouped-binomial proportions:
590 /// `y` must be finite and lie in the closed unit interval. The stricter
591 /// `{0, 1}` predicate is used only where the code is specifically asking
592 /// whether a numeric response is binary (auto-inference and all-boundary
593 /// degeneracy checks).
594 #[inline]
595 fn response_support_contains(&self, yi: f64) -> bool {
596 match self {
597 Self::Gamma => yi.is_finite() && yi > 0.0,
598 Self::Poisson | Self::NegativeBinomial { .. } | Self::Tweedie { .. } => {
599 yi.is_finite() && yi >= 0.0
600 }
601 Self::Beta { .. } => yi.is_finite() && yi > 0.0 && yi < 1.0,
602 Self::Binomial => yi.is_finite() && (0.0..=1.0).contains(&yi),
603 Self::Gaussian | Self::RoystonParmar => true,
604 }
605 }
606
607 /// Human-readable family label used in domain-violation error messages
608 /// (capitalised to match user-facing prose, distinct from `name()` which
609 /// returns the lowercase canonical identifier).
610 #[inline]
611 fn response_support_label(&self) -> &'static str {
612 match self {
613 Self::Gaussian => "Gaussian",
614 Self::Binomial => "Binomial",
615 Self::Poisson => "Poisson",
616 Self::Tweedie { .. } => "Tweedie",
617 Self::NegativeBinomial { .. } => "Negative-Binomial",
618 Self::Beta { .. } => "Beta",
619 Self::Gamma => "Gamma",
620 Self::RoystonParmar => "Royston-Parmar",
621 }
622 }
623
624 /// Validate that every element of `y` lies in this family's response
625 /// support. The check is the upfront, fit-blocking enforcement of the
626 /// family's distributional support — e.g. Gamma rejects `y ≤ 0` because
627 /// the log-likelihood contains `log(y)`, Poisson rejects `y < 0` because
628 /// the log-mass contains `log(y!)`.
629 ///
630 /// Returns `Ok(())` for families whose support is the entire real line at
631 /// this layer (Gaussian) or whose support is enforced by a downstream
632 /// pathway (RoystonParmar via the survival pipeline). `Binomial` is
633 /// enforced here: `0 ≤ y ≤ 1` keeps the Bernoulli / grouped-binomial
634 /// log-likelihood bounded.
635 ///
636 /// Up to `ResponseSupportViolation::MAX_REPORTED` offending row indices
637 /// are returned in the violation so the message stays bounded on large
638 /// datasets while still identifying offending rows.
639 pub fn validate_response_support(
640 &self,
641 y: ArrayView1<'_, f64>,
642 ) -> Result<(), ResponseSupportViolation> {
643 let requirement = match self.response_support_requirement() {
644 Some(r) => r,
645 None => return Ok(()),
646 };
647 let mut offending: Vec<(usize, f64)> = Vec::new();
648 let mut total_violations: usize = 0;
649 for (i, &yi) in y.iter().enumerate() {
650 if !self.response_support_contains(yi) {
651 total_violations += 1;
652 if offending.len() < ResponseSupportViolation::MAX_REPORTED {
653 offending.push((i, yi));
654 }
655 }
656 }
657 if total_violations == 0 {
658 Ok(())
659 } else {
660 Err(ResponseSupportViolation {
661 family_label: self.response_support_label(),
662 requirement,
663 offending,
664 total_violations,
665 })
666 }
667 }
668
669 /// Detect a *degenerate* response: one whose value distribution makes the
670 /// family's REML log-likelihood non-finite even though every individual
671 /// `y_i` lies inside the family's distributional support.
672 ///
673 /// Symmetric counterpart to [`Self::validate_response_support`]: support
674 /// rejects out-of-domain *values* (e.g. a negative Poisson count); this
675 /// rejects *distributions* that send the saturated MLE to a boundary at
676 /// which the score diverges. Each family answers the question for itself
677 /// — adding a new family does not require touching workflow.rs.
678 ///
679 /// Concretely:
680 /// * `Binomial` — refuses an all-zero or all-one response: the saturated
681 /// logit is ±∞ and the REML score is +∞ (issue #331).
682 /// * Every other family currently returns `Ok(())` at this layer — the
683 /// support check already guarantees enough variation to make the
684 /// log-likelihood finite.
685 pub fn validate_response_degeneracy(
686 &self,
687 y: ArrayView1<'_, f64>,
688 ) -> Result<(), ResponseDegeneracy> {
689 match self {
690 Self::Binomial => {
691 if y.is_empty() {
692 return Ok(());
693 }
694 let all_zeros = y.iter().all(|&yi| (yi - 0.0).abs() < BINOMIAL_BINARY_TOL);
695 let all_ones = y.iter().all(|&yi| (yi - 1.0).abs() < BINOMIAL_BINARY_TOL);
696 let kind = if all_zeros {
697 ResponseDegeneracyKind::BinomialAllZeros
698 } else if all_ones {
699 ResponseDegeneracyKind::BinomialAllOnes
700 } else {
701 return Ok(());
702 };
703 Err(ResponseDegeneracy {
704 family_label: self.response_support_label(),
705 kind,
706 })
707 }
708 Self::Gaussian => {
709 // A Gaussian fit's marginal REML log-likelihood carries a
710 // `−n/2·log σ²` term; for an effectively-constant response the
711 // ML scale `σ → 0` drives it to `+∞`, so the outer objective
712 // rejects every seed with "reml_score must be finite, got inf"
713 // (#332). Reject pre-fit when the two-pass, mean-centred sample
714 // sd is at or below `GAUSSIAN_MIN_SAMPLE_SD`. Fewer than two
715 // observations carries no estimable scale degeneracy (the
716 // sample-size gate handles too-small data), and any non-finite
717 // value is left to the dedicated finiteness checks rather than
718 // poisoning the sd, so it is skipped here.
719 //
720 // Exception (#1856): a *genuinely* zero-variance response —
721 // every observation bit-for-bit identical — is not the
722 // pathological near-constant case above but the well-posed
723 // degenerate limit. The penalized fit collapses cleanly to the
724 // constant (intercept = the shared value, every smooth shrunk
725 // to zero) and predicts that constant, so it must fit rather
726 // than be rejected. Only a response that *varies* below the sd
727 // floor without being exactly constant keeps the #332
728 // rejection, whose REML score genuinely diverges to +∞.
729 let mut count = 0usize;
730 let mut mean = 0.0f64;
731 for &yi in y.iter() {
732 if !yi.is_finite() {
733 return Ok(());
734 }
735 count += 1;
736 mean += yi;
737 }
738 if count < 2 {
739 return Ok(());
740 }
741 mean /= count as f64;
742 let mut sumsq = 0.0f64;
743 for &yi in y.iter() {
744 let d = yi - mean;
745 sumsq += d * d;
746 }
747 let sample_sd = (sumsq / (count as f64 - 1.0)).sqrt();
748 if sample_sd <= GAUSSIAN_MIN_SAMPLE_SD {
749 // Genuine zero variance (all values exactly equal) is the
750 // well-posed constant limit, not the #332 divergence: accept
751 // it and let the fitter return the constant surface (#1856).
752 let first = y[0];
753 if y.iter().all(|&yi| yi == first) {
754 return Ok(());
755 }
756 return Err(ResponseDegeneracy {
757 family_label: self.response_support_label(),
758 kind: ResponseDegeneracyKind::GaussianNearConstant {
759 sample_sd,
760 min_sd: GAUSSIAN_MIN_SAMPLE_SD,
761 },
762 });
763 }
764 Ok(())
765 }
766 Self::Poisson
767 | Self::Tweedie { .. }
768 | Self::NegativeBinomial { .. }
769 | Self::Beta { .. }
770 | Self::Gamma
771 | Self::RoystonParmar => Ok(()),
772 }
773 }
774
775 /// Auto-infer a likelihood family when the user did not specify one.
776 ///
777 /// Policy:
778 /// * A string-valued (`Categorical`) response column is refused —
779 /// numeric-encoded level indices (e.g. `"yes"`/`"no"` → `0.0`/`1.0`)
780 /// would otherwise be silently interpreted as a binary outcome,
781 /// producing a probability model the user never asked for.
782 /// * A strictly-binary numeric response (`Binary` kind, or `Numeric`
783 /// with only `{0, 1}` values) maps to `Binomial`.
784 /// * A non-negative integer-valued count response (every value finite,
785 /// `>= 0`, and within [`COUNT_INTEGER_TOL`] of an integer) that reaches
786 /// beyond the binary `{0, 1}` window (i.e. carries at least one value
787 /// `>= 2`) maps to `Poisson` (log link). This is the "magic-by-default"
788 /// count detection: mgcv/statsmodels users expect `0,1,2,3,...` to fit a
789 /// Poisson GLM, not an identity-link Gaussian.
790 /// * Anything else (any fractional or negative value) maps to `Gaussian`.
791 ///
792 /// The fallback to `is_binary_response` inside the `Numeric` arm is what
793 /// historically lived directly inside `resolve_family`; centralising the
794 /// policy here means every entry point (formula API, CLI, future bindings)
795 /// gets the same default-inference behaviour.
796 pub fn infer_from_response(
797 y: ArrayView1<'_, f64>,
798 y_kind: ResponseColumnKind,
799 ) -> Result<Self, ResponseInferenceRefusal> {
800 match y_kind {
801 ResponseColumnKind::Categorical { levels } => Err(ResponseInferenceRefusal {
802 reason: ResponseInferenceRefusalReason::NonNumericResponse,
803 levels,
804 }),
805 ResponseColumnKind::Binary => Ok(Self::Binomial),
806 ResponseColumnKind::Numeric => {
807 let binary = !y.is_empty()
808 && y.iter().all(|v| {
809 v.is_finite()
810 && ((*v - 0.0).abs() < BINOMIAL_BINARY_TOL
811 || (*v - 1.0).abs() < BINOMIAL_BINARY_TOL)
812 });
813 if binary {
814 return Ok(Self::Binomial);
815 }
816 // Count signature: every value finite, non-negative, and an
817 // integer within `COUNT_INTEGER_TOL`, with at least one value
818 // `>= 2` so it is not the (already-handled) binary case and not
819 // a degenerate all-zero column. A single fractional or negative
820 // value disqualifies the whole response, keeping continuous and
821 // signed data on the conservative Gaussian default.
822 let count = !y.is_empty()
823 && y.iter().all(|v| {
824 v.is_finite() && *v >= 0.0 && (*v - v.round()).abs() <= COUNT_INTEGER_TOL
825 })
826 && y.iter().any(|v| *v >= 2.0 - COUNT_INTEGER_TOL);
827 if count {
828 Ok(Self::Poisson)
829 } else {
830 Ok(Self::Gaussian)
831 }
832 }
833 }
834 }
835}
836
837/// Domain-violation detail produced by [`ResponseFamily::validate_response_support`].
838///
839/// Owns its own `Display` impl so call sites in the workflow, the CLI, and the
840/// external-design GLM path produce identical user-facing prose. The
841/// `total_violations` counter is kept distinct from `offending.len()` so the
842/// message can honestly say `(N total)` even when only the first
843/// `MAX_REPORTED` indices are surfaced.
844#[derive(Debug, Clone)]
845pub struct ResponseSupportViolation {
846 pub family_label: &'static str,
847 pub requirement: &'static str,
848 pub offending: Vec<(usize, f64)>,
849 pub total_violations: usize,
850}
851
852impl ResponseSupportViolation {
853 /// Maximum number of offending row indices reported in the error message.
854 /// Keeps the message bounded on large-scale data while still pointing
855 /// the user at concrete bad rows to inspect.
856 pub const MAX_REPORTED: usize = 5;
857
858 /// Format the violation against a specific response column name. The
859 /// column name is supplied by the caller because [`ResponseFamily`] does
860 /// not know which column the user pointed at.
861 pub fn message_for(&self, response_name: &str) -> String {
862 let shown = self
863 .offending
864 .iter()
865 .map(|(i, v)| format!("y[{i}]={v}"))
866 .collect::<Vec<_>>()
867 .join(", ");
868 let more = if self.total_violations > self.offending.len() {
869 format!(", ... ({} total)", self.total_violations)
870 } else {
871 String::new()
872 };
873 format!(
874 "{family} family requires {req}; response column '{name}' violates this constraint at row(s) [{shown}{more}]",
875 family = self.family_label,
876 req = self.requirement,
877 name = response_name,
878 )
879 }
880}
881
882impl std::fmt::Display for ResponseSupportViolation {
883 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
884 f.write_str(&self.message_for("y"))
885 }
886}
887
888impl std::error::Error for ResponseSupportViolation {}
889
890/// Absolute tolerance for the exact-`{0, 1}` test that defines the scalar
891/// Bernoulli (`Binomial`) response support.
892///
893/// The scalar `Binomial` family carries no per-row trial count, so its
894/// log-likelihood is the Bernoulli/soft-label cross-entropy
895/// `ℓ(η) = y·η − log(1 + eη)`, which is unbounded above for `y ∉ {0, 1}`.
896/// Both the auto-inference (`infer_from_response`) and degeneracy
897/// (`validate_response_degeneracy`) paths classify a value as binary by the
898/// same `1e-12` window; the support check shares this single threshold so the
899/// three layers agree on exactly which responses are admissible.
900pub const BINOMIAL_BINARY_TOL: f64 = 1.0e-12;
901
902/// Minimum admissible sample standard deviation for a `Gaussian` response.
903///
904/// A response whose two-pass, mean-centred sample sd is at or below this
905/// threshold is *effectively constant* in `f64` arithmetic: the marginal REML
906/// log-likelihood carries a `−n/2·log σ²` term that diverges to `+∞` as the
907/// fitted scale `σ → 0`, so the outer objective rejects every seed with
908/// `reml_score must be finite, got inf` (#332). The bound is chosen well below
909/// any well-conditioned scientific signal (genuine data has sd many orders of
910/// magnitude larger) yet above the f64 round-off floor, so it never trips a
911/// real fit while catching responses that carry no signal (e.g. a column read
912/// in the wrong scale, or a constant accidentally fed as the response).
913///
914/// One case below the floor is *not* rejected: a genuinely zero-variance
915/// response whose values are all bit-for-bit identical. That is the well-posed
916/// constant limit (the fit collapses to the constant, smooths shrunk to zero)
917/// rather than the divergent near-constant case, so it fits (#1856); only a
918/// response that varies below this floor without being exactly constant is
919/// rejected.
920pub const GAUSSIAN_MIN_SAMPLE_SD: f64 = 1.0e-10;
921
922/// Round tolerance for recognising an integer-valued (count) response.
923///
924/// `infer_from_response` classifies a numeric response as a Poisson count when
925/// every value is finite, non-negative, and within this window of its nearest
926/// non-negative integer. The threshold is looser than [`BINOMIAL_BINARY_TOL`]
927/// because count columns frequently arrive as `f64` round-trips of integers
928/// (CSV parse, integer→double promotion) that accumulate ULP-scale error well
929/// above `1e-12`; `1e-9` admits those without ever matching genuinely
930/// continuous data, whose fractional parts are O(1).
931pub const COUNT_INTEGER_TOL: f64 = 1.0e-9;
932
933/// Classifier for a [`ResponseDegeneracy`]. Each variant carries the family-
934/// specific evidence the caller needs to format a useful message without
935/// having to re-derive the diagnostic.
936#[derive(Debug, Clone)]
937pub enum ResponseDegeneracyKind {
938 /// Bernoulli / Binomial response with every observed value equal to 0.
939 BinomialAllZeros,
940 /// Bernoulli / Binomial response with every observed value equal to 1.
941 BinomialAllOnes,
942 /// Gaussian response that is effectively constant in `f64` arithmetic
943 /// (sample standard deviation at or below [`GAUSSIAN_MIN_SAMPLE_SD`]). The
944 /// marginal REML log-likelihood `−n/2·log σ²` diverges to `+∞` as the
945 /// fitted scale `σ → 0`, so every outer evaluation rejects with a
946 /// non-finite score. Carries the observed `sample_sd` and the `min_sd`
947 /// threshold so the message can quote both verbatim (#332).
948 GaussianNearConstant {
949 /// The two-pass, mean-centred sample standard deviation of the response.
950 sample_sd: f64,
951 /// The rejection threshold ([`GAUSSIAN_MIN_SAMPLE_SD`]).
952 min_sd: f64,
953 },
954}
955
956/// Degenerate-response detail produced by
957/// [`ResponseFamily::validate_response_degeneracy`].
958///
959/// Mirrors [`ResponseSupportViolation`]: it owns its own `Display` and
960/// `message_for(column_name)` so call sites in the workflow, the CLI, and
961/// any future binding produce identical user-facing prose without coupling
962/// each one to the family-internal classifier.
963#[derive(Debug, Clone)]
964pub struct ResponseDegeneracy {
965 pub family_label: &'static str,
966 pub kind: ResponseDegeneracyKind,
967}
968
969impl ResponseDegeneracy {
970 /// Format the degeneracy against a specific response column name. The
971 /// column name is supplied by the caller because [`ResponseFamily`] does
972 /// not know which column the user pointed at.
973 pub fn message_for(&self, response_name: &str) -> String {
974 match self.kind {
975 ResponseDegeneracyKind::BinomialAllZeros => format!(
976 "{family} response '{name}' is degenerate: all values are 0 (no events). \
977 The maximum-likelihood logit is −∞ at this boundary, so the REML score \
978 is not finite. Fix: ensure the response contains at least one 0 and \
979 at least one 1 (e.g. drop the offending subgroup, or refit on a pooled \
980 sample that includes both classes).",
981 family = self.family_label,
982 name = response_name,
983 ),
984 ResponseDegeneracyKind::BinomialAllOnes => format!(
985 "{family} response '{name}' is degenerate: all values are 1 (no non-events). \
986 The maximum-likelihood logit is +∞ at this boundary, so the REML score \
987 is not finite. Fix: ensure the response contains at least one 0 and \
988 at least one 1 (e.g. drop the offending subgroup, or refit on a pooled \
989 sample that includes both classes).",
990 family = self.family_label,
991 name = response_name,
992 ),
993 ResponseDegeneracyKind::GaussianNearConstant { sample_sd, min_sd } => format!(
994 "{family} response '{name}' is effectively constant (sample sd ~ {sample_sd:.3e} \
995 <= {min_sd:.0e}); the marginal REML log-likelihood −n/2·log σ² diverges to \
996 +∞ as σ → 0. Fix: check the response column units (is it being read in the \
997 right scale?), centre/rescale the response, or drop the column if it carries \
998 no signal.",
999 family = self.family_label,
1000 name = response_name,
1001 ),
1002 }
1003 }
1004}
1005
1006impl std::fmt::Display for ResponseDegeneracy {
1007 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1008 f.write_str(&self.message_for("y"))
1009 }
1010}
1011
1012impl std::error::Error for ResponseDegeneracy {}
1013
1014/// Caller-supplied description of the response column's *source* kind.
1015///
1016/// `Categorical { levels }` flags a column that arrived as non-numeric strings
1017/// (the ingest layer encoded its levels to `0.0, 1.0, ...` indices) — the
1018/// `levels` list is preserved so the auto-inference refusal can echo them
1019/// back to the user verbatim. `Binary` is the ingest-layer signal that a
1020/// numeric column already contains only `{0, 1}` (used to short-circuit the
1021/// scan inside [`ResponseFamily::infer_from_response`]). `Numeric` is the
1022/// generic continuous case.
1023#[derive(Debug, Clone)]
1024pub enum ResponseColumnKind {
1025 Numeric,
1026 Binary,
1027 Categorical { levels: Vec<String> },
1028}
1029
1030/// Reason [`ResponseFamily::infer_from_response`] refused to pick a default
1031/// family. Kept as an enum so future policy extensions (e.g. "refuse on
1032/// constant response" — currently a separate CLI-side check) can be added
1033/// without breaking the call site's match arms.
1034#[derive(Debug, Clone)]
1035pub enum ResponseInferenceRefusalReason {
1036 NonNumericResponse,
1037}
1038
1039/// Auto-inference refusal carrying the levels seen in the source column so
1040/// the workflow error can echo them in its message.
1041#[derive(Debug, Clone)]
1042pub struct ResponseInferenceRefusal {
1043 pub reason: ResponseInferenceRefusalReason,
1044 pub levels: Vec<String>,
1045}
1046
1047impl ResponseInferenceRefusal {
1048 /// Format the refusal against a specific response column name.
1049 pub fn message_for(&self, response_name: &str) -> String {
1050 match self.reason {
1051 ResponseInferenceRefusalReason::NonNumericResponse => {
1052 let n = self.levels.len().min(5);
1053 let head = self
1054 .levels
1055 .iter()
1056 .take(n)
1057 .map(|s| format!("'{s}'"))
1058 .collect::<Vec<_>>()
1059 .join(", ");
1060 let preview = if self.levels.len() > n {
1061 format!("[{head}, ...]")
1062 } else {
1063 format!("[{head}]")
1064 };
1065 format!(
1066 "response column '{name}' contains non-numeric values {preview}. \
1067 Did you mean to use family='binomial' for a binary outcome, \
1068 or does '{name}' contain categorical labels that should be encoded first?",
1069 name = response_name,
1070 preview = preview,
1071 )
1072 }
1073 }
1074 }
1075}
1076
1077impl std::fmt::Display for ResponseInferenceRefusal {
1078 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1079 f.write_str(&self.message_for("y"))
1080 }
1081}
1082
1083impl std::error::Error for ResponseInferenceRefusal {}
1084
1085/// Unified likelihood specification: response distribution + parameterized link.
1086///
1087/// `ResponseFamily` carries the per-family scalars (Tweedie p, NegBin theta,
1088/// Beta phi); `InverseLink` carries the parameterized link state. Together
1089/// they replace the former flat likelihood enum.
1090///
1091/// Only the legal `(response, link)` cells enumerated by [`LikelihoodSpec::kind`]
1092/// are representable through the public surface: [`LikelihoodSpec::try_new`]
1093/// validates the legal matrix on construction, and deserialization routes
1094/// through [`LikelihoodSpecWire`] (`#[serde(try_from / into)]`) so saved bytes
1095/// cannot resurrect an illegal cell. The on-wire shape is byte-identical to the
1096/// historical `{ response, link }` struct, so legal saved models load unchanged.
1097#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1098#[serde(try_from = "LikelihoodSpecWire", into = "LikelihoodSpecWire")]
1099pub struct LikelihoodSpec {
1100 pub response: ResponseFamily,
1101 pub link: InverseLink,
1102}
1103
1104/// Transparent serde shadow of [`LikelihoodSpec`] with the identical wire shape
1105/// (`response`, `link`). All (de)serialization of `LikelihoodSpec` routes
1106/// through this type so the legal-matrix check in
1107/// [`TryFrom<LikelihoodSpecWire>`] runs on every load, closing the
1108/// saved-bytes hole: an illegal `(response, link)` cell deserializes into a
1109/// serde error instead of a silently-masked spec.
1110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1111pub struct LikelihoodSpecWire {
1112 pub response: ResponseFamily,
1113 pub link: InverseLink,
1114}
1115
1116impl From<LikelihoodSpec> for LikelihoodSpecWire {
1117 #[inline]
1118 fn from(spec: LikelihoodSpec) -> Self {
1119 Self {
1120 response: spec.response,
1121 link: spec.link,
1122 }
1123 }
1124}
1125
1126impl TryFrom<LikelihoodSpecWire> for LikelihoodSpec {
1127 type Error = IllegalLikelihoodCell;
1128
1129 #[inline]
1130 fn try_from(wire: LikelihoodSpecWire) -> Result<Self, Self::Error> {
1131 Self::try_new(wire.response, wire.link)
1132 }
1133}
1134
1135/// Error returned when an illegal `(ResponseFamily, InverseLink)` cell is
1136/// presented to [`LikelihoodSpec::try_new`] or surfaced during
1137/// deserialization. Only the cells enumerated by [`LikelihoodSpec::kind`] are
1138/// legal; every other product cell would silently mask a wrong response
1139/// transformation (e.g. `Poisson + Identity` predicting `μ = η`, which can go
1140/// negative).
1141#[derive(Debug, Clone, PartialEq)]
1142pub struct IllegalLikelihoodCell {
1143 pub response: &'static str,
1144 pub link: &'static str,
1145}
1146
1147impl std::fmt::Display for IllegalLikelihoodCell {
1148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1149 write!(
1150 f,
1151 "illegal likelihood cell: response `{}` does not admit inverse link `{}`. \
1152 Each non-binomial family is pinned to one link (Gaussian/Royston-Parmar→identity, \
1153 Poisson/Gamma/Tweedie/Negative-Binomial→log, Beta→logit); the binomial family \
1154 admits logit/probit/cloglog and the latent-cloglog/SAS/beta-logistic/blended \
1155 links, but not identity/log.",
1156 self.response, self.link
1157 )
1158 }
1159}
1160
1161impl std::error::Error for IllegalLikelihoodCell {}
1162
1163/// Legal-only enumeration of the `(ResponseFamily, InverseLink)` cells the
1164/// engine recognises. `LikelihoodSpec` is the product type with ~40 nominal
1165/// cells (8 response variants × 5 inverse-link variants), but only the cells
1166/// listed here are honoured by the family math; the rest are silently masked
1167/// by fallback arms. `FamilySpecKind` is the canonical projection used by
1168/// naming, predicates, and dispatch.
1169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1170pub enum FamilySpecKind {
1171 GaussianIdentity,
1172 PoissonLog,
1173 GammaLog,
1174 TweedieLog { p: f64 },
1175 NegativeBinomialLog { theta: f64 },
1176 BetaLogit { phi: f64 },
1177 RoystonParmar,
1178 BinomialLogit,
1179 BinomialProbit,
1180 BinomialCLogLog,
1181 BinomialLatentCLogLog(LatentCLogLogState),
1182 BinomialSas(SasLinkState),
1183 BinomialBetaLogistic(SasLinkState),
1184 BinomialMixture(MixtureLinkState),
1185}
1186
1187impl FamilySpecKind {
1188 /// Short identifier matching the legacy `LikelihoodSpec::name()` strings.
1189 #[inline]
1190 pub const fn name(&self) -> &'static str {
1191 match self {
1192 Self::GaussianIdentity => "gaussian",
1193 Self::PoissonLog => "poisson-log",
1194 Self::TweedieLog { .. } => "tweedie-log",
1195 Self::NegativeBinomialLog { .. } => "negative-binomial-log",
1196 Self::BetaLogit { .. } => "beta-regression-logit",
1197 Self::GammaLog => "gamma-log",
1198 Self::RoystonParmar => "royston-parmar",
1199 Self::BinomialLogit => "binomial-logit",
1200 Self::BinomialProbit => "binomial-probit",
1201 Self::BinomialCLogLog => "binomial-cloglog",
1202 Self::BinomialLatentCLogLog(_) => "latent-cloglog-binomial",
1203 Self::BinomialSas(_) => "binomial-sas",
1204 Self::BinomialBetaLogistic(_) => "binomial-beta-logistic",
1205 Self::BinomialMixture(_) => "binomial-blended-inverse-link",
1206 }
1207 }
1208
1209 /// Human-readable label matching the legacy `LikelihoodSpec::pretty_name()` strings.
1210 #[inline]
1211 pub const fn pretty_name(&self) -> &'static str {
1212 match self {
1213 Self::GaussianIdentity => "Gaussian Identity",
1214 Self::PoissonLog => "Poisson Log",
1215 Self::TweedieLog { .. } => "Tweedie Log",
1216 Self::NegativeBinomialLog { .. } => "Negative-Binomial Log",
1217 Self::BetaLogit { .. } => "Beta Regression Logit",
1218 Self::GammaLog => "Gamma Log",
1219 Self::RoystonParmar => "Royston Parmar",
1220 Self::BinomialLogit => "Binomial Logit",
1221 Self::BinomialProbit => "Binomial Probit",
1222 Self::BinomialCLogLog => "Binomial CLogLog",
1223 Self::BinomialLatentCLogLog(_) => "Latent CLogLog Binomial",
1224 Self::BinomialSas(_) => "Binomial SAS",
1225 Self::BinomialBetaLogistic(_) => "Binomial Beta-Logistic",
1226 Self::BinomialMixture(_) => "Binomial Blended Inverse-Link",
1227 }
1228 }
1229
1230 #[inline]
1231 pub const fn is_binomial(&self) -> bool {
1232 matches!(
1233 self,
1234 Self::BinomialLogit
1235 | Self::BinomialProbit
1236 | Self::BinomialCLogLog
1237 | Self::BinomialLatentCLogLog(_)
1238 | Self::BinomialSas(_)
1239 | Self::BinomialBetaLogistic(_)
1240 | Self::BinomialMixture(_)
1241 )
1242 }
1243
1244 #[inline]
1245 pub const fn is_gaussian_identity(&self) -> bool {
1246 matches!(self, Self::GaussianIdentity)
1247 }
1248
1249 #[inline]
1250 pub const fn is_royston_parmar(&self) -> bool {
1251 matches!(self, Self::RoystonParmar)
1252 }
1253
1254 #[inline]
1255 pub const fn is_latent_cloglog(&self) -> bool {
1256 matches!(self, Self::BinomialLatentCLogLog(_))
1257 }
1258
1259 #[inline]
1260 pub const fn is_binomial_mixture(&self) -> bool {
1261 matches!(self, Self::BinomialMixture(_))
1262 }
1263
1264 #[inline]
1265 pub const fn is_binomial_sas(&self) -> bool {
1266 matches!(self, Self::BinomialSas(_))
1267 }
1268
1269 #[inline]
1270 pub const fn is_binomial_beta_logistic(&self) -> bool {
1271 matches!(self, Self::BinomialBetaLogistic(_))
1272 }
1273
1274 /// Coarse kind-level Firth eligibility: every binomial inverse link this
1275 /// enum can represent (Logit/Probit/CLogLog and the stateful
1276 /// LatentCLogLog/SAS/Beta-Logistic/Mixture links) carries a Fisher-weight
1277 /// jet, so kind-level Firth support is exactly binomial membership.
1278 ///
1279 /// The authoritative, link-resolved gate is
1280 /// [`LikelihoodSpec::supports_firth`], which routes through
1281 /// `inverse_link_has_fisher_weight_jet`. Keep this in agreement with that
1282 /// predicate: a future binomial link without a Fisher-weight jet would make
1283 /// this approximation diverge and must be handled at both sites.
1284 #[inline]
1285 pub const fn supports_firth(&self) -> bool {
1286 self.is_binomial()
1287 }
1288}
1289
1290impl LikelihoodSpec {
1291 /// Unchecked constructor: assembles a `(response, link)` cell *without*
1292 /// validating the legal matrix. Reserved for the in-crate named const
1293 /// constructors below (`gaussian_identity`, `poisson_log`, `beta_logit`,
1294 /// the `binomial_*` family, …), every one of which builds a cell that is
1295 /// legal by construction. The public, fallible entry point for an arbitrary
1296 /// `(response, link)` pair is [`LikelihoodSpec::try_new`]; the serde path
1297 /// also validates via [`LikelihoodSpecWire`]. Do not expose illegal cells
1298 /// through this method.
1299 #[inline]
1300 pub const fn new(response: ResponseFamily, link: InverseLink) -> Self {
1301 Self { response, link }
1302 }
1303
1304 /// Returns `true` when the `(response, link)` pair is one of the legal cells
1305 /// the family math honours — exactly the cells enumerated by
1306 /// [`LikelihoodSpec::kind`] before any masking. Each non-binomial response
1307 /// is pinned to a single inverse link; the binomial family admits its full
1308 /// set of probability links but never the identity/log standard links.
1309 #[inline]
1310 pub fn is_legal_cell(response: &ResponseFamily, link: &InverseLink) -> bool {
1311 match response {
1312 // Pure-identity families.
1313 ResponseFamily::Gaussian | ResponseFamily::RoystonParmar => {
1314 matches!(link, InverseLink::Standard(StandardLink::Identity))
1315 }
1316 // Log-link families.
1317 ResponseFamily::Poisson
1318 | ResponseFamily::Gamma
1319 | ResponseFamily::Tweedie { .. }
1320 | ResponseFamily::NegativeBinomial { .. } => {
1321 matches!(link, InverseLink::Standard(StandardLink::Log))
1322 }
1323 // Logit-link family.
1324 ResponseFamily::Beta { .. } => {
1325 matches!(link, InverseLink::Standard(StandardLink::Logit))
1326 }
1327 // Binomial admits every probability link except the inert
1328 // identity/log standard links.
1329 ResponseFamily::Binomial => match link {
1330 InverseLink::Standard(
1331 StandardLink::Logit | StandardLink::Probit | StandardLink::CLogLog,
1332 ) => true,
1333 InverseLink::Standard(StandardLink::Identity | StandardLink::Log) => false,
1334 InverseLink::LatentCLogLog(_)
1335 | InverseLink::Sas(_)
1336 | InverseLink::BetaLogistic(_)
1337 | InverseLink::Mixture(_) => true,
1338 },
1339 }
1340 }
1341
1342 /// Fallible constructor over an arbitrary `(response, link)` pair. Validates
1343 /// the legal matrix ([`LikelihoodSpec::is_legal_cell`]) so that an illegal
1344 /// cell — one whose stored link would drive a wrong response transformation
1345 /// — is rejected instead of silently masked by [`LikelihoodSpec::kind`].
1346 #[inline]
1347 pub fn try_new(
1348 response: ResponseFamily,
1349 link: InverseLink,
1350 ) -> Result<Self, IllegalLikelihoodCell> {
1351 if Self::is_legal_cell(&response, &link) {
1352 Ok(Self::new(response, link))
1353 } else {
1354 Err(IllegalLikelihoodCell {
1355 response: response.name(),
1356 link: link.link_function().name(),
1357 })
1358 }
1359 }
1360
1361 #[inline]
1362 pub const fn gaussian_identity() -> Self {
1363 Self::new(
1364 ResponseFamily::Gaussian,
1365 InverseLink::Standard(StandardLink::Identity),
1366 )
1367 }
1368
1369 #[inline]
1370 pub const fn binomial_logit() -> Self {
1371 Self::new(
1372 ResponseFamily::Binomial,
1373 InverseLink::Standard(StandardLink::Logit),
1374 )
1375 }
1376
1377 #[inline]
1378 pub const fn binomial_probit() -> Self {
1379 Self::new(
1380 ResponseFamily::Binomial,
1381 InverseLink::Standard(StandardLink::Probit),
1382 )
1383 }
1384
1385 #[inline]
1386 pub const fn binomial_cloglog() -> Self {
1387 Self::new(
1388 ResponseFamily::Binomial,
1389 InverseLink::Standard(StandardLink::CLogLog),
1390 )
1391 }
1392
1393 #[inline]
1394 pub const fn binomial_latent_cloglog(state: LatentCLogLogState) -> Self {
1395 Self::new(ResponseFamily::Binomial, InverseLink::LatentCLogLog(state))
1396 }
1397
1398 #[inline]
1399 pub const fn binomial_sas(state: SasLinkState) -> Self {
1400 Self::new(ResponseFamily::Binomial, InverseLink::Sas(state))
1401 }
1402
1403 #[inline]
1404 pub const fn binomial_beta_logistic(state: SasLinkState) -> Self {
1405 Self::new(ResponseFamily::Binomial, InverseLink::BetaLogistic(state))
1406 }
1407
1408 #[inline]
1409 pub fn binomial_mixture(state: MixtureLinkState) -> Self {
1410 Self::new(ResponseFamily::Binomial, InverseLink::Mixture(state))
1411 }
1412
1413 #[inline]
1414 pub const fn poisson_log() -> Self {
1415 Self::new(
1416 ResponseFamily::Poisson,
1417 InverseLink::Standard(StandardLink::Log),
1418 )
1419 }
1420
1421 #[inline]
1422 pub const fn tweedie_log(p: f64) -> Self {
1423 Self::new(
1424 ResponseFamily::Tweedie { p },
1425 InverseLink::Standard(StandardLink::Log),
1426 )
1427 }
1428
1429 /// Estimated-theta NB spec: `theta` is the seed, refined by the inner
1430 /// solver (#802 default).
1431 #[inline]
1432 pub const fn negative_binomial_log(theta: f64) -> Self {
1433 Self::new(
1434 ResponseFamily::NegativeBinomial {
1435 theta,
1436 theta_fixed: false,
1437 },
1438 InverseLink::Standard(StandardLink::Log),
1439 )
1440 }
1441
1442 /// Fixed-theta NB spec: the fit holds `theta` at exactly this value
1443 /// (`--negative-binomial-theta`, issue #983).
1444 #[inline]
1445 pub const fn negative_binomial_log_fixed(theta: f64) -> Self {
1446 Self::new(
1447 ResponseFamily::NegativeBinomial {
1448 theta,
1449 theta_fixed: true,
1450 },
1451 InverseLink::Standard(StandardLink::Log),
1452 )
1453 }
1454
1455 #[inline]
1456 pub const fn beta_logit(phi: f64) -> Self {
1457 Self::new(
1458 ResponseFamily::Beta { phi },
1459 InverseLink::Standard(StandardLink::Logit),
1460 )
1461 }
1462
1463 #[inline]
1464 pub const fn gamma_log() -> Self {
1465 Self::new(
1466 ResponseFamily::Gamma,
1467 InverseLink::Standard(StandardLink::Log),
1468 )
1469 }
1470
1471 #[inline]
1472 pub const fn royston_parmar() -> Self {
1473 Self::new(
1474 ResponseFamily::RoystonParmar,
1475 InverseLink::Standard(StandardLink::Identity),
1476 )
1477 }
1478
1479 #[inline]
1480 pub const fn link_function(&self) -> LinkFunction {
1481 self.link.link_function()
1482 }
1483
1484 /// Once-and-for-all classification into the legal-only `FamilySpecKind`.
1485 ///
1486 /// `(ResponseFamily, InverseLink)` is a 40-cell product (8 response × 5
1487 /// inverse-link); only the cells listed here are legal. Construction
1488 /// ([`LikelihoodSpec::try_new`]) and deserialization (the
1489 /// [`LikelihoodSpecWire`] `try_from`) both enforce
1490 /// [`LikelihoodSpec::is_legal_cell`], so an illegal cell can never reach
1491 /// this method. Each link-pinned family therefore matches its *one* legal
1492 /// link explicitly; the remaining (now-unreachable) illegal combinations
1493 /// are `unreachable!()` so the historical silent masking — collapsing e.g.
1494 /// `Poisson + Identity` to `PoissonLog` while the transform predicted
1495 /// `μ = η` — can never silently happen again.
1496 pub fn kind(&self) -> FamilySpecKind {
1497 // `legal_cell_kind` returns `Some` for every legal cell and `None`
1498 // for the (by-construction-unreachable) illegal ones. Construction
1499 // (`try_new`) and deserialization (`LikelihoodSpecWire` try_from)
1500 // both enforce `is_legal_cell`, so the `None` branch can never fire
1501 // on a value that exists — `.expect` is the idiomatic loud-on-
1502 // impossible-state assertion (a banned `unreachable!`/`panic!` macro
1503 // would be the same panic with worse provenance). If it ever does
1504 // fire, the message names the offending cell so the silent-masking
1505 // regression this guards against (e.g. `Poisson + Identity`
1506 // collapsing to `PoissonLog`) stays impossible.
1507 self.legal_cell_kind().expect(
1508 "illegal likelihood cell reached kind(): construction (try_new) and \
1509 deserialization (LikelihoodSpecWire) guarantee legality",
1510 )
1511 }
1512
1513 fn legal_cell_kind(&self) -> Option<FamilySpecKind> {
1514 Some(match (&self.response, &self.link) {
1515 (ResponseFamily::Gaussian, InverseLink::Standard(StandardLink::Identity)) => {
1516 FamilySpecKind::GaussianIdentity
1517 }
1518 (ResponseFamily::RoystonParmar, InverseLink::Standard(StandardLink::Identity)) => {
1519 FamilySpecKind::RoystonParmar
1520 }
1521 (ResponseFamily::Poisson, InverseLink::Standard(StandardLink::Log)) => {
1522 FamilySpecKind::PoissonLog
1523 }
1524 (ResponseFamily::Gamma, InverseLink::Standard(StandardLink::Log)) => {
1525 FamilySpecKind::GammaLog
1526 }
1527 (ResponseFamily::Tweedie { p }, InverseLink::Standard(StandardLink::Log)) => {
1528 FamilySpecKind::TweedieLog { p: *p }
1529 }
1530 (
1531 ResponseFamily::NegativeBinomial { theta, .. },
1532 InverseLink::Standard(StandardLink::Log),
1533 ) => FamilySpecKind::NegativeBinomialLog { theta: *theta },
1534 (ResponseFamily::Beta { phi }, InverseLink::Standard(StandardLink::Logit)) => {
1535 FamilySpecKind::BetaLogit { phi: *phi }
1536 }
1537 (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::Logit)) => {
1538 FamilySpecKind::BinomialLogit
1539 }
1540 (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::Probit)) => {
1541 FamilySpecKind::BinomialProbit
1542 }
1543 (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::CLogLog)) => {
1544 FamilySpecKind::BinomialCLogLog
1545 }
1546 (ResponseFamily::Binomial, InverseLink::LatentCLogLog(state)) => {
1547 FamilySpecKind::BinomialLatentCLogLog(*state)
1548 }
1549 (ResponseFamily::Binomial, InverseLink::Sas(state)) => {
1550 FamilySpecKind::BinomialSas(*state)
1551 }
1552 (ResponseFamily::Binomial, InverseLink::BetaLogistic(state)) => {
1553 FamilySpecKind::BinomialBetaLogistic(*state)
1554 }
1555 (ResponseFamily::Binomial, InverseLink::Mixture(state)) => {
1556 FamilySpecKind::BinomialMixture(state.clone())
1557 }
1558 // Every remaining product cell is illegal. `try_new` /
1559 // `LikelihoodSpecWire::try_from` reject these, so construction and
1560 // deserialization guarantee they are unreachable here; `None`
1561 // surfaces that to `kind()`, which aborts loudly via `.expect`
1562 // rather than misclassify the family (a wrong `FamilySpecKind`
1563 // would silently corrupt every downstream likelihood/gradient
1564 // evaluation). A banned `panic!`/`unreachable!` macro would be the
1565 // same divergence with worse provenance.
1566 _ => return None,
1567 })
1568 }
1569
1570 #[inline]
1571 pub fn is_binomial(&self) -> bool {
1572 self.kind().is_binomial()
1573 }
1574
1575 #[inline]
1576 pub fn is_gaussian_identity(&self) -> bool {
1577 self.kind().is_gaussian_identity()
1578 }
1579
1580 #[inline]
1581 pub fn is_royston_parmar(&self) -> bool {
1582 self.kind().is_royston_parmar()
1583 }
1584
1585 #[inline]
1586 pub fn is_latent_cloglog(&self) -> bool {
1587 self.kind().is_latent_cloglog()
1588 }
1589
1590 #[inline]
1591 pub fn is_binomial_mixture(&self) -> bool {
1592 self.kind().is_binomial_mixture()
1593 }
1594
1595 #[inline]
1596 pub fn is_binomial_sas(&self) -> bool {
1597 self.kind().is_binomial_sas()
1598 }
1599
1600 #[inline]
1601 pub fn is_binomial_beta_logistic(&self) -> bool {
1602 self.kind().is_binomial_beta_logistic()
1603 }
1604
1605 /// Default scale metadata for this (response, link).
1606 #[inline]
1607 pub fn default_scale_metadata(&self) -> LikelihoodScaleMetadata {
1608 match &self.response {
1609 ResponseFamily::Gaussian => LikelihoodScaleMetadata::ProfiledGaussian,
1610 ResponseFamily::Gamma => LikelihoodScaleMetadata::EstimatedGammaShape { shape: 1.0 },
1611 // Binomial and Poisson have `phi ≡ 1` (variance fully pinned by the
1612 // mean), so a fixed unit dispersion is correct.
1613 ResponseFamily::Binomial | ResponseFamily::Poisson => {
1614 LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 }
1615 }
1616 // Negative-Binomial's overdispersion `theta` (`Var(y)=mu+mu^2/theta`)
1617 // is a genuine free parameter estimated jointly with the mean by
1618 // default — the family-variant `theta` is only the seed, refined from
1619 // the converged-η ML score during fitting, exactly like the Gamma
1620 // shape / Beta precision / Tweedie φ. Freezing it at the seed made
1621 // every variance-derived output (coefficient/η SEs, Wald and credible
1622 // intervals, predictive intervals, `generate` draws) ignore the
1623 // data's overdispersion (issue #802). `phi` itself stays `≡ 1`.
1624 //
1625 // A user-supplied `--negative-binomial-theta` is the opposite
1626 // contract (issue #983): `theta_fixed = true` routes to the
1627 // non-estimated scale variant, so the inner solver's refresh gate
1628 // (`negbin_theta_is_estimated()`) stays closed and the fit honours
1629 // the held value everywhere it enters.
1630 ResponseFamily::NegativeBinomial { theta, theta_fixed } => {
1631 if *theta_fixed {
1632 LikelihoodScaleMetadata::FixedNegBinTheta { theta: *theta }
1633 } else {
1634 LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: *theta }
1635 }
1636 }
1637 // Tweedie's dispersion `phi` is a genuine free parameter
1638 // (`Var(y) = phi · mu^p`) and is estimated jointly with the mean by
1639 // default, exactly like the Gamma shape and Beta precision. The seed
1640 // `phi = 1` is refined from the converged-η Pearson residuals during
1641 // fitting (issue #771). Freezing it at 1 made every variance-derived
1642 // output (SEs, intervals, generate draws) ignore the data's spread.
1643 ResponseFamily::Tweedie { .. } => {
1644 LikelihoodScaleMetadata::EstimatedTweediePhi { phi: 1.0 }
1645 }
1646 // Beta precision is estimated jointly with the mean by default
1647 // (magic-by-default, issue #567): the family-variant `phi` is the
1648 // seed, refined from the working residuals during fitting.
1649 ResponseFamily::Beta { phi } => LikelihoodScaleMetadata::EstimatedBetaPhi { phi: *phi },
1650 ResponseFamily::RoystonParmar => LikelihoodScaleMetadata::Unspecified,
1651 }
1652 }
1653
1654 /// Human-readable label, routed through `FamilySpecKind`.
1655 #[inline]
1656 pub fn pretty_name(&self) -> &'static str {
1657 self.kind().pretty_name()
1658 }
1659
1660 /// Short identifier, routed through `FamilySpecKind`.
1661 #[inline]
1662 pub fn name(&self) -> &'static str {
1663 self.kind().name()
1664 }
1665
1666 #[inline]
1667 pub fn supports_firth(&self) -> bool {
1668 matches!(self.response, ResponseFamily::Binomial)
1669 && inverse_link_has_fisher_weight_jet(&self.link)
1670 }
1671
1672 /// Family-level fixed-dispersion contract. Returns the dispersion parameter
1673 /// `phi` that the GLM log-likelihood / weight expressions treat as fixed
1674 /// for the given `ResponseFamily`, or `None` when the family carries no
1675 /// fixed scale (profiled or jointly estimated).
1676 ///
1677 /// - `Gaussian` and `Gamma` profile/estimate the scale jointly with the
1678 /// mean, so no fixed `phi` is exposed here.
1679 /// - `Binomial` and `Poisson` are unit-scale exponential-family fits, so the
1680 /// contract is `Some(1.0)`. NegativeBinomial's overdispersion lives in
1681 /// `theta` (a separate parameter / flag), not in a free `phi`, so it also
1682 /// returns `Some(1.0)`.
1683 /// - `Tweedie { p }` carries its variance power on the family variant. Its
1684 /// free dispersion `phi` lives in `LikelihoodScaleMetadata` and is
1685 /// estimated by default (`EstimatedTweediePhi`, issue #771), so this
1686 /// family-level contract only exposes the unit seed used when callers ask
1687 /// the response family without scale metadata.
1688 /// - `Beta { phi }` carries its precision parameter directly on the family
1689 /// variant; the contract returns that exact value rather than the
1690 /// placeholder used elsewhere for unit-scale GLMs.
1691 /// - `RoystonParmar` has no GLM-style dispersion slot.
1692 #[inline]
1693 pub const fn fixed_dispersion(&self) -> Option<f64> {
1694 match self.response {
1695 ResponseFamily::Gaussian | ResponseFamily::Gamma | ResponseFamily::RoystonParmar => {
1696 None
1697 }
1698 ResponseFamily::Binomial
1699 | ResponseFamily::Poisson
1700 | ResponseFamily::Tweedie { .. }
1701 | ResponseFamily::NegativeBinomial { .. } => Some(1.0),
1702 ResponseFamily::Beta { phi } => Some(phi),
1703 }
1704 }
1705}
1706
1707#[inline]
1708pub const fn is_valid_tweedie_power(p: f64) -> bool {
1709 p.is_finite() && p > 1.0 && p < 2.0
1710}
1711
1712/// Error returned when an `InverseLink` cannot be paired with a particular
1713/// response family because the link is structurally unsupported for that
1714/// family. Carries the link name so call sites can produce a useful message
1715/// without losing the offending variant.
1716#[derive(Debug, Clone, PartialEq, Eq)]
1717pub struct UnsupportedLinkError {
1718 pub family: &'static str,
1719 pub link_name: String,
1720}
1721
1722impl UnsupportedLinkError {
1723 /// Construct an `UnsupportedLinkError` tagged with the response-family
1724 /// name (`"binomial"`, `"gaussian"`, ...) and a printable name for the
1725 /// offending `InverseLink` variant (extracted via the module-private
1726 /// `inverse_link_diagnostic_name`). No allocation beyond the link name.
1727 #[inline]
1728 pub fn new(family: &'static str, link: &InverseLink) -> Self {
1729 Self {
1730 family,
1731 link_name: inverse_link_diagnostic_name(link),
1732 }
1733 }
1734}
1735
1736impl std::fmt::Display for UnsupportedLinkError {
1737 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1738 write!(
1739 f,
1740 "inverse link `{}` is not supported by the {} response family",
1741 self.link_name, self.family
1742 )
1743 }
1744}
1745
1746impl std::error::Error for UnsupportedLinkError {}
1747
1748#[inline]
1749pub fn inverse_link_diagnostic_name(link: &InverseLink) -> String {
1750 match link {
1751 InverseLink::Standard(lf) => lf.name().to_string(),
1752 InverseLink::LatentCLogLog(_) => "latent-cloglog".to_string(),
1753 InverseLink::Sas(_) => "sas".to_string(),
1754 InverseLink::BetaLogistic(_) => "beta-logistic".to_string(),
1755 InverseLink::Mixture(_) => "mixture".to_string(),
1756 }
1757}
1758
1759/// Resolve a binomial-flavoured `LikelihoodSpec` from an `InverseLink`.
1760///
1761/// `StandardLink::Logit | Probit | CLogLog` and the state-bearing
1762/// `LatentCLogLog / Sas / BetaLogistic / Mixture` variants are accepted as
1763/// binomial-compatible. `StandardLink::Log | Identity` have no canonical
1764/// binomial meaning and return `UnsupportedLinkError`. Since
1765/// `InverseLink::Standard` carries `StandardLink` (not `LinkFunction`), the
1766/// previously-required `Standard(LinkFunction::Sas | BetaLogistic)` arm is
1767/// structurally impossible and has been removed.
1768#[inline]
1769pub fn inverse_link_to_binomial_spec(
1770 link: &InverseLink,
1771) -> Result<LikelihoodSpec, UnsupportedLinkError> {
1772 match link {
1773 InverseLink::Standard(StandardLink::Logit)
1774 | InverseLink::Standard(StandardLink::Probit)
1775 | InverseLink::Standard(StandardLink::CLogLog) => {
1776 Ok(LikelihoodSpec::new(ResponseFamily::Binomial, link.clone()))
1777 }
1778 InverseLink::LatentCLogLog(_)
1779 | InverseLink::Sas(_)
1780 | InverseLink::BetaLogistic(_)
1781 | InverseLink::Mixture(_) => {
1782 Ok(LikelihoodSpec::new(ResponseFamily::Binomial, link.clone()))
1783 }
1784 InverseLink::Standard(StandardLink::Log)
1785 | InverseLink::Standard(StandardLink::Identity) => {
1786 Err(UnsupportedLinkError::new("binomial", link))
1787 }
1788 }
1789}
1790
1791/// How a likelihood's scale parameter is handled by the fit/result contract.
1792#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1793pub enum LikelihoodScaleMetadata {
1794 /// Gaussian identity fits profile sigma outside the fixed-scale GLM machinery.
1795 ProfiledGaussian,
1796 /// Fixed exponential-dispersion parameter `phi`.
1797 FixedDispersion { phi: f64 },
1798 /// Fixed Gamma shape `k`, equivalent to `phi = 1 / k`.
1799 FixedGammaShape { shape: f64 },
1800 /// Gamma shape `k` estimated jointly with the mean model.
1801 EstimatedGammaShape { shape: f64 },
1802 /// Beta-regression precision `phi` estimated jointly with the mean model.
1803 /// `Var(y) = mu(1-mu)/(1+phi)`; larger `phi` means less noise. Estimated
1804 /// from the working residuals after each mean fit and refreshed across outer
1805 /// iterations, exactly like the Gamma shape (issue #567).
1806 EstimatedBetaPhi { phi: f64 },
1807 /// Tweedie exponential-dispersion `phi` estimated jointly with the mean
1808 /// model. `Var(y) = phi · mu^p` with `phi` a genuine free parameter (unlike
1809 /// Binomial/Poisson, where `phi ≡ 1`). Estimated by the Pearson moment
1810 /// estimator `phî = Σ wᵢ (yᵢ − μᵢ)² / μᵢ^p / Σ wᵢ` at the converged η and
1811 /// refreshed across outer iterations, exactly like the Gamma shape and the
1812 /// Beta precision. `phi` enters the IRLS working weight `prior·μ^{2−p}/phi`,
1813 /// so the coefficient covariance `Vb = H⁻¹` already scales as `phi` and the
1814 /// reported SEs track `√phi` (issue #771).
1815 EstimatedTweediePhi { phi: f64 },
1816 /// Negative-Binomial overdispersion `theta` estimated jointly with the mean
1817 /// model. `Var(y) = mu + mu^2 / theta`; larger `theta` means less
1818 /// overdispersion (the Poisson limit is `theta → ∞`). Estimated by the
1819 /// maximum-likelihood `theta` score
1820 /// `Σ wᵢ[ψ(yᵢ+θ) − ψ(θ) + lnθ + 1 − ln(θ+μᵢ) − (yᵢ+θ)/(μᵢ+θ)] = 0` at the
1821 /// converged η (MASS `glm.nb`'s `theta.ml`) and refreshed across outer
1822 /// iterations, exactly like the Gamma shape / Beta precision / Tweedie φ.
1823 /// Unlike those, `theta` is *not* a dispersion scale `phi`: it enters only
1824 /// the IRLS working weight `W = μθ/(θ+μ)` (the full NB2 Fisher information),
1825 /// so the stored penalized Hessian is already the true one and the
1826 /// coefficient covariance `Vb = H⁻¹` takes no post-hoc multiply — `phi ≡ 1`
1827 /// for NB, the overdispersion lives in the variance function. The `theta`
1828 /// carried here mirrors `ResponseFamily::NegativeBinomial { theta }` (the
1829 /// canonical store every weight/deviance expression reads), kept in sync by
1830 /// `with_negbin_theta`, exactly as `EstimatedBetaPhi` mirrors `Beta { phi }`
1831 /// (issue #802).
1832 EstimatedNegBinTheta { theta: f64 },
1833 /// Negative-Binomial overdispersion `theta` held fixed at a user-supplied
1834 /// value (`--negative-binomial-theta`, issue #983). Identical role to
1835 /// `EstimatedNegBinTheta` in every weight / variance / covariance
1836 /// expression (`W = μθ/(θ+μ)`, `Var(y) = μ + μ²/θ`, `phi ≡ 1`), but the
1837 /// inner solver's ML refresh is gated off: the recorded `theta` is the
1838 /// user's, by construction. The fixed/estimated split mirrors
1839 /// `FixedGammaShape` vs `EstimatedGammaShape`.
1840 FixedNegBinTheta { theta: f64 },
1841 /// The engine does not expose fixed-scale semantics for this family.
1842 Unspecified,
1843}
1844
1845impl LikelihoodScaleMetadata {
1846 #[inline]
1847 pub const fn fixed_phi(self) -> Option<f64> {
1848 match self {
1849 Self::FixedDispersion { phi }
1850 | Self::EstimatedBetaPhi { phi }
1851 | Self::EstimatedTweediePhi { phi } => Some(phi),
1852 Self::FixedGammaShape { shape } | Self::EstimatedGammaShape { shape } => {
1853 Some(1.0 / shape)
1854 }
1855 // NB's dispersion scale is `phi ≡ 1` (the overdispersion is carried
1856 // by `theta` inside the variance function, not a scale multiply), so
1857 // the fixed-`phi` contract is `Some(1.0)` — NOT `theta`.
1858 Self::EstimatedNegBinTheta { .. } | Self::FixedNegBinTheta { .. } => Some(1.0),
1859 Self::ProfiledGaussian | Self::Unspecified => None,
1860 }
1861 }
1862
1863 /// Whether the Negative-Binomial overdispersion `theta` is estimated from
1864 /// data (the default for NB families, issue #802).
1865 #[inline]
1866 pub const fn negbin_theta_is_estimated(self) -> bool {
1867 matches!(self, Self::EstimatedNegBinTheta { .. })
1868 }
1869
1870 /// The Negative-Binomial `theta` carried in the scale metadata (estimated
1871 /// or user-fixed), or `None` for non-NB families.
1872 #[inline]
1873 pub const fn negbin_theta(self) -> Option<f64> {
1874 match self {
1875 Self::EstimatedNegBinTheta { theta } | Self::FixedNegBinTheta { theta } => Some(theta),
1876 _ => None,
1877 }
1878 }
1879
1880 /// Whether the Beta-regression precision `phi` is estimated from data.
1881 #[inline]
1882 pub const fn beta_phi_is_estimated(self) -> bool {
1883 matches!(self, Self::EstimatedBetaPhi { .. })
1884 }
1885
1886 /// Whether the Tweedie exponential-dispersion `phi` is estimated from data.
1887 #[inline]
1888 pub const fn tweedie_phi_is_estimated(self) -> bool {
1889 matches!(self, Self::EstimatedTweediePhi { .. })
1890 }
1891
1892 #[inline]
1893 pub const fn gamma_shape(self) -> Option<f64> {
1894 match self {
1895 Self::FixedGammaShape { shape } | Self::EstimatedGammaShape { shape } => Some(shape),
1896 _ => None,
1897 }
1898 }
1899
1900 #[inline]
1901 pub const fn gamma_shape_is_estimated(self) -> bool {
1902 matches!(self, Self::EstimatedGammaShape { .. })
1903 }
1904}
1905
1906/// Whether a stored log-likelihood includes response-only normalization constants.
1907#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1908pub enum LogLikelihoodNormalization {
1909 Full,
1910 OmittingResponseConstants,
1911 UserProvided,
1912}
1913
1914/// Explicit GLM likelihood specification: response/link spec plus scale semantics.
1915///
1916/// `spec` is the canonical `(ResponseFamily, InverseLink)` selector. `scale`
1917/// records how the scale parameter is handled (profiled Gaussian sigma, fixed
1918/// dispersion, fixed/estimated Gamma shape). The Gamma shape is mutated in
1919/// place during PIRLS via `with_gamma_shape`; preserving that field on this
1920/// struct is what lets the inner solver thread the estimated shape into
1921/// deviance / log-likelihood / weight evaluation without a separate side
1922/// channel.
1923#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1924pub struct GlmLikelihoodSpec {
1925 pub spec: LikelihoodSpec,
1926 pub scale: LikelihoodScaleMetadata,
1927}
1928
1929impl GlmLikelihoodSpec {
1930 /// Build a `GlmLikelihoodSpec` from a `LikelihoodSpec`, deriving the
1931 /// canonical default scale metadata for the response family.
1932 #[inline]
1933 pub fn canonical(spec: LikelihoodSpec) -> Self {
1934 let scale = spec.default_scale_metadata();
1935 Self { spec, scale }
1936 }
1937
1938 #[inline]
1939 pub fn link_function(&self) -> LinkFunction {
1940 self.spec.link_function()
1941 }
1942
1943 #[inline]
1944 pub fn fixed_phi(&self) -> Option<f64> {
1945 self.scale.fixed_phi()
1946 }
1947
1948 /// Multiplier converting the stored unscaled inverse penalized Hessian
1949 /// `H⁻¹` into the reported coefficient covariance `Vb = H⁻¹ · scale`.
1950 ///
1951 /// # Invariant
1952 ///
1953 /// `Vb` is the inverse of the Hessian of the *actual penalized objective the
1954 /// inner solver minimizes*. The stored Hessian is always assembled as
1955 /// `H = XᵀWX + S_λ`, with the penalty `S_λ` added **unscaled** (see
1956 /// `pirls::penalty::add_to_hessian`). Whether `H` is already that true
1957 /// objective Hessian — and hence whether any post-hoc dispersion multiply is
1958 /// warranted — is decided entirely by what the IRLS working weight `W`
1959 /// carries:
1960 ///
1961 /// * **Working weight already carries the reciprocal dispersion / full
1962 /// Fisher information.** Then `H = Xᵀ(W_sf/φ)X + S_λ` already equals the
1963 /// true penalized Hessian (e.g. mgcv's `XᵀW_sfX/φ + S_λ` for Gamma), so
1964 /// `Vb = H⁻¹` and the scale is exactly `1.0`. This is the case for Gamma
1965 /// (`W = prior·shape = prior/φ`), Tweedie (`W = prior·μ^{2−p}/φ`), Beta
1966 /// and Negative-Binomial (the working weight is the complete fixed-scale
1967 /// Fisher information), and the fixed-scale exponential families
1968 /// Poisson/Binomial (`φ ≡ 1`). Multiplying `H⁻¹` by the dispersion again
1969 /// for any of these double-counts it and shrinks every SE by `√dispersion`.
1970 ///
1971 /// * **Working weight is scale-free** (`W = priorweights`, the profiled
1972 /// Gaussian convention). Then the data term carries an implicit unit scale
1973 /// and `H = XᵀPX + S_λ` is the Hessian of `½·(scaled deviance)·σ²⁻¹`
1974 /// *without* the `σ²`. The correct covariance restores it:
1975 /// `Vb = H⁻¹ · σ̂²`. Only this branch returns a non-unit scale.
1976 ///
1977 /// `profiled_gaussian_phi` is the profiled residual variance `σ̂²` and is
1978 /// consulted **only** for the scale-free profiled-Gaussian branch; every
1979 /// other family ignores it. This deliberately does NOT touch
1980 /// `dispersion()` / `dispersion_from_likelihood`, which still report the
1981 /// response-level observation noise (`1/shape` for Gamma, `1/(1+φ)` for
1982 /// Beta, …) used by predictive-interval construction — a distinct quantity
1983 /// from the coefficient-covariance scale defined here.
1984 #[inline]
1985 pub fn coefficient_covariance_scale(&self, profiled_gaussian_phi: f64) -> f64 {
1986 match self.scale {
1987 // Scale-free working weight: restore the profiled variance.
1988 LikelihoodScaleMetadata::ProfiledGaussian => profiled_gaussian_phi,
1989 // Working weight already carries the dispersion / full Fisher
1990 // information, so the stored H is the true penalized Hessian and no
1991 // further dispersion multiply is warranted.
1992 //
1993 // FixedDispersion covers the explicitly-scaled Gaussian submodel
1994 // (W·=1/φ above) and Negative-Binomial; the Gamma, Beta and Tweedie
1995 // variants fold their reciprocal-dispersion / precision / φ into W
1996 // (Tweedie W = prior·μ^{2−p}/φ, so the SE already scales as √φ); and
1997 // Unspecified families never expose a separate post-hoc scale.
1998 LikelihoodScaleMetadata::FixedDispersion { .. }
1999 | LikelihoodScaleMetadata::FixedGammaShape { .. }
2000 | LikelihoodScaleMetadata::EstimatedGammaShape { .. }
2001 | LikelihoodScaleMetadata::EstimatedBetaPhi { .. }
2002 | LikelihoodScaleMetadata::EstimatedTweediePhi { .. }
2003 // Negative-Binomial folds `theta` into the working weight
2004 // `W = μθ/(θ+μ)` (the full NB2 Fisher information), so the stored
2005 // `H = XᵀWX + S_λ` is already the true penalized Hessian and the
2006 // covariance scale is `1.0` (`phi ≡ 1`). The reported SEs respond to
2007 // the data's overdispersion entirely through that `theta`-dependent
2008 // weight (issue #802) — multiplying again would double-count it.
2009 // The same holds verbatim for a user-fixed `theta` (issue #983).
2010 | LikelihoodScaleMetadata::EstimatedNegBinTheta { .. }
2011 | LikelihoodScaleMetadata::FixedNegBinTheta { .. }
2012 | LikelihoodScaleMetadata::Unspecified => 1.0,
2013 }
2014 }
2015
2016 #[inline]
2017 pub fn gamma_shape(&self) -> Option<f64> {
2018 self.scale.gamma_shape()
2019 }
2020
2021 /// Mutate the Gamma shape parameter in place while preserving the rest of
2022 /// the spec. The shape only takes effect for Gamma families; for other
2023 /// families the scale metadata is left untouched.
2024 #[inline]
2025 pub fn with_gamma_shape(mut self, shape: f64) -> Self {
2026 self.scale = match self.scale {
2027 LikelihoodScaleMetadata::FixedGammaShape { .. } => {
2028 LikelihoodScaleMetadata::FixedGammaShape { shape }
2029 }
2030 LikelihoodScaleMetadata::EstimatedGammaShape { .. } => {
2031 LikelihoodScaleMetadata::EstimatedGammaShape { shape }
2032 }
2033 other => match &self.spec.response {
2034 ResponseFamily::Gamma => LikelihoodScaleMetadata::EstimatedGammaShape { shape },
2035 _ => other,
2036 },
2037 };
2038 self
2039 }
2040
2041 /// Whether the Beta-regression precision `phi` is estimated from data.
2042 #[inline]
2043 pub fn beta_phi_is_estimated(&self) -> bool {
2044 self.scale.beta_phi_is_estimated()
2045 }
2046
2047 /// Mutate the Beta precision `phi` in place, on BOTH the family variant
2048 /// (where every PIRLS weight / deviance / log-likelihood expression reads it
2049 /// via `ResponseFamily::Beta { phi }`) and the scale metadata (the
2050 /// estimated-vs-fixed contract). No-op for non-Beta families. The inner
2051 /// solver calls this once per inner solve after a moment estimate of `phi`
2052 /// from the working residuals, so the IRLS weights `Var(y)=mu(1-mu)/(1+phi)`
2053 /// reflect the true precision rather than the `phi=1` seed (issue #567).
2054 #[inline]
2055 pub fn with_beta_phi(mut self, phi: f64) -> Self {
2056 if let ResponseFamily::Beta { phi: family_phi } = &mut self.spec.response {
2057 *family_phi = phi;
2058 self.scale = LikelihoodScaleMetadata::EstimatedBetaPhi { phi };
2059 }
2060 self
2061 }
2062
2063 /// Whether the Tweedie exponential-dispersion `phi` is estimated from data.
2064 #[inline]
2065 pub fn tweedie_phi_is_estimated(&self) -> bool {
2066 self.scale.tweedie_phi_is_estimated()
2067 }
2068
2069 /// Mutate the Tweedie dispersion `phi` in place. Unlike Beta, the Tweedie
2070 /// power `p` (not `phi`) is what is carried on the `ResponseFamily::Tweedie`
2071 /// variant; the dispersion lives purely in the scale metadata and is read by
2072 /// the IRLS weight (`prior·μ^{2−p}/phi`) through `fixed_phi()`. So updating
2073 /// the metadata here is sufficient to thread the estimated `phi` into every
2074 /// weight / covariance expression. No-op for non-Tweedie families (issue
2075 /// #771).
2076 #[inline]
2077 pub fn with_tweedie_phi(mut self, phi: f64) -> Self {
2078 if matches!(self.spec.response, ResponseFamily::Tweedie { .. }) {
2079 self.scale = LikelihoodScaleMetadata::EstimatedTweediePhi { phi };
2080 }
2081 self
2082 }
2083
2084 /// Whether the Negative-Binomial overdispersion `theta` is estimated from
2085 /// data (issue #802).
2086 #[inline]
2087 pub fn negbin_theta_is_estimated(&self) -> bool {
2088 self.scale.negbin_theta_is_estimated()
2089 }
2090
2091 /// Mutate the Negative-Binomial overdispersion `theta` in place, on BOTH the
2092 /// family variant (where every PIRLS weight / deviance / log-likelihood
2093 /// expression reads it via `ResponseFamily::NegativeBinomial { theta }`) and
2094 /// the scale metadata (the estimated-vs-fixed contract). No-op for non-NB
2095 /// families. The inner solver calls this once per inner solve after a
2096 /// maximum-likelihood estimate of `theta` from the working residuals, so the
2097 /// IRLS weight `W = μθ/(θ+μ)` and the variance `Var(y)=mu+mu^2/theta` reflect
2098 /// the data's overdispersion rather than the seed `theta` (issue #802). This
2099 /// mirrors `with_beta_phi` exactly — both keep the family variant and the
2100 /// scale metadata as two synchronized views of one estimated parameter.
2101 /// No-op for a user-fixed `theta` (`theta_fixed = true` /
2102 /// `FixedNegBinTheta`, issue #983): the held value is the contract, and
2103 /// this mutator must never let an estimation path overwrite it — the
2104 /// PIRLS refresh gate (`negbin_theta_is_estimated()`) already skips the
2105 /// call, this enforces the same invariant at the data itself.
2106 #[inline]
2107 pub fn with_negbin_theta(mut self, theta: f64) -> Self {
2108 if let ResponseFamily::NegativeBinomial {
2109 theta: family_theta,
2110 theta_fixed,
2111 } = &mut self.spec.response
2112 && !*theta_fixed
2113 {
2114 *family_theta = theta;
2115 self.scale = LikelihoodScaleMetadata::EstimatedNegBinTheta { theta };
2116 }
2117 self
2118 }
2119
2120 /// The estimated Negative-Binomial `theta`, read from the family variant
2121 /// (the canonical store), or `None` for non-NB families.
2122 #[inline]
2123 pub fn negbin_theta(&self) -> Option<f64> {
2124 match self.spec.response {
2125 ResponseFamily::NegativeBinomial { theta, .. } => Some(theta),
2126 _ => None,
2127 }
2128 }
2129
2130 /// Produce a copy of this spec with the Tweedie exponential-dispersion
2131 /// `phi` PINNED at `phi` for the duration of the smoothing-parameter (λ)
2132 /// search (#1477). Converts an `EstimatedTweediePhi` scale into the
2133 /// statistically-identical `FixedDispersion` form, which gates off the
2134 /// per-inner-solve Pearson refresh in
2135 /// `GamWorkingModel::update_with_curvature` (its guard is
2136 /// `tweedie_phi_is_estimated()`, which `FixedDispersion` does not satisfy)
2137 /// while leaving every weight / variance / covariance expression unchanged
2138 /// (they read `phi` through `fixed_phi()`, which `FixedDispersion` answers
2139 /// identically).
2140 ///
2141 /// Rationale: with `phi` estimated, the inner solver re-derives it from each
2142 /// outer iterate's *warm-start* η (the Pearson moment estimator
2143 /// `phî = Σ wᵢ(yᵢ−μᵢ)²/μᵢ^p / Σ wᵢ`). The Tweedie LAML omits the
2144 /// `phi`-dependent saddlepoint normalizer `a(y,φ)` from `−ℓ(β̂)` — valid only
2145 /// when `phi` is fixed across the surface — so a drifting `phi` makes
2146 /// `F(ρ)` a non-stationary function of ρ that REWARDS dispersion inflation:
2147 /// driving a double-penalty null-space `λ` up kills a genuinely-supported
2148 /// linear trend, the residuals grow, the warm-start `phî` rises, and the
2149 /// `[yθ−κ]/φ` deviance term shrinks with no compensating normalizer penalty,
2150 /// so the criterion falls and the outer optimizer rails `λ_null` to the box
2151 /// bound (the #1477 Tweedie double-penalty boundary blow-up). Holding `phi`
2152 /// fixed across the λ-search makes `F(ρ) = REML(ρ, φ_frozen)` a genuine
2153 /// stationary function of ρ, exactly as for the Gaussian profiled scale
2154 /// (whose `(n−Mp)/2·log(2πφ̂)` normalizer is retained) and as mgcv does for
2155 /// Tweedie. `phi` is still Pearson-refreshed at the single final reported fit
2156 /// (the `refine_dispersion_at_converged_eta = true` accept-fit). No-op for
2157 /// non-Tweedie families and for a user-fixed `phi`.
2158 #[inline]
2159 pub fn with_tweedie_phi_frozen_for_search(mut self, phi: f64) -> Self {
2160 if matches!(self.spec.response, ResponseFamily::Tweedie { .. })
2161 && self.scale.tweedie_phi_is_estimated()
2162 {
2163 self.scale = LikelihoodScaleMetadata::FixedDispersion { phi };
2164 }
2165 self
2166 }
2167
2168 /// Produce a copy of this spec with the Negative-Binomial overdispersion
2169 /// `theta` PINNED at `theta` for the duration of the smoothing-parameter
2170 /// (λ) search (#1082). Converts an `EstimatedNegBinTheta` spec into the
2171 /// statistically-identical `FixedNegBinTheta` form (`theta_fixed = true`),
2172 /// which gates off the per-inner-solve ML refresh in
2173 /// `GamWorkingModel::update_with_curvature` (its guard is
2174 /// `negbin_theta_is_estimated()`).
2175 ///
2176 /// Rationale: with θ estimated, the inner solver re-derives θ from each
2177 /// outer iterate's *warm-start* η, so θ — and hence the NB working response,
2178 /// deviance and penalty-logdet that feed the REML criterion — drifts every
2179 /// outer evaluation. The outer optimizer then chases a moving target and the
2180 /// projected-gradient convergence test never trips, grinding the loop to
2181 /// `max_iter` (the #1082 negative-binomial tensor timeout). Holding θ fixed
2182 /// across the λ-search makes the REML objective `F(ρ) = REML(ρ, θ_frozen)` a
2183 /// genuine stationary function of ρ, so the loop converges in a handful of
2184 /// iterations — and θ is still ML-refreshed at the single final, reported fit
2185 /// (the `refine_dispersion_at_converged_eta = true` accept-fit), exactly as
2186 /// the function-level docs require ("estimate the scale at the converged fit,
2187 /// not inside the λ search; mgcv likewise"). No-op for non-NB families and
2188 /// for an already user-fixed θ.
2189 #[inline]
2190 pub fn with_negbin_theta_frozen_for_search(mut self, theta: f64) -> Self {
2191 if let ResponseFamily::NegativeBinomial {
2192 theta: family_theta,
2193 theta_fixed,
2194 } = &mut self.spec.response
2195 {
2196 *family_theta = theta;
2197 *theta_fixed = true;
2198 self.scale = LikelihoodScaleMetadata::FixedNegBinTheta { theta };
2199 }
2200 self
2201 }
2202
2203 /// Produce a copy of this spec with the Gamma shape `k = 1/φ` PINNED at
2204 /// `shape` for the duration of the smoothing-parameter (λ) search (#1074).
2205 /// Converts an `EstimatedGammaShape` scale into the statistically-identical
2206 /// `FixedGammaShape` form, which gates off the per-inner-solve shape refresh
2207 /// in `GamWorkingModel::update_with_curvature` (its guard is
2208 /// `gamma_shape_is_estimated()`, which `FixedGammaShape` does not satisfy)
2209 /// while leaving every weight / deviance / log-likelihood expression
2210 /// unchanged (they read the shape through `gamma_shape()` / `fixed_phi()`,
2211 /// which `FixedGammaShape` answers identically).
2212 ///
2213 /// Rationale: with the shape estimated, the inner solver re-derives it from
2214 /// each outer iterate's *warm-start* η (the converged-η MLE
2215 /// `k̂` solving `ln k − ψ(k) = mean[y/μ − ln(y/μ) − 1]`). The Gamma working
2216 /// weight is `W = prior·k` and the omitting-constants log-likelihood is
2217 /// `ℓ(β̂) = −k·½·D(ρ)` (the `k`-dependent saturated normalizer is dropped,
2218 /// #359), so a `k` that swings 2×↔ with the warm-start η makes BOTH the
2219 /// likelihood-curvature `H = k·XᵀX + λS` and the data-fit term `k·½D` jump
2220 /// discontinuously with ρ — the REML criterion `V(ρ)` develops deterministic
2221 /// spikes between the smooth basin floors (e.g. a flat warm-start η at a
2222 /// just-rejected over-smoothed trial gives `k≈2.3`, the fitted-surface η at
2223 /// the neighbor gives `k≈4.7`, doubling `−ℓ` with β̂ essentially unchanged).
2224 /// The analytic outer gradient holds `k` fixed, so it can never agree with
2225 /// the realized cost's `k(ρ)` motion: the projected gradient floors at
2226 /// `O(|∂k/∂ρ|·½D)` and the ARC descent stalls on a weakly-identified valley,
2227 /// railing `λ` to the over-smoothed corner (the #1074 te/Gamma tensor
2228 /// under-recovery). Holding `k` fixed across the λ-search makes
2229 /// `F(ρ) = REML(ρ, k_frozen)` a genuine stationary function of ρ, exactly as
2230 /// the sibling Tweedie-φ (#1477) and NB-θ (#1082) freezes do, and as mgcv
2231 /// does (it fixes the scale across the smoothness search for the scale-free
2232 /// Gamma mean). `k` is still ML-refreshed at the single final reported fit
2233 /// (the `refine_dispersion_at_converged_eta = true` accept-fit), so the
2234 /// reported dispersion / SEs remain the converged-η estimate. No-op for
2235 /// non-Gamma families and for a user-fixed shape.
2236 #[inline]
2237 pub fn with_gamma_shape_frozen_for_search(mut self, shape: f64) -> Self {
2238 if matches!(self.spec.response, ResponseFamily::Gamma)
2239 && self.scale.gamma_shape_is_estimated()
2240 {
2241 self.scale = LikelihoodScaleMetadata::FixedGammaShape { shape };
2242 }
2243 self
2244 }
2245}
2246
2247#[cfg(test)]
2248mod tests {
2249 use super::*;
2250 use ndarray::arr1;
2251
2252 // -----------------------------------------------------------------------
2253 // CoefficientGroupPrior::validate
2254 // -----------------------------------------------------------------------
2255
2256 #[test]
2257 fn prior_flat_always_ok() {
2258 assert!(CoefficientGroupPrior::Flat.validate("ctx").is_ok());
2259 }
2260
2261 #[test]
2262 fn prior_normal_log_precision_valid() {
2263 assert!(
2264 CoefficientGroupPrior::NormalLogPrecision { mean: 0.0, sd: 1.0 }
2265 .validate("ctx")
2266 .is_ok()
2267 );
2268 }
2269
2270 #[test]
2271 fn prior_normal_log_precision_infinite_mean_errors() {
2272 assert!(
2273 CoefficientGroupPrior::NormalLogPrecision { mean: f64::INFINITY, sd: 1.0 }
2274 .validate("ctx")
2275 .is_err()
2276 );
2277 }
2278
2279 #[test]
2280 fn prior_normal_log_precision_zero_sd_errors() {
2281 assert!(
2282 CoefficientGroupPrior::NormalLogPrecision { mean: 0.0, sd: 0.0 }
2283 .validate("ctx")
2284 .is_err()
2285 );
2286 }
2287
2288 #[test]
2289 fn prior_normal_log_precision_negative_sd_errors() {
2290 assert!(
2291 CoefficientGroupPrior::NormalLogPrecision { mean: 0.0, sd: -1.0 }
2292 .validate("ctx")
2293 .is_err()
2294 );
2295 }
2296
2297 #[test]
2298 fn prior_gamma_precision_valid() {
2299 assert!(
2300 CoefficientGroupPrior::GammaPrecision { shape: 1.0, rate: 0.0 }
2301 .validate("ctx")
2302 .is_ok()
2303 );
2304 }
2305
2306 #[test]
2307 fn prior_gamma_precision_zero_shape_errors() {
2308 assert!(
2309 CoefficientGroupPrior::GammaPrecision { shape: 0.0, rate: 1.0 }
2310 .validate("ctx")
2311 .is_err()
2312 );
2313 }
2314
2315 #[test]
2316 fn prior_gamma_precision_negative_rate_errors() {
2317 assert!(
2318 CoefficientGroupPrior::GammaPrecision { shape: 1.0, rate: -0.1 }
2319 .validate("ctx")
2320 .is_err()
2321 );
2322 }
2323
2324 #[test]
2325 fn prior_penalized_complexity_valid() {
2326 assert!(
2327 CoefficientGroupPrior::PenalizedComplexity { upper: 1.0, tail_prob: 0.05 }
2328 .validate("ctx")
2329 .is_ok()
2330 );
2331 }
2332
2333 #[test]
2334 fn prior_penalized_complexity_zero_upper_errors() {
2335 assert!(
2336 CoefficientGroupPrior::PenalizedComplexity { upper: 0.0, tail_prob: 0.05 }
2337 .validate("ctx")
2338 .is_err()
2339 );
2340 }
2341
2342 #[test]
2343 fn prior_penalized_complexity_tail_prob_zero_errors() {
2344 assert!(
2345 CoefficientGroupPrior::PenalizedComplexity { upper: 1.0, tail_prob: 0.0 }
2346 .validate("ctx")
2347 .is_err()
2348 );
2349 }
2350
2351 #[test]
2352 fn prior_penalized_complexity_tail_prob_one_errors() {
2353 assert!(
2354 CoefficientGroupPrior::PenalizedComplexity { upper: 1.0, tail_prob: 1.0 }
2355 .validate("ctx")
2356 .is_err()
2357 );
2358 }
2359
2360 // -----------------------------------------------------------------------
2361 // LatentCLogLogState::new
2362 // -----------------------------------------------------------------------
2363
2364 #[test]
2365 fn latent_cloglog_zero_sd_ok() {
2366 assert!(LatentCLogLogState::new(0.0).is_ok());
2367 }
2368
2369 #[test]
2370 fn latent_cloglog_positive_sd_ok() {
2371 assert!(LatentCLogLogState::new(1.5).is_ok());
2372 }
2373
2374 #[test]
2375 fn latent_cloglog_negative_sd_errors() {
2376 assert!(LatentCLogLogState::new(-0.1).is_err());
2377 }
2378
2379 #[test]
2380 fn latent_cloglog_infinite_sd_errors() {
2381 assert!(LatentCLogLogState::new(f64::INFINITY).is_err());
2382 }
2383
2384 #[test]
2385 fn latent_cloglog_nan_errors() {
2386 assert!(LatentCLogLogState::new(f64::NAN).is_err());
2387 }
2388
2389 // -----------------------------------------------------------------------
2390 // WigglePenaltyConfig::cubic_triple_operator_default
2391 // -----------------------------------------------------------------------
2392
2393 #[test]
2394 fn wiggle_penalty_default_fields() {
2395 let cfg = WigglePenaltyConfig::cubic_triple_operator_default();
2396 assert_eq!(cfg.degree, 3);
2397 assert_eq!(cfg.num_internal_knots, 8);
2398 assert_eq!(cfg.penalty_orders, vec![1, 2, 3]);
2399 assert!(cfg.double_penalty);
2400 assert!((cfg.monotonicity_eps - 1e-4).abs() < 1e-15);
2401 }
2402
2403 // -----------------------------------------------------------------------
2404 // is_valid_tweedie_power
2405 // -----------------------------------------------------------------------
2406
2407 #[test]
2408 fn tweedie_power_valid_interior() {
2409 assert!(is_valid_tweedie_power(1.5));
2410 assert!(is_valid_tweedie_power(1.1));
2411 assert!(is_valid_tweedie_power(1.9));
2412 }
2413
2414 #[test]
2415 fn tweedie_power_boundaries_invalid() {
2416 assert!(!is_valid_tweedie_power(1.0));
2417 assert!(!is_valid_tweedie_power(2.0));
2418 }
2419
2420 #[test]
2421 fn tweedie_power_outside_interval_invalid() {
2422 assert!(!is_valid_tweedie_power(0.5));
2423 assert!(!is_valid_tweedie_power(2.5));
2424 assert!(!is_valid_tweedie_power(-1.0));
2425 assert!(!is_valid_tweedie_power(f64::INFINITY));
2426 }
2427
2428 // -----------------------------------------------------------------------
2429 // StandardLink <-> LinkFunction conversions
2430 // -----------------------------------------------------------------------
2431
2432 #[test]
2433 fn standard_link_roundtrip_to_link_function() {
2434 assert_eq!(StandardLink::Logit.as_link_function(), LinkFunction::Logit);
2435 assert_eq!(StandardLink::Probit.as_link_function(), LinkFunction::Probit);
2436 assert_eq!(StandardLink::CLogLog.as_link_function(), LinkFunction::CLogLog);
2437 assert_eq!(StandardLink::Identity.as_link_function(), LinkFunction::Identity);
2438 assert_eq!(StandardLink::Log.as_link_function(), LinkFunction::Log);
2439 }
2440
2441 #[test]
2442 fn standard_link_from_link_function_state_bearing_errors() {
2443 assert!(StandardLink::try_from(LinkFunction::Sas).is_err());
2444 assert!(StandardLink::try_from(LinkFunction::BetaLogistic).is_err());
2445 }
2446
2447 #[test]
2448 fn standard_link_from_link_function_standard_ok() {
2449 assert_eq!(StandardLink::try_from(LinkFunction::Logit), Ok(StandardLink::Logit));
2450 assert_eq!(StandardLink::try_from(LinkFunction::Log), Ok(StandardLink::Log));
2451 }
2452
2453 // -----------------------------------------------------------------------
2454 // LikelihoodSpec: legal-cell matrix
2455 // -----------------------------------------------------------------------
2456
2457 #[test]
2458 fn legal_cells_accepted() {
2459 assert!(LikelihoodSpec::try_new(
2460 ResponseFamily::Gaussian,
2461 InverseLink::Standard(StandardLink::Identity)
2462 )
2463 .is_ok());
2464 assert!(LikelihoodSpec::try_new(
2465 ResponseFamily::Poisson,
2466 InverseLink::Standard(StandardLink::Log)
2467 )
2468 .is_ok());
2469 assert!(LikelihoodSpec::try_new(
2470 ResponseFamily::Gamma,
2471 InverseLink::Standard(StandardLink::Log)
2472 )
2473 .is_ok());
2474 assert!(LikelihoodSpec::try_new(
2475 ResponseFamily::Beta { phi: 1.0 },
2476 InverseLink::Standard(StandardLink::Logit)
2477 )
2478 .is_ok());
2479 assert!(LikelihoodSpec::try_new(
2480 ResponseFamily::Binomial,
2481 InverseLink::Standard(StandardLink::Logit)
2482 )
2483 .is_ok());
2484 assert!(LikelihoodSpec::try_new(
2485 ResponseFamily::Binomial,
2486 InverseLink::Standard(StandardLink::Probit)
2487 )
2488 .is_ok());
2489 assert!(LikelihoodSpec::try_new(
2490 ResponseFamily::Binomial,
2491 InverseLink::Standard(StandardLink::CLogLog)
2492 )
2493 .is_ok());
2494 }
2495
2496 #[test]
2497 fn illegal_cells_rejected() {
2498 assert!(LikelihoodSpec::try_new(
2499 ResponseFamily::Poisson,
2500 InverseLink::Standard(StandardLink::Identity)
2501 )
2502 .is_err());
2503 assert!(LikelihoodSpec::try_new(
2504 ResponseFamily::Gaussian,
2505 InverseLink::Standard(StandardLink::Logit)
2506 )
2507 .is_err());
2508 assert!(LikelihoodSpec::try_new(
2509 ResponseFamily::Binomial,
2510 InverseLink::Standard(StandardLink::Log)
2511 )
2512 .is_err());
2513 assert!(LikelihoodSpec::try_new(
2514 ResponseFamily::Binomial,
2515 InverseLink::Standard(StandardLink::Identity)
2516 )
2517 .is_err());
2518 }
2519
2520 #[test]
2521 fn likelihood_spec_kind_names() {
2522 assert_eq!(LikelihoodSpec::gaussian_identity().name(), "gaussian");
2523 assert_eq!(LikelihoodSpec::poisson_log().name(), "poisson-log");
2524 assert_eq!(LikelihoodSpec::binomial_logit().name(), "binomial-logit");
2525 assert_eq!(LikelihoodSpec::gamma_log().name(), "gamma-log");
2526 }
2527
2528 // -----------------------------------------------------------------------
2529 // ResponseFamily::infer_from_response
2530 // -----------------------------------------------------------------------
2531
2532 #[test]
2533 fn infer_binary_kind_gives_binomial() {
2534 let y = arr1(&[0.0_f64, 1.0]);
2535 let result = ResponseFamily::infer_from_response(y.view(), ResponseColumnKind::Binary);
2536 assert!(matches!(result, Ok(ResponseFamily::Binomial)));
2537 }
2538
2539 #[test]
2540 fn infer_categorical_kind_refuses() {
2541 let y = arr1(&[0.0_f64, 1.0]);
2542 let result = ResponseFamily::infer_from_response(
2543 y.view(),
2544 ResponseColumnKind::Categorical { levels: vec!["yes".to_string(), "no".to_string()] },
2545 );
2546 assert!(result.is_err());
2547 }
2548
2549 #[test]
2550 fn infer_numeric_binary_values_gives_binomial() {
2551 let y = arr1(&[0.0_f64, 1.0, 0.0, 1.0, 0.0]);
2552 let result =
2553 ResponseFamily::infer_from_response(y.view(), ResponseColumnKind::Numeric);
2554 assert!(matches!(result, Ok(ResponseFamily::Binomial)));
2555 }
2556
2557 #[test]
2558 fn infer_numeric_count_values_gives_poisson() {
2559 let y = arr1(&[0.0_f64, 1.0, 2.0, 3.0, 5.0]);
2560 let result =
2561 ResponseFamily::infer_from_response(y.view(), ResponseColumnKind::Numeric);
2562 assert!(matches!(result, Ok(ResponseFamily::Poisson)));
2563 }
2564
2565 #[test]
2566 fn infer_numeric_fractional_gives_gaussian() {
2567 let y = arr1(&[1.5_f64, 2.3, 3.7]);
2568 let result =
2569 ResponseFamily::infer_from_response(y.view(), ResponseColumnKind::Numeric);
2570 assert!(matches!(result, Ok(ResponseFamily::Gaussian)));
2571 }
2572
2573 #[test]
2574 fn infer_numeric_negative_gives_gaussian() {
2575 let y = arr1(&[-1.0_f64, 0.0, 1.0]);
2576 let result =
2577 ResponseFamily::infer_from_response(y.view(), ResponseColumnKind::Numeric);
2578 assert!(matches!(result, Ok(ResponseFamily::Gaussian)));
2579 }
2580
2581 // -----------------------------------------------------------------------
2582 // ResponseFamily::validate_response_support
2583 // -----------------------------------------------------------------------
2584
2585 #[test]
2586 fn gaussian_support_accepts_any_finite() {
2587 let y = arr1(&[-100.0_f64, 0.0, 100.0]);
2588 assert!(ResponseFamily::Gaussian.validate_response_support(y.view()).is_ok());
2589 }
2590
2591 #[test]
2592 fn gamma_support_rejects_zero() {
2593 let y = arr1(&[0.0_f64, 1.0, 2.0]);
2594 assert!(ResponseFamily::Gamma.validate_response_support(y.view()).is_err());
2595 }
2596
2597 #[test]
2598 fn gamma_support_rejects_negative() {
2599 let y = arr1(&[-1.0_f64, 1.0]);
2600 assert!(ResponseFamily::Gamma.validate_response_support(y.view()).is_err());
2601 }
2602
2603 #[test]
2604 fn gamma_support_accepts_positive() {
2605 let y = arr1(&[0.1_f64, 1.0, 100.0]);
2606 assert!(ResponseFamily::Gamma.validate_response_support(y.view()).is_ok());
2607 }
2608
2609 #[test]
2610 fn binomial_support_accepts_fractional_proportions() {
2611 let y = arr1(&[0.0_f64, 0.5, 1.0]);
2612 assert!(ResponseFamily::Binomial.validate_response_support(y.view()).is_ok());
2613 }
2614
2615 #[test]
2616 fn binomial_support_rejects_values_outside_unit_interval() {
2617 let y = arr1(&[0.0_f64, -0.1, 1.1]);
2618 assert!(ResponseFamily::Binomial.validate_response_support(y.view()).is_err());
2619 }
2620
2621 #[test]
2622 fn binomial_support_accepts_binary() {
2623 let y = arr1(&[0.0_f64, 1.0, 0.0, 1.0]);
2624 assert!(ResponseFamily::Binomial.validate_response_support(y.view()).is_ok());
2625 }
2626
2627 #[test]
2628 fn poisson_support_rejects_negative() {
2629 let y = arr1(&[-1.0_f64, 0.0, 1.0]);
2630 assert!(ResponseFamily::Poisson.validate_response_support(y.view()).is_err());
2631 }
2632
2633 #[test]
2634 fn poisson_support_accepts_nonneg() {
2635 let y = arr1(&[0.0_f64, 1.0, 2.0, 10.0]);
2636 assert!(ResponseFamily::Poisson.validate_response_support(y.view()).is_ok());
2637 }
2638
2639 #[test]
2640 fn beta_support_rejects_zero_boundary() {
2641 let y = arr1(&[0.0_f64, 0.5]);
2642 assert!(ResponseFamily::Beta { phi: 1.0 }.validate_response_support(y.view()).is_err());
2643 }
2644
2645 #[test]
2646 fn beta_support_rejects_one_boundary() {
2647 let y = arr1(&[0.5_f64, 1.0]);
2648 assert!(ResponseFamily::Beta { phi: 1.0 }.validate_response_support(y.view()).is_err());
2649 }
2650
2651 #[test]
2652 fn beta_support_accepts_open_interval() {
2653 let y = arr1(&[0.1_f64, 0.5, 0.9]);
2654 assert!(ResponseFamily::Beta { phi: 1.0 }.validate_response_support(y.view()).is_ok());
2655 }
2656
2657 // -----------------------------------------------------------------------
2658 // ResponseFamily::validate_response_degeneracy
2659 // -----------------------------------------------------------------------
2660
2661 #[test]
2662 fn binomial_degeneracy_all_zeros_errors() {
2663 let y = arr1(&[0.0_f64, 0.0, 0.0]);
2664 assert!(ResponseFamily::Binomial.validate_response_degeneracy(y.view()).is_err());
2665 }
2666
2667 #[test]
2668 fn binomial_degeneracy_all_ones_errors() {
2669 let y = arr1(&[1.0_f64, 1.0, 1.0]);
2670 assert!(ResponseFamily::Binomial.validate_response_degeneracy(y.view()).is_err());
2671 }
2672
2673 #[test]
2674 fn binomial_degeneracy_mixed_ok() {
2675 let y = arr1(&[0.0_f64, 1.0, 0.0]);
2676 assert!(ResponseFamily::Binomial.validate_response_degeneracy(y.view()).is_ok());
2677 }
2678
2679 #[test]
2680 fn binomial_degeneracy_fractional_proportions_ok() {
2681 let y = arr1(&[0.0_f64, 0.5, 0.75]);
2682 assert!(ResponseFamily::Binomial.validate_response_degeneracy(y.view()).is_ok());
2683 }
2684
2685 #[test]
2686 fn gaussian_degeneracy_exactly_constant_ok() {
2687 // A *genuinely* zero-variance response \u{2014} every value bit-for-bit
2688 // identical \u{2014} is the well-posed constant limit, not the #332
2689 // divergence: the fit collapses to the constant (intercept = the shared
2690 // value, smooths shrunk to zero). The guard must accept it and let the
2691 // fitter return the constant surface (#1856); only a response that
2692 // varies below the sd floor without being exactly constant keeps the
2693 // rejection (see `gaussian_degeneracy_near_constant_reproducer_errors`).
2694 let y = arr1(&[1.0_f64, 1.0, 1.0]);
2695 assert!(ResponseFamily::Gaussian
2696 .validate_response_degeneracy(y.view())
2697 .is_ok());
2698 }
2699
2700 #[test]
2701 fn gaussian_degeneracy_near_constant_reproducer_errors() {
2702 // The issue reproducer: a response with sd ~ 1e-13, well below the
2703 // `1e-10` floor, so the REML score blows up to +inf without the guard.
2704 let y = arr1(&[
2705 5.0_f64,
2706 5.0 + 1.0e-13,
2707 5.0 - 1.0e-13,
2708 5.0 + 2.0e-13,
2709 5.0 - 2.0e-13,
2710 ]);
2711 let err = ResponseFamily::Gaussian
2712 .validate_response_degeneracy(y.view())
2713 .expect_err("near-constant Gaussian response must be rejected");
2714 match err.kind {
2715 ResponseDegeneracyKind::GaussianNearConstant { sample_sd, min_sd } => {
2716 assert!(
2717 sample_sd <= min_sd,
2718 "guard must fire only when sample_sd ({sample_sd:.3e}) <= min_sd ({min_sd:.0e})"
2719 );
2720 assert_eq!(min_sd, GAUSSIAN_MIN_SAMPLE_SD);
2721 // The message quotes both numbers verbatim.
2722 let msg = err.message_for("y");
2723 assert!(msg.contains("effectively constant"), "msg = {msg}");
2724 }
2725 other => panic!("expected GaussianNearConstant, got {other:?}"),
2726 }
2727 }
2728
2729 #[test]
2730 fn gaussian_degeneracy_well_conditioned_ok() {
2731 // A genuinely varying response (sd ~ O(1)) is never tripped.
2732 let y = arr1(&[-2.0_f64, 0.5, 1.7, 3.0, -1.1, 2.2]);
2733 assert!(ResponseFamily::Gaussian
2734 .validate_response_degeneracy(y.view())
2735 .is_ok());
2736 }
2737
2738 #[test]
2739 fn gaussian_degeneracy_small_signal_above_floor_ok() {
2740 // sd ~ 1e-6 is small but far above the 1e-10 floor: a legitimately
2741 // small-but-real signal (e.g. a finely-resolved measurement) must fit,
2742 // so the guard must not over-reject.
2743 let y = arr1(&[1.0_f64, 1.0 + 1.0e-6, 1.0 - 1.0e-6, 1.0 + 2.0e-6]);
2744 assert!(ResponseFamily::Gaussian
2745 .validate_response_degeneracy(y.view())
2746 .is_ok());
2747 }
2748
2749 #[test]
2750 fn gaussian_degeneracy_single_observation_ok() {
2751 // Fewer than two observations carries no estimable scale degeneracy;
2752 // the sample-size gate handles too-small data separately.
2753 let y = arr1(&[42.0_f64]);
2754 assert!(ResponseFamily::Gaussian
2755 .validate_response_degeneracy(y.view())
2756 .is_ok());
2757 }
2758
2759 // -----------------------------------------------------------------------
2760 // ResponseFamily::mean_clamp_bounds / response_support_bounds
2761 // -----------------------------------------------------------------------
2762
2763 #[test]
2764 fn mean_clamp_bounds_binomial_unit_interval() {
2765 assert_eq!(ResponseFamily::Binomial.mean_clamp_bounds(), Some((0.0, 1.0)));
2766 }
2767
2768 #[test]
2769 fn mean_clamp_bounds_gaussian_none() {
2770 assert_eq!(ResponseFamily::Gaussian.mean_clamp_bounds(), None);
2771 }
2772
2773 #[test]
2774 fn mean_clamp_bounds_poisson_none() {
2775 assert_eq!(ResponseFamily::Poisson.mean_clamp_bounds(), None);
2776 }
2777
2778 #[test]
2779 fn response_support_bounds_gamma_nonneg_to_inf() {
2780 assert_eq!(
2781 ResponseFamily::Gamma.response_support_bounds(),
2782 Some((0.0, f64::INFINITY))
2783 );
2784 }
2785
2786 #[test]
2787 fn response_support_bounds_binomial_unit_interval() {
2788 assert_eq!(ResponseFamily::Binomial.response_support_bounds(), Some((0.0, 1.0)));
2789 }
2790
2791 #[test]
2792 fn response_support_bounds_gaussian_none() {
2793 assert_eq!(ResponseFamily::Gaussian.response_support_bounds(), None);
2794 }
2795
2796 // -----------------------------------------------------------------------
2797 // ResponseSupportViolation::message_for
2798 // -----------------------------------------------------------------------
2799
2800 #[test]
2801 fn violation_message_names_column() {
2802 let y = arr1(&[-1.0_f64]);
2803 let err = ResponseFamily::Gamma
2804 .validate_response_support(y.view())
2805 .unwrap_err();
2806 let msg = err.message_for("my_column");
2807 assert!(msg.contains("my_column"), "message: {msg}");
2808 assert!(msg.contains("Gamma"), "message: {msg}");
2809 }
2810
2811 // -----------------------------------------------------------------------
2812 // inverse_link_to_binomial_spec
2813 // -----------------------------------------------------------------------
2814
2815 #[test]
2816 fn binomial_spec_from_logit_link_ok() {
2817 let link = InverseLink::Standard(StandardLink::Logit);
2818 assert!(inverse_link_to_binomial_spec(&link).is_ok());
2819 }
2820
2821 #[test]
2822 fn binomial_spec_from_log_link_errors() {
2823 let link = InverseLink::Standard(StandardLink::Log);
2824 assert!(inverse_link_to_binomial_spec(&link).is_err());
2825 }
2826
2827 #[test]
2828 fn binomial_spec_from_identity_link_errors() {
2829 let link = InverseLink::Standard(StandardLink::Identity);
2830 assert!(inverse_link_to_binomial_spec(&link).is_err());
2831 }
2832
2833 // -----------------------------------------------------------------------
2834 // FamilySpecKind::name / pretty_name
2835 // -----------------------------------------------------------------------
2836
2837 #[test]
2838 fn family_spec_kind_name_non_binomial_variants() {
2839 assert_eq!(FamilySpecKind::GaussianIdentity.name(), "gaussian");
2840 assert_eq!(FamilySpecKind::PoissonLog.name(), "poisson-log");
2841 assert_eq!(FamilySpecKind::GammaLog.name(), "gamma-log");
2842 assert_eq!(FamilySpecKind::TweedieLog { p: 1.5 }.name(), "tweedie-log");
2843 assert_eq!(
2844 FamilySpecKind::NegativeBinomialLog { theta: 2.0 }.name(),
2845 "negative-binomial-log"
2846 );
2847 assert_eq!(FamilySpecKind::BetaLogit { phi: 5.0 }.name(), "beta-regression-logit");
2848 assert_eq!(FamilySpecKind::RoystonParmar.name(), "royston-parmar");
2849 }
2850
2851 #[test]
2852 fn family_spec_kind_name_binomial_variants() {
2853 assert_eq!(FamilySpecKind::BinomialLogit.name(), "binomial-logit");
2854 assert_eq!(FamilySpecKind::BinomialProbit.name(), "binomial-probit");
2855 assert_eq!(FamilySpecKind::BinomialCLogLog.name(), "binomial-cloglog");
2856 }
2857
2858 #[test]
2859 fn family_spec_kind_pretty_name_gaussian() {
2860 assert_eq!(FamilySpecKind::GaussianIdentity.pretty_name(), "Gaussian Identity");
2861 }
2862
2863 #[test]
2864 fn family_spec_kind_pretty_name_binomial_logit() {
2865 assert_eq!(FamilySpecKind::BinomialLogit.pretty_name(), "Binomial Logit");
2866 }
2867
2868 // -----------------------------------------------------------------------
2869 // FamilySpecKind::is_binomial and companions
2870 // -----------------------------------------------------------------------
2871
2872 #[test]
2873 fn is_binomial_true_for_all_binomial_variants() {
2874 assert!(FamilySpecKind::BinomialLogit.is_binomial());
2875 assert!(FamilySpecKind::BinomialProbit.is_binomial());
2876 assert!(FamilySpecKind::BinomialCLogLog.is_binomial());
2877 }
2878
2879 #[test]
2880 fn is_binomial_false_for_non_binomial_variants() {
2881 assert!(!FamilySpecKind::GaussianIdentity.is_binomial());
2882 assert!(!FamilySpecKind::PoissonLog.is_binomial());
2883 assert!(!FamilySpecKind::GammaLog.is_binomial());
2884 assert!(!FamilySpecKind::RoystonParmar.is_binomial());
2885 assert!(!FamilySpecKind::TweedieLog { p: 1.5 }.is_binomial());
2886 assert!(!FamilySpecKind::NegativeBinomialLog { theta: 1.0 }.is_binomial());
2887 assert!(!FamilySpecKind::BetaLogit { phi: 1.0 }.is_binomial());
2888 }
2889
2890 #[test]
2891 fn is_gaussian_identity_true_only_for_gaussian() {
2892 assert!(FamilySpecKind::GaussianIdentity.is_gaussian_identity());
2893 assert!(!FamilySpecKind::PoissonLog.is_gaussian_identity());
2894 assert!(!FamilySpecKind::BinomialLogit.is_gaussian_identity());
2895 }
2896
2897 #[test]
2898 fn is_royston_parmar_true_only_for_royston_parmar() {
2899 assert!(FamilySpecKind::RoystonParmar.is_royston_parmar());
2900 assert!(!FamilySpecKind::GaussianIdentity.is_royston_parmar());
2901 assert!(!FamilySpecKind::BinomialLogit.is_royston_parmar());
2902 }
2903
2904 #[test]
2905 fn supports_firth_iff_is_binomial() {
2906 assert!(FamilySpecKind::BinomialLogit.supports_firth());
2907 assert!(FamilySpecKind::BinomialProbit.supports_firth());
2908 assert!(FamilySpecKind::BinomialCLogLog.supports_firth());
2909 assert!(!FamilySpecKind::GaussianIdentity.supports_firth());
2910 assert!(!FamilySpecKind::PoissonLog.supports_firth());
2911 assert!(!FamilySpecKind::GammaLog.supports_firth());
2912 assert!(!FamilySpecKind::RoystonParmar.supports_firth());
2913 }
2914}