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