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}