Skip to main content

gam_solve/pirls/
curvature.rs

1//! Curvature primitives: the variance-function jet, observed-information
2//! Hessian weights, and the weight-family / weight-link classification used to
3//! choose between Fisher and observed curvature per family.
4
5use super::*;
6
7pub struct VarianceJet {
8    pub v: f64,
9    pub v1: f64,
10    pub v2: f64,
11    pub v3: f64,
12    pub v4: f64,
13}
14
15impl VarianceJet {
16    /// Bernoulli / binomial variance V(μ) = μ(1−μ).
17    #[inline]
18    pub fn bernoulli(mu: f64) -> Self {
19        Self::bernoulli_with_complement(mu, 1.0 - mu)
20    }
21
22    /// Bernoulli variance jet from the (μ, 1−μ) PAIR (#2273).
23    ///
24    /// `1.0 - mu` is a hard zero once `mu` rounds to exactly `1.0`, which a
25    /// bounded inverse link reaches far inside its tail — cloglog at `η ≈ 3.62`,
26    /// probit at `η ≈ 8.29` — while the true complement is still a perfectly
27    /// representable `~1e-18`. `V = μ(1−μ)` then collapses to `0` and every
28    /// observed-information quantity divides by it, so a row whose Fisher weight
29    /// is a healthy `1e-14` is refused as unrepresentable instead of evaluated.
30    /// `crate::mixture_link::inverse_link_complement_for_inverse_link` recovers
31    /// the complement from `η` without cancellation; this constructor is how it
32    /// reaches the variance.
33    ///
34    /// Only `v` changes: `v1 = 1 − 2μ` needs no complement (it is `−1` to full
35    /// precision wherever `μ` has saturated, from either expression), and
36    /// rewriting it would move existing well-conditioned fits by an ulp for no
37    /// accuracy gained.
38    #[inline]
39    pub fn bernoulli_with_complement(mu: f64, one_minus_mu: f64) -> Self {
40        Self {
41            v: mu * one_minus_mu,
42            v1: 1.0 - 2.0 * mu,
43            v2: -2.0,
44            v3: 0.0,
45            v4: 0.0,
46        }
47    }
48
49    /// Poisson variance V(μ) = μ.
50    #[inline]
51    pub fn poisson(mu: f64) -> Self {
52        Self {
53            v: mu,
54            v1: 1.0,
55            v2: 0.0,
56            v3: 0.0,
57            v4: 0.0,
58        }
59    }
60
61    /// Gamma variance V(μ) = μ².
62    #[inline]
63    pub fn gamma(mu: f64) -> Self {
64        Self {
65            v: mu * mu,
66            v1: 2.0 * mu,
67            v2: 2.0,
68            v3: 0.0,
69            v4: 0.0,
70        }
71    }
72
73    /// Tweedie variance V(μ) = μ^p.
74    #[inline]
75    pub fn tweedie(mu: f64, p: f64) -> Self {
76        Self {
77            v: mu.powf(p),
78            v1: p * mu.powf(p - 1.0),
79            v2: p * (p - 1.0) * mu.powf(p - 2.0),
80            v3: p * (p - 1.0) * (p - 2.0) * mu.powf(p - 3.0),
81            v4: p * (p - 1.0) * (p - 2.0) * (p - 3.0) * mu.powf(p - 4.0),
82        }
83    }
84
85    /// Negative-binomial variance V(μ) = μ + μ² / theta.
86    #[inline]
87    pub fn negative_binomial(mu: f64, theta: f64) -> Self {
88        let inv_theta = if valid_negbin_theta(theta) {
89            1.0 / theta
90        } else {
91            f64::NAN
92        };
93        Self {
94            v: mu + mu * mu * inv_theta,
95            v1: 1.0 + 2.0 * mu * inv_theta,
96            v2: 2.0 * inv_theta,
97            v3: 0.0,
98            v4: 0.0,
99        }
100    }
101
102    /// Gaussian (identity) variance V(μ) = 1.
103    #[inline]
104    pub fn gaussian() -> Self {
105        Self {
106            v: 1.0,
107            v1: 0.0,
108            v2: 0.0,
109            v3: 0.0,
110            v4: 0.0,
111        }
112    }
113
114    /// Binomial(n, p) variance V(p) = p(1−p), identical to Bernoulli.
115    ///
116    /// The trial count `n` enters as a prior-weight multiplier, not through
117    /// the variance function itself.
118    #[inline]
119    pub fn binomial_n(mu: f64, one_minus_mu: f64) -> Self {
120        // V(μ) = μ(1−μ), same jet as Bernoulli
121        Self::bernoulli_with_complement(mu, one_minus_mu)
122    }
123
124    /// Beta-regression variance V(μ) = μ(1−μ)/(1+φ).
125    #[inline]
126    pub fn beta(mu: f64, one_minus_mu: f64, phi: f64) -> Self {
127        let scale = 1.0 / (1.0 + phi);
128        let base = Self::bernoulli_with_complement(mu, one_minus_mu);
129        Self {
130            v: base.v * scale,
131            v1: base.v1 * scale,
132            v2: base.v2 * scale,
133            v3: 0.0,
134            v4: 0.0,
135        }
136    }
137}
138
139/// Certify and return the exact statistical `(W, dW/deta, d2W/deta2)` surface.
140/// Positive-definiteness stabilization belongs to the assembled matrix/ridge
141/// layer; changing individual row weights would change the likelihood Hessian.
142pub fn exact_hessian_surface_arrays(
143    hessian_weights: gam_linalg::matrix::SignedWeightsView<'_>,
144    c_array: &Array1<f64>,
145    d_array: &Array1<f64>,
146    eta: &Array1<f64>,
147) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>), EstimationError> {
148    let hessian_view = hessian_weights.view();
149    let n = hessian_view.len();
150    if c_array.len() != n || d_array.len() != n || eta.len() != n {
151        crate::bail_invalid_estim!(
152            "exact Hessian surface length mismatch: W={}, c={}, d={}, eta={}",
153            n,
154            c_array.len(),
155            d_array.len(),
156            eta.len()
157        );
158    }
159    for i in 0..n {
160        for (quantity, value) in [
161            ("observed Hessian weight", hessian_view[i]),
162            ("observed Hessian dW/deta", c_array[i]),
163            ("observed Hessian d2W/deta2", d_array[i]),
164        ] {
165            if !value.is_finite() {
166                return Err(EstimationError::PirlsRowGeometryUnrepresentable {
167                    row: i,
168                    quantity,
169                    eta: eta[i],
170                    value,
171                });
172            }
173        }
174    }
175    Ok((
176        hessian_view.to_owned(),
177        c_array.to_owned(),
178        d_array.to_owned(),
179    ))
180}
181
182#[inline]
183pub(crate) fn fixed_glm_dispersion(
184    likelihood: &GlmLikelihoodSpec,
185) -> Result<f64, EstimationError> {
186    use gam_problem::ResolvedLikelihoodScale as Scale;
187
188    let scale = likelihood
189        .resolved_scale()
190        .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
191    match scale {
192        // The profiled Gaussian working geometry is intentionally scale-free.
193        Scale::ProfiledGaussian | Scale::Unit | Scale::NegativeBinomial { .. } => Ok(1.0),
194        Scale::FixedGaussian { phi } | Scale::Tweedie { phi, .. } => Ok(phi.value()),
195        Scale::Gamma { .. } => scale
196            .gamma_phi()
197            .map_err(|error| EstimationError::InvalidInput(error.to_string())),
198        // Beta precision is already inside its variance/Fisher geometry; it is
199        // not an exponential-dispersion phi multiplier.
200        Scale::BetaPrecision { .. } => Ok(1.0),
201        Scale::Unspecified => Err(EstimationError::InvalidInput(
202            "family has no fixed GLM dispersion".to_string(),
203        )),
204    }
205}
206
207/// The constant dispersion factor `k` the inner IRLS working weight carries but
208/// the (post-#2126/#2131 *unscaled*) `calculate_deviance` does **not**.
209///
210/// The inner P-IRLS builds its gradient and Hessian from the working weight,
211/// which for the dispersion families is `prior · k` (Gamma) or `prior · … / φ`
212/// (Tweedie / fixed-φ Gaussian) — i.e. the Newton/LM step is computed for the
213/// penalized objective `k·D(β) + βᵀSβ`, whose argmin is the true penalized MLE
214/// (`max ℓ − ½βᵀSβ`, since the Gamma/Tweedie log-likelihood is `−½·k·D`). But
215/// `loglik_deviance` returns the *reported* deviance `D` (φ ≡ 1), so the LM
216/// gain-ratio would compare the *actual* reduction in `D + βᵀSβ` against a
217/// *predicted* reduction built for `k·D + βᵀSβ`. When `k ≠ 1` those two
218/// objectives have different minima; at a heavily-penalized ρ every step that
219/// lowers `k·D + penalty` *raises* `D + penalty`, so no step is ever accepted,
220/// the solve freezes with a non-zero (k-scaled) residual gradient, and the outer
221/// REML sees a non-finite cost for every seed (issue #2128). Scaling the
222/// gain-ratio objective's deviance by `k` realigns it with the step, the
223/// gradient certificate, and the outer objective (which already carries the same
224/// `k`; see `calculate_loglikelihood_omitting_constants_from_eta`).
225///
226///  * Gamma:  weight `prior·shape` ⇒ `k = shape` (`= 1/φ`).
227///  * Tweedie: weight `prior·μ^{2−p}/φ` ⇒ `k = 1/φ` (the μ-power is already in
228///    the deviance's η-derivative, so only the constant `1/φ` is missing from D).
229///  * Gaussian with an explicitly fixed `φ ≠ 1`: weight `prior/φ` ⇒ `k = 1/φ`.
230///  * Every other family (Poisson, Binomial, negative-binomial, Beta, profiled
231///    Gaussian): the working weight carries no constant dispersion factor absent
232///    from D, so `k = 1` and the objective is already self-consistent.
233#[inline]
234pub(crate) fn penalized_objective_deviance_scale(
235    likelihood: &GlmLikelihoodSpec,
236) -> Result<f64, EstimationError> {
237    let resolved = likelihood
238        .resolved_scale()
239        .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
240    let k = match likelihood.spec.response {
241        ResponseFamily::Gamma => resolved
242            .gamma_shape()
243            .map_err(|error| EstimationError::InvalidInput(error.to_string()))?,
244        ResponseFamily::Tweedie { .. } => {
245            let phi = resolved
246                .tweedie_phi()
247                .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
248            1.0 / phi
249        }
250        ResponseFamily::Gaussian => match resolved {
251            gam_problem::ResolvedLikelihoodScale::ProfiledGaussian => 1.0,
252            gam_problem::ResolvedLikelihoodScale::FixedGaussian { phi } => 1.0 / phi.value(),
253            _ => {
254                return Err(EstimationError::InvalidInput(
255                    "resolved Gaussian scale has the wrong family variant".to_string(),
256                ));
257            }
258        },
259        _ => 1.0,
260    };
261    if k.is_finite() && k > 0.0 {
262        Ok(k)
263    } else {
264        Err(EstimationError::InvalidInput(format!(
265            "penalized objective deviance scale is not representable: {k:?}"
266        )))
267    }
268}
269
270#[inline]
271pub fn weight_family_for_glm_likelihood(
272    likelihood: &GlmLikelihoodSpec,
273) -> Result<WeightFamily, EstimationError> {
274    let resolved = likelihood
275        .resolved_scale()
276        .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
277    match &likelihood.spec.response {
278        ResponseFamily::Gaussian => Ok(WeightFamily::Gaussian),
279        ResponseFamily::Poisson => Ok(WeightFamily::Poisson),
280        ResponseFamily::Tweedie { p } => Ok(WeightFamily::Tweedie { p: *p }),
281        ResponseFamily::NegativeBinomial { .. } => Ok(WeightFamily::NegativeBinomial {
282            theta: resolved
283                .negative_binomial_theta()
284                .map_err(|error| EstimationError::InvalidInput(error.to_string()))?,
285        }),
286        ResponseFamily::Beta { .. } => Ok(WeightFamily::Beta {
287            phi: resolved
288                .beta_precision()
289                .map_err(|error| EstimationError::InvalidInput(error.to_string()))?,
290        }),
291        ResponseFamily::Gamma => Ok(WeightFamily::Gamma),
292        ResponseFamily::Binomial => Ok(WeightFamily::Binomial),
293        ResponseFamily::RoystonParmar => Err(EstimationError::InvalidInput(
294            "Royston-Parmar is not a GLM weight family".to_string(),
295        )),
296    }
297}
298
299#[inline]
300pub(crate) fn weight_link_for_inverse_link(inverse_link: &InverseLink) -> WeightLink {
301    match inverse_link {
302        InverseLink::Standard(StandardLink::Identity) => WeightLink::Identity,
303        InverseLink::Standard(StandardLink::Log) => WeightLink::Log,
304        InverseLink::Standard(StandardLink::Logit) => WeightLink::Logit,
305        InverseLink::Standard(StandardLink::Probit)
306        | InverseLink::Standard(StandardLink::CLogLog)
307        | InverseLink::Standard(StandardLink::LogLog)
308        | InverseLink::Standard(StandardLink::Cauchit)
309        | InverseLink::LatentCLogLog(_)
310        | InverseLink::Sas(_)
311        | InverseLink::BetaLogistic(_)
312        | InverseLink::Mixture(_) => WeightLink::Other,
313    }
314}
315
316#[inline]
317pub(crate) fn supports_observed_hessian_curvature_for_likelihood(
318    likelihood: &GlmLikelihoodSpec,
319    inverse_link: &InverseLink,
320) -> bool {
321    let spec = &likelihood.spec;
322    if matches!(spec.response, ResponseFamily::NegativeBinomial { .. }) {
323        return matches!(inverse_link, InverseLink::Standard(StandardLink::Log));
324    }
325    if matches!(spec.response, ResponseFamily::Gamma) {
326        return true;
327    }
328    if !matches!(spec.response, ResponseFamily::Binomial) {
329        return false;
330    }
331    matches!(
332        spec.link,
333        InverseLink::Standard(StandardLink::Probit)
334            | InverseLink::Standard(StandardLink::CLogLog)
335            | InverseLink::Standard(StandardLink::LogLog)
336            | InverseLink::Standard(StandardLink::Cauchit)
337            | InverseLink::Sas(_)
338            | InverseLink::BetaLogistic(_)
339            | InverseLink::Mixture(_)
340    )
341}
342
343/// Compute vectorised observed-information curvature arrays (w_obs, c_obs, d_obs)
344/// for the Hessian surface at the mode.
345///
346/// This function is the primary entry point for obtaining the observed weights
347/// that flow into the outer REML/LAML Hessian H_obs = X' W_obs X + S. The
348/// observed corrections include residual-dependent terms that vanish for
349/// canonical links but are nonzero for probit, cloglog, SAS, mixture, Gamma-log,
350/// and other flexible links.
351///
352/// The output arrays are:
353/// - `hessian_weights`: W_obs per observation (exact; matrix ridge applied separately).
354/// - `hessian_c`: c_obs = dW_obs/deta per observation (for outer gradient C[v]).
355/// - `hessian_d`: d_obs = d^2W_obs/deta^2 per observation (for outer Hessian Q[v_k,v_l]).
356///
357/// See `observed_weight_noncanonical` for the per-observation formulas and
358/// response.md Section 3 for the mathematical justification of why observed
359/// (not Fisher) information is required.
360pub(crate) fn compute_observed_hessian_curvature_arrays_into(
361    likelihood: &GlmLikelihoodSpec,
362    inverse_link: &InverseLink,
363    eta: &Array1<f64>,
364    y: ArrayView1<'_, f64>,
365    fisher_weights: &Array1<f64>,
366    priorweights: ArrayView1<'_, f64>,
367    hessian_weights: &mut Array1<f64>,
368    hessian_c: &mut Array1<f64>,
369    hessian_d: &mut Array1<f64>,
370) -> Result<(), EstimationError> {
371    assert!(supports_observed_hessian_curvature_for_likelihood(
372        likelihood,
373        inverse_link
374    ));
375    let n = eta.len();
376    if hessian_weights.len() != n {
377        *hessian_weights = Array1::<f64>::zeros(n);
378    }
379    if hessian_c.len() != n {
380        *hessian_c = Array1::<f64>::zeros(n);
381    }
382    if hessian_d.len() != n {
383        *hessian_d = Array1::<f64>::zeros(n);
384    }
385
386    let weight_family = weight_family_for_glm_likelihood(likelihood)?;
387    let weight_link = weight_link_for_inverse_link(inverse_link);
388    let phi = fixed_glm_dispersion(likelihood)?;
389
390    // Compute into an indexed certificate buffer before touching caller-owned
391    // arrays.  Parallel evaluation stays O(n), while the ordered scan below
392    // deterministically reports the smallest bad row and guarantees atomic
393    // output on error.
394    let certified: Vec<Result<(f64, f64, f64), EstimationError>> = (0..n)
395        .into_par_iter()
396        .map(|i| -> Result<(f64, f64, f64), EstimationError> {
397            let eta_used = eta[i];
398            if !(priorweights[i].is_finite() && priorweights[i] >= 0.0) {
399                return Err(EstimationError::PirlsRowGeometryUnrepresentable {
400                    row: i,
401                    quantity: "prior weight",
402                    eta: eta_used,
403                    value: priorweights[i],
404                });
405            }
406            if priorweights[i] == 0.0 {
407                return Ok((0.0, 0.0, 0.0));
408            }
409            // Every jet and every variance carrier is evaluated at this exact
410            // eta.  A non-representable tail is refused below rather than
411            // projected onto a different Hessian surface.
412            let jet =
413                crate::mixture_link::inverse_link_jet_for_inverse_link(inverse_link, eta_used)?;
414            let h4 = crate::mixture_link::inverse_link_pdfthird_derivative_for_inverse_link(
415                inverse_link,
416                eta_used,
417            )?;
418            let one_minus_mu = crate::mixture_link::inverse_link_complement_for_inverse_link(
419                inverse_link,
420                eta_used,
421                jet.mu,
422            );
423            let (w_obs, c_obs, d_obs) = observed_weight_dispatch(
424                weight_family,
425                weight_link,
426                eta_used,
427                y[i],
428                jet.mu,
429                one_minus_mu,
430                phi,
431                priorweights[i],
432                jet,
433                h4,
434            );
435            // A *finite* but non-positive observed weight is NOT a failure: the
436            // observed information `W_obs = W_Fisher - (y-μ)·B` legitimately goes
437            // indefinite on individual rows for a non-canonical link (probit,
438            // cloglog, SAS, and — critically for #1598 — a blended/mixture link)
439            // whenever a residual flips the correction's sign.  Signed row
440            // weights are assembled exactly; the matrix-level ridge handles a
441            // non-PD aggregate without modifying these statistical carriers.
442            if !w_obs.is_finite() {
443                return Err(EstimationError::PirlsRowGeometryUnrepresentable {
444                    row: i,
445                    quantity: "observed Hessian weight",
446                    eta: eta_used,
447                    value: w_obs,
448                });
449            }
450            if !c_obs.is_finite() {
451                return Err(EstimationError::PirlsRowGeometryUnrepresentable {
452                    row: i,
453                    quantity: "observed Hessian dW/deta",
454                    eta: eta_used,
455                    value: c_obs,
456                });
457            }
458            if !d_obs.is_finite() {
459                return Err(EstimationError::PirlsRowGeometryUnrepresentable {
460                    row: i,
461                    quantity: "observed Hessian d2W/deta2",
462                    eta: eta_used,
463                    value: d_obs,
464                });
465            }
466            Ok((w_obs, c_obs, d_obs))
467        })
468        .collect();
469    let certified: Vec<(f64, f64, f64)> = certified.into_iter().collect::<Result<_, _>>()?;
470    for (i, &(w, c, d)) in certified.iter().enumerate() {
471        hessian_weights[i] = w;
472        hessian_c[i] = c;
473        hessian_d[i] = d;
474    }
475    // The caller supplies Fisher weights for the observed-vs-Fisher contract;
476    // certify that this parallel surface has the same row cardinality.
477    if fisher_weights.len() != n {
478        crate::bail_invalid_estim!(
479            "observed Hessian Fisher-weight length mismatch: expected {n}, got {}",
480            fisher_weights.len()
481        );
482    }
483    Ok(())
484}
485
486pub(crate) fn compute_observed_hessian_curvature_arrays(
487    likelihood: &GlmLikelihoodSpec,
488    inverse_link: &InverseLink,
489    eta: &Array1<f64>,
490    y: ArrayView1<'_, f64>,
491    fisher_weights: &Array1<f64>,
492    priorweights: ArrayView1<'_, f64>,
493) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>), EstimationError> {
494    let n = eta.len();
495    let mut hessian_weights = Array1::<f64>::zeros(n);
496    let mut hessian_c = Array1::<f64>::zeros(n);
497    let mut hessian_d = Array1::<f64>::zeros(n);
498    compute_observed_hessian_curvature_arrays_into(
499        likelihood,
500        inverse_link,
501        eta,
502        y,
503        fisher_weights,
504        priorweights,
505        &mut hessian_weights,
506        &mut hessian_c,
507        &mut hessian_d,
508    )?;
509    Ok((hessian_weights, hessian_c, hessian_d))
510}
511
512/// Per-observation observed-information weights and their first two
513/// eta-derivatives for a general exponential-dispersion family with a
514/// noncanonical link.
515///
516/// The observed weight differs from the Fisher (expected) weight by a
517/// residual-dependent correction (see response.md Section 3):
518///
519///   W_obs = W_Fisher - (y - mu) * B
520///   B = (h'' V - h'^2 V') / (phi V^2)
521///
522///   c_obs = c_Fisher + h' * B - (y - mu) * B_eta
523///   d_obs = d_Fisher + h'' * B + 2*h' * B_eta - (y - mu) * B_etaeta
524///
525/// For canonical links (for example logit-Binomial and log-Poisson), B = 0
526/// so observed = Fisher and no correction is needed.
527///
528/// These observed quantities are required for:
529/// 1. The outer REML/LAML Hessian H_obs = X' W_obs X + S (log|H| term).
530/// 2. The outer gradient's C\[v\] correction (uses c_obs).
531/// 3. The outer Hessian's Q[v_k, v_l] correction (uses d_obs).
532///
533/// Using Fisher weights in the outer REML would yield a PQL-type surrogate
534/// rather than the exact Laplace approximation.
535///
536/// # Arguments
537/// * `resid` -- `y - mu`, formed by the caller so a saturated Bernoulli row can
538///   supply the link's tail complement instead of a cancelled zero (#2273)
539/// * `h1`...`h4` -- inverse-link derivatives h'(eta) ... h''''(eta)
540/// * `vj`  -- variance-function jet (V, V', V'', V''') evaluated at mu
541/// * `phi` -- dispersion parameter (1.0 for Bernoulli/Poisson)
542/// * `pw`  -- prior weight for this observation
543///
544/// # Returns
545/// `(w_obs, c_obs, d_obs)` -- the observed weight and its first two
546/// eta-derivatives, all pre-multiplied by `pw`.
547#[inline]
548pub fn observed_weight_noncanonical(
549    resid: f64,
550    h1: f64,
551    h2: f64,
552    h3: f64,
553    h4: f64,
554    vj: VarianceJet,
555    phi: f64,
556    pw: f64,
557) -> (f64, f64, f64) {
558    // The whole tower is `T = h₁/(φV)` and its η-derivatives: `B = T₁`,
559    // `B_η = T₂`, `B_ηη = T₃`, and `W_F = h₁·T₀` with its derivatives by the
560    // product rule. `weight_ratio_tower` builds `T` by Leibniz on `T·Q = h₁`,
561    // dividing by `Q = φV` once per order.
562    //
563    // #2273 — that is the whole point. The closed forms this replaced carried
564    // `φV²`, `φV³` and `φV⁴` denominators, and for a saturating Bernoulli row
565    // those UNDERFLOW while every quantity that matters stays representable: at
566    // `η = 5.26` a cloglog row has `V = 3.6e-84`, so `V⁴ = 1.7e-334` is zero in
567    // f64 and `B_ηη` came out NaN, refusing the row as "not representable" —
568    // when the correct `B_ηη` is a perfectly ordinary number. The tower never
569    // forms a power of `V`, so the same row evaluates. It is also the identical
570    // derivation `e_obs_from_jets` uses one order higher, so the third and
571    // fourth derivatives of one object are now built by one recurrence instead
572    // of two independently-maintained algebraic expansions.
573    //
574    // `h5` is not needed for orders <= 3, so `T₄` is not read.
575    let t = weight_ratio_tower(h1, h2, h3, h4, 0.0, vj, phi);
576    let (t0, t1, t2, t3) = (t[0], t[1], t[2], t[3]);
577
578    // W_F = h₁·T and its derivatives by the product rule.
579    let w_f = h1 * t0;
580    let c_f = h2 * t0 + h1 * t1;
581    let d_f = h3 * t0 + 2.0 * h2 * t1 + h1 * t2;
582
583    // W_obs = W_F − (y−μ)·T₁, differentiated twice with `(y−μ)' = −h₁`.
584    //
585    // `resid = y − μ` is supplied rather than formed here (#2273): for a
586    // saturated Bernoulli row `y − μ` cancels to exactly zero while the true
587    // residual is the link's representable tail complement, and only the caller
588    // knows the link.
589    let w_obs = w_f - resid * t1;
590    let c_obs = c_f + h1 * t1 - resid * t2;
591    let d_obs = d_f + h2 * t1 + 2.0 * h1 * t2 - resid * t3;
592
593    (pw * w_obs, pw * c_obs, pw * d_obs)
594}
595
596/// `T = h₁/(φV)` and its first four η-derivatives, by Leibniz on `T·Q = h₁`
597/// with `Q = φ·V(μ(η))`.
598///
599/// One recurrence, dividing by `Q` once per order and never forming a power of
600/// `V`. Every noncanonical observed-information quantity is a polynomial in this
601/// tower and the inverse-link jet, so this is the single place the algebra
602/// lives.
603///
604/// `T₄` requires `h5`; callers that only need orders up to 3 may pass any value
605/// for it and ignore the last entry.
606#[inline]
607pub fn weight_ratio_tower(
608    h1: f64,
609    h2: f64,
610    h3: f64,
611    h4: f64,
612    h5: f64,
613    vj: VarianceJet,
614    phi: f64,
615) -> [f64; 5] {
616    let VarianceJet { v, v1, v2, v3, v4 } = vj;
617    let q = phi * v;
618    let h1_sq = h1 * h1;
619    let h1_cu = h1_sq * h1;
620    let h1_qu = h1_sq * h1_sq;
621
622    // Q = phi*V and its eta-derivatives (chain rule d/deta = h1 * d/dmu on V):
623    //   Q'    = phi V1 h1
624    //   Q''   = phi (V1 h2 + V2 h1^2)
625    //   Q'''  = phi (V1 h3 + 3 V2 h1 h2 + V3 h1^3)
626    //   Q'''' = phi (V1 h4 + 4 V2 h1 h3 + 3 V2 h2^2 + 6 V3 h1^2 h2 + V4 h1^4)
627    let q1 = phi * v1 * h1;
628    let q2 = phi * (v1 * h2 + v2 * h1_sq);
629    let q3 = phi * (v1 * h3 + 3.0 * v2 * h1 * h2 + v3 * h1_cu);
630    let q4 = phi
631        * (v1 * h4 + 4.0 * v2 * h1 * h3 + 3.0 * v2 * h2 * h2 + 6.0 * v3 * h1_sq * h2 + v4 * h1_qu);
632
633    //   T'    = (h2 - T Q')/Q
634    //   T''   = (h3 - 2 T' Q' - T Q'')/Q
635    //   T'''  = (h4 - 3 T'' Q' - 3 T' Q'' - T Q''')/Q
636    //   T'''' = (h5 - 4 T''' Q' - 6 T'' Q'' - 4 T' Q''' - T Q'''')/Q
637    let t0 = h1 / q;
638    let t1 = (h2 - t0 * q1) / q;
639    let t2 = (h3 - 2.0 * t1 * q1 - t0 * q2) / q;
640    let t3 = (h4 - 3.0 * t2 * q1 - 3.0 * t1 * q2 - t0 * q3) / q;
641    let t4 = (h5 - 4.0 * t3 * q1 - 6.0 * t2 * q2 - 4.0 * t1 * q3 - t0 * q4) / q;
642    [t0, t1, t2, t3, t4]
643}
644
645/// Per-observation third η-derivative of the observed-information weight,
646/// `e_obs := ∂³W_obs/∂η³`, for a general exponential-dispersion family with
647/// any (canonical or non-canonical) link.
648///
649/// Closed-form derivation:
650///   Define `T(η) := h₁(η)/(φ V(μ(η)))`. Then
651///   * Fisher weight `W_F = h₁ · T`
652///   * Observed correction `B = T'`, so `B_η = T''`, `B_ηη = T'''`,
653///     `B_ηηη = T''''`
654///   * `W_obs = W_F − (y−μ) · T'`
655///
656/// Differentiating three times:
657///   `∂³W_obs/∂η³ = W_F''' + h₃·T' + 3 h₂·T'' + 3 h₁·T''' − (y−μ)·T''''`
658///
659/// `T` is computed via Leibniz on `T·Q = h₁` with `Q = φV`; `W_F` via
660/// Leibniz on `W_F·1 = h₁·T` (product rule).
661///
662/// All inverse-link derivatives `h₁..h₅` and variance-function derivatives
663/// `V..V₄` are required as inputs. Caller supplies them.
664///
665/// Returns `pw * e_obs` (pre-multiplied by the prior weight) so the result
666/// scales identically to `(w_obs, c_obs, d_obs)` from
667/// `observed_weight_noncanonical`.
668#[inline]
669pub fn e_obs_from_jets(
670    resid: f64,
671    h1: f64,
672    h2: f64,
673    h3: f64,
674    h4: f64,
675    h5: f64,
676    vj: VarianceJet,
677    phi: f64,
678    pw: f64,
679) -> f64 {
680    // One recurrence, shared with `observed_weight_noncanonical` (#2273): the
681    // two used to expand the same `T`-tower independently, and the lower-order
682    // one expanded it into `φV²/φV³/φV⁴` closed forms that underflow on a
683    // saturated Bernoulli row while this one does not.
684    let t = weight_ratio_tower(h1, h2, h3, h4, h5, vj, phi);
685    let (t0, t1, t2, t3, t4) = (t[0], t[1], t[2], t[3], t[4]);
686
687    // Fisher weight derivatives via product rule on W_F = h₁·T.
688    //   W_F^(0) = h₁ T
689    //   W_F^(1) = h₁ T₁ + h₂ T
690    //   W_F^(2) = h₁ T₂ + 2 h₂ T₁ + h₃ T
691    //   W_F^(3) = h₁ T₃ + 3 h₂ T₂ + 3 h₃ T₁ + h₄ T
692    let w_f3 = h1 * t3 + 3.0 * h2 * t2 + 3.0 * h3 * t1 + h4 * t0;
693
694    // Observed third derivative: differentiate W_obs = W_F − (y−μ)·T₁ thrice.
695    // (resid)' = −h₁, so iterating product rule yields
696    //   ∂³((y−μ)·T₁)/∂η³ = −h₃·T₁ − 3 h₂·T₂ − 3 h₁·T₃ + (y−μ)·T₄
697    let e_obs = w_f3 + h3 * t1 + 3.0 * h2 * t2 + 3.0 * h1 * t3 - resid * t4;
698
699    pw * e_obs
700}
701
702// Direct (closed-form) observed-information weights for specific family-link
703// combinations.  These avoid the overhead of the generic noncanonical formula
704// when the algebra simplifies.
705
706/// Gaussian family with log link: y ~ N(μ, φ), μ = exp(η).
707///
708/// Returns `(w_obs, c_obs, d_obs)` pre-multiplied by the prior weight `pw`.
709///
710/// ```text
711/// w_obs = ω μ(2μ − y) / φ
712/// c_obs = ω μ(4μ − y) / φ
713/// d_obs = ω μ(8μ − y) / φ
714/// ```
715#[inline]
716pub fn observed_weight_gaussian_log(y: f64, mu: f64, phi: f64, pw: f64) -> (f64, f64, f64) {
717    let inv_phi = pw / phi;
718    let w = inv_phi * mu * (2.0 * mu - y);
719    let c = inv_phi * mu * (4.0 * mu - y);
720    let d = inv_phi * mu * (8.0 * mu - y);
721    (w, c, d)
722}
723
724/// Gaussian family with inverse link: y ~ N(μ, φ), μ = 1/η.
725///
726/// Returns `(w_obs, c_obs, d_obs)` pre-multiplied by the prior weight `pw`.
727///
728/// ```text
729/// w_obs = ω (3 − 2ηy) / (φ η⁴)
730/// c_obs = 6ω (ηy − 2) / (φ η⁵)
731/// d_obs = 12ω (5 − 2ηy) / (φ η⁶)
732/// ```
733#[inline]
734pub fn observed_weight_gaussian_inverse(y: f64, eta: f64, phi: f64, pw: f64) -> (f64, f64, f64) {
735    let eta2 = eta * eta;
736    let eta4 = eta2 * eta2;
737    let eta5 = eta4 * eta;
738    let eta6 = eta4 * eta2;
739    let ey = eta * y;
740    let inv_phi = pw / phi;
741    let w = inv_phi * (3.0 - 2.0 * ey) / eta4;
742    let c = inv_phi * 6.0 * (ey - 2.0) / eta5;
743    let d = inv_phi * 12.0 * (5.0 - 2.0 * ey) / eta6;
744    (w, c, d)
745}
746
747/// Gamma family with log link: `V(μ)=μ²`, `μ=exp(η)`.
748///
749/// For a Gamma exponential-dispersion model the negative log-likelihood
750/// second derivative with respect to `η` is exactly `y / (φ μ)`.  The generic
751/// observed-information formula computes the same value as
752/// `W_Fisher - (y - μ)·B`; with `V=μ²` and log-link derivatives this subtracts
753/// two `1/φ`-scale terms to leave the small positive `y/(φμ)` tail, and its
754/// intermediate `V²`/`V³` products overflow for large trial `η`.  This
755/// closed form is algebraically identical but cancellation- and overflow-free.
756///
757/// ```text
758/// w_obs =  ω y / (φ μ)
759/// c_obs = -ω y / (φ μ)
760/// d_obs =  ω y / (φ μ)
761/// ```
762#[inline]
763pub fn observed_weight_gamma_log(y: f64, mu: f64, phi: f64, pw: f64) -> (f64, f64, f64) {
764    let w = (pw / phi) * (y / mu);
765    (w, -w, w)
766}
767
768/// NB2 observed information under the log link, evaluated through bounded
769/// ratios.  With `r = theta/(theta+mu)` and `s = 1-r`,
770/// `W_obs = prior (y+theta) r s`, `W' = W(r-s)`, and
771/// `W'' = W((r-s)^2 - 2rs)`.
772#[inline]
773pub fn observed_weight_negative_binomial_log(
774    y: f64,
775    mu: f64,
776    theta: f64,
777    prior_weight: f64,
778) -> (f64, f64, f64) {
779    let r = if theta >= mu {
780        1.0 / (1.0 + mu / theta)
781    } else {
782        let theta_over_mu = theta / mu;
783        theta_over_mu / (1.0 + theta_over_mu)
784    };
785    let s = 1.0 - r;
786    let w = prior_weight * (y + theta) * r * s;
787    let c = w * (r - s);
788    let d = w * ((r - s) * (r - s) - 2.0 * r * s);
789    (w, c, d)
790}
791
792#[inline]
793pub(crate) fn observed_weight_binomial_logit_from_jet(
794    n_trials: f64,
795    jet: MixtureInverseLinkJet,
796    pw: f64,
797) -> (f64, f64, f64) {
798    let scale = pw * n_trials;
799    (scale * jet.d1, scale * jet.d2, scale * jet.d3)
800}
801
802/// Family tag for the observed-information weight dispatch.
803///
804/// This is a simplified family tag that identifies the variance function,
805/// independent of the link function. It is used by [`observed_weight_dispatch`]
806/// to select closed-form weight specializations.
807#[derive(Debug, Clone, Copy, PartialEq)]
808pub enum WeightFamily {
809    Gaussian,
810    Binomial,
811    Poisson,
812    Tweedie { p: f64 },
813    NegativeBinomial { theta: f64 },
814    Beta { phi: f64 },
815    Gamma,
816}
817
818/// Link tag for the observed-information weight dispatch.
819///
820/// Identifies the link function for selecting closed-form weight
821/// specializations in [`observed_weight_dispatch`].
822#[derive(Debug, Clone, Copy, PartialEq, Eq)]
823pub enum WeightLink {
824    Identity,
825    Log,
826    Logit,
827    Inverse,
828    /// Any other link — falls back to the generic noncanonical formula.
829    Other,
830}
831
832/// `one_minus_mu` is the cancellation-free complement of `mu` (#2273). It is a
833/// required argument rather than a derived one because only the caller knows the
834/// inverse link, and only the link can produce `1 − μ` without cancellation once
835/// `μ` has saturated to exactly `1.0`; the families whose variance does not
836/// involve the complement ignore it.
837#[inline]
838pub fn variance_jet_for_weight_family(
839    family: WeightFamily,
840    mu: f64,
841    one_minus_mu: f64,
842) -> VarianceJet {
843    match family {
844        WeightFamily::Gaussian => VarianceJet::gaussian(),
845        WeightFamily::Binomial => VarianceJet::binomial_n(mu, one_minus_mu),
846        WeightFamily::Poisson => VarianceJet::poisson(mu),
847        WeightFamily::Tweedie { p } => VarianceJet::tweedie(mu, p),
848        WeightFamily::NegativeBinomial { theta } => VarianceJet::negative_binomial(mu, theta),
849        WeightFamily::Beta { phi } => VarianceJet::beta(mu, one_minus_mu, phi),
850        WeightFamily::Gamma => VarianceJet::gamma(mu),
851    }
852}
853
854/// Dispatch to closed-form observed-information weights for known family-link
855/// combinations, falling back to the generic noncanonical formula.
856///
857/// Returns `(w_obs, c_obs, d_obs)` pre-multiplied by the prior weight.
858///
859/// For the `Binomial + Logit` case, `n_trials` is passed as `phi` (dispersion
860/// slot is unused for binomial) and the prior weight controls the
861/// observation-level scaling. For all other cases, `phi` is the dispersion
862/// parameter.
863///
864/// `jet` and `h4` are the inverse-link derivatives used by the generic
865/// noncanonical fallback path. They may be zero for the specialized paths.
866/// `y − μ` formed from the (μ, 1−μ) pair for a two-point response (#2273).
867///
868/// For a saturated Bernoulli row `y − μ` cancels to exactly `0` at `y = 1`,
869/// silently degrading the observed information back to Fisher on precisely the
870/// rows where the two differ most. The pair carries the residual exactly:
871/// `y = 1 ⇒ y − μ = 1 − μ`, `y = 0 ⇒ y − μ = −μ`. Grouped-binomial proportions
872/// and every other family fall through to the ordinary difference, which does
873/// not cancel for them.
874#[inline]
875pub fn bernoulli_pair_residual(family: WeightFamily, y: f64, mu: f64, one_minus_mu: f64) -> f64 {
876    if matches!(family, WeightFamily::Binomial) {
877        if y == 1.0 {
878            return one_minus_mu;
879        }
880        if y == 0.0 {
881            return -mu;
882        }
883    }
884    y - mu
885}
886
887pub fn observed_weight_dispatch(
888    family: WeightFamily,
889    link: WeightLink,
890    eta: f64,
891    y: f64,
892    mu: f64,
893    one_minus_mu: f64,
894    phi: f64,
895    prior_weight: f64,
896    jet: MixtureInverseLinkJet,
897    h4: f64,
898) -> (f64, f64, f64) {
899    match (family, link) {
900        (WeightFamily::Gaussian, WeightLink::Log) => {
901            observed_weight_gaussian_log(y, mu, phi, prior_weight)
902        }
903        (WeightFamily::Gaussian, WeightLink::Inverse) => {
904            observed_weight_gaussian_inverse(y, eta, phi, prior_weight)
905        }
906        (WeightFamily::Gamma, WeightLink::Log) => {
907            observed_weight_gamma_log(y, mu, phi, prior_weight)
908        }
909        (WeightFamily::NegativeBinomial { theta }, WeightLink::Log) => {
910            observed_weight_negative_binomial_log(y, mu, theta, prior_weight)
911        }
912        (WeightFamily::Binomial, WeightLink::Logit) => {
913            observed_weight_binomial_logit_from_jet(1.0, jet, prior_weight)
914        }
915        _ => {
916            // Generic noncanonical path via the full variance-function jet.
917            let vj = variance_jet_for_weight_family(family, mu, one_minus_mu);
918            let resid = bernoulli_pair_residual(family, y, mu, one_minus_mu);
919            observed_weight_noncanonical(
920                resid,
921                jet.d1,
922                jet.d2,
923                jet.d3,
924                h4,
925                vj,
926                phi,
927                prior_weight,
928            )
929        }
930    }
931}
932
933#[derive(Clone)]
934pub enum DirectionalWorkingCurvature {
935    /// Directional derivative of the PIRLS curvature when the working
936    /// curvature is diagonal in observation space:
937    ///   W_τ = diag(w_τ).
938    Diagonal(Array1<f64>),
939}
940
941pub fn directionalworking_curvature_from_c_array(
942    c_array: &Array1<f64>,
943    eta_direction: &Array1<f64>,
944) -> DirectionalWorkingCurvature {
945    DirectionalWorkingCurvature::Diagonal(c_array * eta_direction)
946}