Skip to main content

gam_models/gamlss/gaussian/
log_link.rs

1// Real concern-organized submodule of the gamlss family stack.
2// Cross-module items are re-exported flat through the parent (`gamlss.rs`),
3// so `use super::*;` makes the sibling-concern symbols this module references
4// resolve through the parent namespace.
5use super::*;
6
7pub struct PoissonLogFamily {
8    pub y: Array1<f64>,
9    pub weights: Array1<f64>,
10}
11
12impl PoissonLogFamily {
13    pub const BLOCK_ETA: usize = 0;
14
15    pub fn parameternames() -> &'static [&'static str] {
16        &["eta"]
17    }
18
19    pub fn parameter_links() -> &'static [ParameterLink] {
20        &[ParameterLink::Log]
21    }
22
23    pub fn metadata() -> FamilyMetadata {
24        FamilyMetadata {
25            name: "poisson_log",
26            parameternames: Self::parameternames(),
27            parameter_links: Self::parameter_links(),
28        }
29    }
30}
31
32/// Certified per-row IRLS contribution for a single-parameter log-link family.
33/// Every field is the exact `f64` evaluation of the declared likelihood at the
34/// supplied predictor; there is no alternate clamped objective or weight floor.
35pub(crate) struct DiagonalIrlsRow {
36    /// Weighted contribution to ℓ at this row.
37    pub(crate) log_lik_increment: f64,
38    /// Exact observed Hessian weight.
39    pub(crate) observed_weight: f64,
40    /// Exact representable working response.
41    pub(crate) working_response: f64,
42}
43
44/// Trait implemented by single-block log-link families that share the
45/// diagonal IRLS structure (Poisson, Gamma). Each impl is responsible only
46/// for the family-specific math: validating `y[i]` and producing the
47/// per-row triple `(ℓ_increment, observed_weight, working_step)`.
48trait LogLinkDiagonalIrlsFamily {
49    /// Short, human-readable name used in size-mismatch errors.
50    fn family_label(&self) -> &'static str;
51
52    /// Read access to the shared (y, prior weights) buffers.
53    fn y(&self) -> &Array1<f64>;
54    fn prior_weights(&self) -> &Array1<f64>;
55
56    /// Optional pre-loop validation hook for parameters outside the
57    /// (y, weights, eta) triple (e.g. Gamma shape > 0).
58    fn validate_self(&self) -> Result<(), String> {
59        Ok(())
60    }
61
62    /// Validate `y[i]` and return an error message if rejected. Default
63    /// implementation enforces only finiteness; concrete families override
64    /// to add domain constraints.
65    fn validate_yi(&self, yi: f64, idx: usize) -> Result<(), String>;
66
67    /// Family-specific row math.  A positive-weight row must either return a
68    /// fully representable likelihood/score/curvature triple or refuse it.
69    fn row_kernel(
70        &self,
71        row: usize,
72        yi: f64,
73        eta: f64,
74        prior_w: f64,
75    ) -> Result<DiagonalIrlsRow, String>;
76}
77
78/// Shared IRLS driver for [`LogLinkDiagonalIrlsFamily`]. Centralises the
79/// validation, exact row-domain certification, and assembly.  Rows are first
80/// certified into a temporary buffer; no working array is mutated until every
81/// row has succeeded, so the smallest invalid row is reported deterministically.
82fn evaluate_log_link_diagonal_irls<F: LogLinkDiagonalIrlsFamily + ?Sized>(
83    family: &F,
84    block_states: &[ParameterBlockState],
85) -> Result<FamilyEvaluation, String> {
86    let label = family.family_label();
87    let eta = &expect_single_block(block_states, label)?.eta;
88    let y = family.y();
89    let prior_weights = family.prior_weights();
90    let n = y.len();
91    if eta.len() != n || prior_weights.len() != n {
92        return Err(GamlssError::DimensionMismatch {
93            reason: format!("{label} input size mismatch"),
94        }
95        .into());
96    }
97    family.validate_self()?;
98
99    let mut rows = Vec::with_capacity(n);
100    for i in 0..n {
101        let yi = y[i];
102        family.validate_yi(yi, i)?;
103        let e = eta[i];
104        if !e.is_finite() {
105            return Err(GamlssError::NonFinite {
106                reason: format!("{label} requires finite eta; found eta[{i}]={e}"),
107            }
108            .into());
109        }
110        let prior_w = prior_weights[i];
111        if !prior_w.is_finite() || prior_w < 0.0 {
112            return Err(GamlssError::InvalidInput {
113                reason: format!(
114                    "{label} requires finite non-negative prior weights; found weight[{i}]={prior_w}"
115                ),
116            }
117            .into());
118        }
119        rows.push(family.row_kernel(i, yi, e, prior_w)?);
120    }
121
122    let mut ll = 0.0;
123    for (i, row) in rows.iter().enumerate() {
124        ll += row.log_lik_increment;
125        if !ll.is_finite() {
126            return Err(GamlssError::RowGeometryUnrepresentable {
127                row: i,
128                quantity: "cumulative log likelihood",
129                eta: eta[i],
130                value: ll,
131            }
132            .into());
133        }
134    }
135    let z = Array1::from_iter(rows.iter().map(|row| row.working_response));
136    let w = Array1::from_iter(rows.iter().map(|row| row.observed_weight));
137
138    Ok(FamilyEvaluation {
139        log_likelihood: ll,
140        blockworking_sets: vec![BlockWorkingSet::diagonal_checked(z, w)?],
141    })
142}
143
144impl LogLinkDiagonalIrlsFamily for PoissonLogFamily {
145    fn family_label(&self) -> &'static str {
146        "PoissonLogFamily"
147    }
148    fn y(&self) -> &Array1<f64> {
149        &self.y
150    }
151    fn prior_weights(&self) -> &Array1<f64> {
152        &self.weights
153    }
154    fn validate_yi(&self, yi: f64, idx: usize) -> Result<(), String> {
155        if !yi.is_finite() || yi < 0.0 {
156            return Err(GamlssError::InvalidInput {
157                reason: format!(
158                    "PoissonLogFamily requires non-negative finite y; found y[{idx}]={yi}"
159                ),
160            }
161            .into());
162        }
163        Ok::<(), _>(())
164    }
165    #[inline]
166    fn row_kernel(
167        &self,
168        row: usize,
169        yi: f64,
170        eta: f64,
171        prior_w: f64,
172    ) -> Result<DiagonalIrlsRow, String> {
173        if prior_w == 0.0 {
174            return Ok(DiagonalIrlsRow {
175                log_lik_increment: 0.0,
176                observed_weight: 0.0,
177                working_response: eta,
178            });
179        }
180        let m = eta.exp();
181        if !m.is_finite() || m <= 0.0 {
182            return Err(row_geometry_error(row, "Poisson mean exp(eta)", eta, m));
183        }
184        let observed_weight = scaled_positive_product_quotient(prior_w, 1.0, m, 1.0);
185        if !observed_weight.is_finite() || observed_weight <= 0.0 {
186            return Err(row_geometry_error(
187                row,
188                "Poisson observed information",
189                eta,
190                observed_weight,
191            ));
192        }
193        // Drop log(y!) constant. Form the weighted `y*eta` term with its
194        // exponent carried separately: `y*eta` may overflow even though the
195        // final prior-weighted contribution is representable.
196        let weighted_y_eta = if yi == 0.0 || eta == 0.0 {
197            0.0
198        } else {
199            scaled_positive_product_quotient(prior_w, yi, eta.abs(), 1.0).copysign(eta)
200        };
201        let log_lik_increment = weighted_y_eta - observed_weight;
202        if !log_lik_increment.is_finite() {
203            return Err(row_geometry_error(
204                row,
205                "Poisson log-likelihood contribution",
206                eta,
207                log_lik_increment,
208            ));
209        }
210        let working_response = eta + (yi / m - 1.0);
211        if !working_response.is_finite() {
212            return Err(row_geometry_error(
213                row,
214                "Poisson working response",
215                eta,
216                working_response,
217            ));
218        }
219        Ok(DiagonalIrlsRow {
220            log_lik_increment,
221            observed_weight,
222            working_response,
223        })
224    }
225}
226
227impl CustomFamily for PoissonLogFamily {
228    // Preserve the pre-gam#1395 behavior: the trait default flipped to OFF (the
229    // flat-prior exact-Newton objective carries no Jeffreys term), so families
230    // that historically armed the term by default opt back in explicitly.
231    fn joint_jeffreys_term_required(&self) -> bool {
232        true
233    }
234
235    fn evaluate(&self, block_states: &[ParameterBlockState]) -> Result<FamilyEvaluation, String> {
236        evaluate_log_link_diagonal_irls(self, block_states)
237    }
238
239    fn exact_newton_joint_gradient_evaluation(
240        &self,
241        block_states: &[ParameterBlockState],
242        specs: &[ParameterBlockSpec],
243    ) -> Result<Option<ExactNewtonJointGradientEvaluation>, String> {
244        // Assemble the exact score from the IRLS working set (X_bᵀ(w⊙(z−η))),
245        // the same source of truth the inner joint-Newton RHS uses. For the
246        // canonical Poisson log link observed = Fisher, so this is the exact
247        // observed gradient (matches FD of the log-likelihood).
248        let eval = self.evaluate(block_states)?;
249        gamlss_joint_gradient_from_working_sets(&eval, specs, block_states).map(Some)
250    }
251}
252
253impl CustomFamilyGenerative for PoissonLogFamily {
254    fn generativespec(
255        &self,
256        block_states: &[ParameterBlockState],
257    ) -> Result<GenerativeSpec, String> {
258        let eta = &expect_single_block(block_states, "PoissonLogFamily")?.eta;
259        // Prediction follows the public log inverse link over IEEE-754: finite
260        // predictors may legitimately map to zero or +infinity.  Fitting has a
261        // narrower certified geometry because its divisions and Hessian must be
262        // representable; prediction must not inherit that fitting restriction.
263        let mean = gamlss_rowwise_map(eta.len(), |i| eta[i].exp());
264        Ok(GenerativeSpec {
265            mean,
266            noise: NoiseModel::Poisson,
267        })
268    }
269}
270
271/// Built-in Gamma log-link family (single parameter block, fixed shape).
272#[derive(Clone)]
273pub struct GammaLogFamily {
274    pub y: Array1<f64>,
275    pub weights: Array1<f64>,
276    pub shape: f64,
277}
278
279impl GammaLogFamily {
280    pub const BLOCK_ETA: usize = 0;
281
282    pub fn parameternames() -> &'static [&'static str] {
283        &["eta"]
284    }
285
286    pub fn parameter_links() -> &'static [ParameterLink] {
287        &[ParameterLink::Log]
288    }
289
290    pub fn metadata() -> FamilyMetadata {
291        FamilyMetadata {
292            name: "gamma_log",
293            parameternames: Self::parameternames(),
294            parameter_links: Self::parameter_links(),
295        }
296    }
297}
298
299impl LogLinkDiagonalIrlsFamily for GammaLogFamily {
300    fn family_label(&self) -> &'static str {
301        "GammaLogFamily"
302    }
303    fn y(&self) -> &Array1<f64> {
304        &self.y
305    }
306    fn prior_weights(&self) -> &Array1<f64> {
307        &self.weights
308    }
309    fn validate_self(&self) -> Result<(), String> {
310        if !self.shape.is_finite() || self.shape <= 0.0 {
311            return Err(GamlssError::NonFinite {
312                reason: "GammaLogFamily shape must be finite and > 0".to_string(),
313            }
314            .into());
315        }
316        Ok(())
317    }
318    fn validate_yi(&self, yi: f64, idx: usize) -> Result<(), String> {
319        if !yi.is_finite() || yi <= 0.0 {
320            return Err(GamlssError::InvalidInput {
321                reason: format!("GammaLogFamily requires positive finite y; found y[{idx}]={yi}"),
322            }
323            .into());
324        }
325        Ok::<(), _>(())
326    }
327    #[inline]
328    fn row_kernel(
329        &self,
330        row: usize,
331        yi: f64,
332        eta: f64,
333        prior_w: f64,
334    ) -> Result<DiagonalIrlsRow, String> {
335        if prior_w == 0.0 {
336            return Ok(DiagonalIrlsRow {
337                log_lik_increment: 0.0,
338                observed_weight: 0.0,
339                working_response: eta,
340            });
341        }
342        let m = eta.exp();
343        if !m.is_finite() || m <= 0.0 {
344            return Err(row_geometry_error(row, "Gamma mean exp(eta)", eta, m));
345        }
346        // Gamma(shape=k, scale=mu/k), dropping eta-independent constants.
347        // Form the two terms independently with exponent-balanced algebra:
348        // `prior*k*y/m` may be finite even when the intermediate `y/m`
349        // overflows, and `(prior*k)*eta` may be representable even when
350        // `prior*k` underflows before multiplication by a large |eta|.
351        let observed_weight = scaled_positive_product_quotient(prior_w, self.shape, yi, m);
352        if !observed_weight.is_finite() || observed_weight <= 0.0 {
353            return Err(row_geometry_error(
354                row,
355                "Gamma observed information",
356                eta,
357                observed_weight,
358            ));
359        }
360        let eta_term = if eta == 0.0 {
361            0.0
362        } else {
363            scaled_positive_product_quotient(prior_w, self.shape, eta.abs(), 1.0).copysign(eta)
364        };
365        let log_lik_increment = -observed_weight - eta_term;
366        if !log_lik_increment.is_finite() {
367            return Err(row_geometry_error(
368                row,
369                "Gamma log-likelihood contribution",
370                eta,
371                log_lik_increment,
372            ));
373        }
374        // Gamma with log mean is non-canonical. Use the exact observed
375        // η-space curvature -d²ℓ/dη² = prior_w * shape * y / μ, not the
376        // Fisher weight prior_w * shape, so diagonal REML/LAML Hessians
377        // use the true Laplace curvature instead of a PQL/Fisher surrogate.
378        // score / information = (y/μ - 1)/(y/μ); keep the cancellation
379        // analytic. In the y >= μ branch the equivalent `1 - μ/y` never
380        // materializes an overflowing y/μ ratio.
381        let working_step = if yi >= m {
382            1.0 - m / yi
383        } else {
384            let ratio = yi / m;
385            (ratio - 1.0) / ratio
386        };
387        let working_response = eta + working_step;
388        if !working_response.is_finite() {
389            return Err(row_geometry_error(
390                row,
391                "Gamma working response",
392                eta,
393                working_response,
394            ));
395        }
396        Ok(DiagonalIrlsRow {
397            log_lik_increment,
398            observed_weight,
399            working_response,
400        })
401    }
402}
403
404impl CustomFamily for GammaLogFamily {
405    // Preserve the pre-gam#1395 behavior: the trait default flipped to OFF (the
406    // flat-prior exact-Newton objective carries no Jeffreys term), so families
407    // that historically armed the term by default opt back in explicitly.
408    fn joint_jeffreys_term_required(&self) -> bool {
409        true
410    }
411
412    fn evaluate(&self, block_states: &[ParameterBlockState]) -> Result<FamilyEvaluation, String> {
413        evaluate_log_link_diagonal_irls(self, block_states)
414    }
415
416    fn exact_newton_joint_gradient_evaluation(
417        &self,
418        block_states: &[ParameterBlockState],
419        specs: &[ParameterBlockSpec],
420    ) -> Result<Option<ExactNewtonJointGradientEvaluation>, String> {
421        // Assemble the exact score from the IRLS working set (X_bᵀ(w⊙(z−η))).
422        // The Gamma log link is non-canonical, but the working step z is defined
423        // relative to the row weight so w(z−η) is the exact observed ∂ℓ/∂η
424        // regardless — the assembled gradient matches FD of the log-likelihood.
425        let eval = self.evaluate(block_states)?;
426        gamlss_joint_gradient_from_working_sets(&eval, specs, block_states).map(Some)
427    }
428
429    fn diagonalworking_weights_directional_derivative(
430        &self,
431        block_states: &[ParameterBlockState],
432        block_idx: usize,
433        d_eta: &Array1<f64>,
434    ) -> Result<Option<Array1<f64>>, String> {
435        if block_idx != Self::BLOCK_ETA {
436            return Ok(None);
437        }
438        let eta = &expect_single_block(block_states, "GammaLogFamily")?.eta;
439        let n = self.y.len();
440        if eta.len() != n || self.weights.len() != n || d_eta.len() != n {
441            return Err(GamlssError::DimensionMismatch {
442                reason: "GammaLogFamily input size mismatch".to_string(),
443            }
444            .into());
445        }
446        if !self.shape.is_finite() || self.shape <= 0.0 {
447            return Err(GamlssError::NonFinite {
448                reason: "GammaLogFamily shape must be finite and > 0".to_string(),
449            }
450            .into());
451        }
452
453        let mut values = Vec::with_capacity(n);
454        for i in 0..n {
455            let yi = self.y[i];
456            if !yi.is_finite() || yi <= 0.0 {
457                return Err(GamlssError::InvalidInput {
458                    reason: format!("GammaLogFamily requires positive finite y; found y[{i}]={yi}"),
459                }
460                .into());
461            }
462            let e = eta[i];
463            if !e.is_finite() || !d_eta[i].is_finite() {
464                return Err(GamlssError::NonFinite {
465                    reason: format!(
466                        "GammaLogFamily directional geometry requires finite eta and direction at row {i}"
467                    ),
468                }
469                .into());
470            }
471            let prior_w = self.weights[i];
472            if !prior_w.is_finite() || prior_w < 0.0 {
473                return Err(GamlssError::InvalidInput {
474                    reason: format!(
475                        "GammaLogFamily requires finite non-negative prior weights; found weight[{i}]={prior_w}"
476                    ),
477                }
478                .into());
479            }
480            if prior_w == 0.0 {
481                values.push(0.0);
482                continue;
483            }
484            let row = self.row_kernel(i, yi, e, prior_w)?;
485            let observed_weight = row.observed_weight;
486            // d/dη [prior_weight * shape * y / exp(η)] = -W_obs.
487            let derivative = -observed_weight * d_eta[i];
488            if !derivative.is_finite() {
489                return Err(row_geometry_error(
490                    i,
491                    "Gamma observed-information directional derivative",
492                    e,
493                    derivative,
494                ));
495            }
496            values.push(derivative);
497        }
498        Ok(Some(Array1::from_vec(values)))
499    }
500}
501
502impl CustomFamilyGenerative for GammaLogFamily {
503    fn generativespec(
504        &self,
505        block_states: &[ParameterBlockState],
506    ) -> Result<GenerativeSpec, String> {
507        let eta = &expect_single_block(block_states, "GammaLogFamily")?.eta;
508        let mean = gamlss_rowwise_map(eta.len(), |i| eta[i].exp());
509        let shape = ndarray::Array1::from_elem(mean.len(), self.shape);
510        Ok(GenerativeSpec {
511            mean,
512            noise: NoiseModel::Gamma { shape },
513        })
514    }
515}
516
517#[inline]
518fn row_geometry_error(row: usize, quantity: &'static str, eta: f64, value: f64) -> String {
519    GamlssError::RowGeometryUnrepresentable {
520        row,
521        quantity,
522        eta,
523        value,
524    }
525    .into()
526}
527
528/// Exact power-of-two decomposition `x = mantissa * 2^exponent` for a positive
529/// finite `f64`, including subnormals. The mantissa lies in `[1, 2)`.
530#[inline]
531fn positive_frexp(x: f64) -> (f64, i32) {
532    assert!(x.is_finite() && x > 0.0);
533    let bits = x.to_bits();
534    let raw_exp = ((bits >> 52) & 0x7ff) as i32;
535    let fraction = bits & ((1_u64 << 52) - 1);
536    if raw_exp != 0 {
537        let mantissa = f64::from_bits((1023_u64 << 52) | fraction);
538        (mantissa, raw_exp - 1023)
539    } else {
540        let leading = 63_i32 - fraction.leading_zeros() as i32;
541        let shift = 52_i32 - leading;
542        let normalized = fraction << shift;
543        let mantissa = f64::from_bits((1023_u64 << 52) | (normalized & ((1_u64 << 52) - 1)));
544        (mantissa, -1022 - shift)
545    }
546}
547
548#[inline]
549fn scale_normalized_power_of_two(mut mantissa: f64, mut exponent: i32) -> f64 {
550    while mantissa >= 2.0 {
551        mantissa *= 0.5;
552        exponent += 1;
553    }
554    while mantissa < 1.0 {
555        mantissa *= 2.0;
556        exponent -= 1;
557    }
558    if exponent > 1023 {
559        return f64::INFINITY;
560    }
561    if exponent >= -1022 {
562        let power = f64::from_bits(((exponent + 1023) as u64) << 52);
563        return mantissa * power;
564    }
565    if exponent < -1075 {
566        return 0.0;
567    }
568    // Scale in units of the least positive subnormal.  Keeping the small
569    // power-of-two multiplier normal until the final operation lets IEEE
570    // round the final subnormal once instead of underflowing an intermediate.
571    let units = mantissa * 2.0_f64.powi(exponent + 1074);
572    units * f64::from_bits(1)
573}
574
575/// Compute `a*b*c/d` for positive finite inputs while carrying the binary
576/// exponent separately. Overflow/underflow therefore occurs only when the
577/// final `f64` result itself is unrepresentable.
578#[inline]
579fn scaled_positive_product_quotient(a: f64, b: f64, c: f64, d: f64) -> f64 {
580    assert!(a.is_finite() && a > 0.0);
581    assert!(b.is_finite() && b > 0.0);
582    assert!(c.is_finite() && c > 0.0);
583    assert!(d.is_finite() && d > 0.0);
584    let (ma, ea) = positive_frexp(a);
585    let (mb, eb) = positive_frexp(b);
586    let (mc, ec) = positive_frexp(c);
587    let (md, ed) = positive_frexp(d);
588    scale_normalized_power_of_two((ma * mb) * (mc / md), ea + eb + ec - ed)
589}
590
591#[cfg(test)]
592mod exact_domain_tests {
593    use super::*;
594
595    #[test]
596    fn scaled_product_quotient_avoids_false_intermediate_overflow_and_underflow() {
597        let got = scaled_positive_product_quotient(1.0e-300, 1.0, 1.0e308, 1.0);
598        assert!((got - 1.0e8).abs() <= 4.0 * f64::EPSILON * 1.0e8);
599        let got = scaled_positive_product_quotient(1.0e-300, 1.0e-200, 1.0, 1.0e-300);
600        assert!((got - 1.0e-200).abs() <= 4.0 * f64::EPSILON * 1.0e-200);
601    }
602
603    #[test]
604    fn gamma_row_accepts_overflowing_raw_ratio_when_final_geometry_is_finite() {
605        let family = GammaLogFamily {
606            y: Array1::from_vec(vec![1.0e308]),
607            weights: Array1::from_vec(vec![1.0e-300]),
608            shape: 1.0,
609        };
610        let row = family
611            .row_kernel(0, 1.0e308, 0.0, 1.0e-300)
612            .expect("final Gamma geometry is representable");
613        assert!(row.log_lik_increment.is_finite());
614        assert!((row.observed_weight - 1.0e8).abs() <= 4.0 * f64::EPSILON * 1.0e8);
615        assert_eq!(row.working_response, 1.0);
616    }
617
618    #[test]
619    fn poisson_row_scales_y_eta_before_multiplication() {
620        let family = PoissonLogFamily {
621            y: Array1::from_vec(vec![1.0e308]),
622            weights: Array1::from_vec(vec![1.0e-300]),
623        };
624        let row = family
625            .row_kernel(0, 1.0e308, 2.0, 1.0e-300)
626            .expect("weighted Poisson objective is representable");
627        assert!(row.log_lik_increment.is_finite());
628        assert!(row.observed_weight.is_normal());
629    }
630}