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/// Per-row IRLS contribution that a single-parameter log-link family must
33/// produce. The shared driver `evaluate_log_link_diagonal_irls` consumes
34/// these and assembles the full `FamilyEvaluation` so the three pieces of
35/// code that previously lived inside each family — size validation, per-row
36/// y validation + η clamping + saturated `exp`, the active-clamp w/z guard,
37/// and the final return — exist in exactly one place.
38pub(crate) struct DiagonalIrlsRow {
39    /// Weighted contribution to ℓ at this row.
40    pub(crate) log_lik_increment: f64,
41    /// Unfloored observed Hessian weight (the driver applies `MIN_WEIGHT`).
42    pub(crate) observed_weight: f64,
43    /// Per-row Newton step on the working response: `z = e + working_step`.
44    /// Each family computes this with its own (score, denominator); the
45    /// driver only handles the active-clamp / zero-weight guard.
46    pub(crate) working_step: f64,
47}
48
49/// Trait implemented by single-block log-link families that share the
50/// diagonal IRLS structure (Poisson, Gamma). Each impl is responsible only
51/// for the family-specific math: validating `y[i]` and producing the
52/// per-row triple `(ℓ_increment, observed_weight, working_step)`.
53trait LogLinkDiagonalIrlsFamily {
54    /// Short, human-readable name used in size-mismatch errors.
55    fn family_label(&self) -> &'static str;
56
57    /// Read access to the shared (y, prior weights) buffers.
58    fn y(&self) -> &Array1<f64>;
59    fn prior_weights(&self) -> &Array1<f64>;
60
61    /// Optional pre-loop validation hook for parameters outside the
62    /// (y, weights, eta) triple (e.g. Gamma shape > 0).
63    fn validate_self(&self) -> Result<(), String> {
64        Ok(())
65    }
66
67    /// Validate `y[i]` and return an error message if rejected. Default
68    /// implementation enforces only finiteness; concrete families override
69    /// to add domain constraints.
70    fn validate_yi(&self, yi: f64, idx: usize) -> Result<(), String>;
71
72    /// Family-specific per-row math; `m = saturated_exp_eta(eta_clamped)`
73    /// is computed by the driver and handed in.
74    fn row_kernel(&self, yi: f64, e_clamped: f64, m: f64, prior_w: f64) -> DiagonalIrlsRow;
75}
76
77/// Shared IRLS driver for [`LogLinkDiagonalIrlsFamily`]. Centralises the
78/// size-check, η-clamp, saturated-exp, active-clamp guard, ll accumulation,
79/// and `FamilyEvaluation` assembly so all log-link families with the diagonal
80/// structure (Poisson, Gamma) cannot drift apart numerically.
81fn evaluate_log_link_diagonal_irls<F: LogLinkDiagonalIrlsFamily + ?Sized>(
82    family: &F,
83    block_states: &[ParameterBlockState],
84) -> Result<FamilyEvaluation, String> {
85    let label = family.family_label();
86    let eta = &expect_single_block(block_states, label)?.eta;
87    let y = family.y();
88    let prior_weights = family.prior_weights();
89    let n = y.len();
90    if eta.len() != n || prior_weights.len() != n {
91        return Err(GamlssError::DimensionMismatch {
92            reason: format!("{label} input size mismatch"),
93        }
94        .into());
95    }
96    family.validate_self()?;
97
98    let mut ll = 0.0;
99    let mut z = Array1::<f64>::zeros(n);
100    let mut w = Array1::<f64>::zeros(n);
101
102    for i in 0..n {
103        let yi = y[i];
104        family.validate_yi(yi, i)?;
105        let e_raw = eta[i];
106        let e = e_raw.clamp(-ETA_HARD_CLAMP, ETA_HARD_CLAMP);
107        let active_clamp = e != e_raw;
108        let m = saturated_exp_eta(e_raw);
109        let prior_w = prior_weights[i];
110        let row = family.row_kernel(yi, e, m, prior_w);
111        ll += row.log_lik_increment;
112        if prior_w == 0.0 || active_clamp {
113            w[i] = 0.0;
114            z[i] = e_raw;
115        } else {
116            w[i] = floor_positiveweight(row.observed_weight, MIN_WEIGHT);
117            z[i] = e + row.working_step;
118        }
119    }
120
121    Ok(FamilyEvaluation {
122        log_likelihood: ll,
123        blockworking_sets: vec![BlockWorkingSet::diagonal_checked(z, w)?],
124    })
125}
126
127impl LogLinkDiagonalIrlsFamily for PoissonLogFamily {
128    fn family_label(&self) -> &'static str {
129        "PoissonLogFamily"
130    }
131    fn y(&self) -> &Array1<f64> {
132        &self.y
133    }
134    fn prior_weights(&self) -> &Array1<f64> {
135        &self.weights
136    }
137    fn validate_yi(&self, yi: f64, idx: usize) -> Result<(), String> {
138        if !yi.is_finite() || yi < 0.0 {
139            return Err(GamlssError::InvalidInput {
140                reason: format!(
141                    "PoissonLogFamily requires non-negative finite y; found y[{idx}]={yi}"
142                ),
143            }
144            .into());
145        }
146        Ok::<(), _>(())
147    }
148    #[inline]
149    fn row_kernel(&self, yi: f64, e_clamped: f64, m: f64, prior_w: f64) -> DiagonalIrlsRow {
150        // Drop log(y!) constant in objective.
151        let log_lik_increment = prior_w * (yi * e_clamped - m);
152        let dmu = m.max(MIN_DERIV);
153        let var = m.max(MIN_PROB);
154        DiagonalIrlsRow {
155            log_lik_increment,
156            observed_weight: prior_w * (dmu * dmu / var),
157            // (yi - m)/dmu, identical to the previous direct expression.
158            working_step: (yi - m) / signedwith_floor(dmu, MIN_DERIV),
159        }
160    }
161}
162
163impl CustomFamily for PoissonLogFamily {
164    // Preserve the pre-gam#1395 behavior: the trait default flipped to OFF (the
165    // flat-prior exact-Newton objective carries no Jeffreys term), so families
166    // that historically armed the term by default opt back in explicitly.
167    fn joint_jeffreys_term_required(&self) -> bool {
168        true
169    }
170
171    fn evaluate(&self, block_states: &[ParameterBlockState]) -> Result<FamilyEvaluation, String> {
172        evaluate_log_link_diagonal_irls(self, block_states)
173    }
174
175    fn exact_newton_joint_gradient_evaluation(
176        &self,
177        block_states: &[ParameterBlockState],
178        specs: &[ParameterBlockSpec],
179    ) -> Result<Option<ExactNewtonJointGradientEvaluation>, String> {
180        // Assemble the exact score from the IRLS working set (X_bᵀ(w⊙(z−η))),
181        // the same source of truth the inner joint-Newton RHS uses. For the
182        // canonical Poisson log link observed = Fisher, so this is the exact
183        // observed gradient (matches FD of the log-likelihood).
184        let eval = self.evaluate(block_states)?;
185        gamlss_joint_gradient_from_working_sets(&eval, specs, block_states).map(Some)
186    }
187}
188
189impl CustomFamilyGenerative for PoissonLogFamily {
190    fn generativespec(
191        &self,
192        block_states: &[ParameterBlockState],
193    ) -> Result<GenerativeSpec, String> {
194        let eta = &expect_single_block(block_states, "PoissonLogFamily")?.eta;
195        let mean = gamlss_rowwise_map(eta.len(), |i| saturated_exp_eta(eta[i]));
196        Ok(GenerativeSpec {
197            mean,
198            noise: NoiseModel::Poisson,
199        })
200    }
201}
202
203/// Built-in Gamma log-link family (single parameter block, fixed shape).
204#[derive(Clone)]
205pub struct GammaLogFamily {
206    pub y: Array1<f64>,
207    pub weights: Array1<f64>,
208    pub shape: f64,
209}
210
211impl GammaLogFamily {
212    pub const BLOCK_ETA: usize = 0;
213
214    pub fn parameternames() -> &'static [&'static str] {
215        &["eta"]
216    }
217
218    pub fn parameter_links() -> &'static [ParameterLink] {
219        &[ParameterLink::Log]
220    }
221
222    pub fn metadata() -> FamilyMetadata {
223        FamilyMetadata {
224            name: "gamma_log",
225            parameternames: Self::parameternames(),
226            parameter_links: Self::parameter_links(),
227        }
228    }
229}
230
231impl LogLinkDiagonalIrlsFamily for GammaLogFamily {
232    fn family_label(&self) -> &'static str {
233        "GammaLogFamily"
234    }
235    fn y(&self) -> &Array1<f64> {
236        &self.y
237    }
238    fn prior_weights(&self) -> &Array1<f64> {
239        &self.weights
240    }
241    fn validate_self(&self) -> Result<(), String> {
242        if !self.shape.is_finite() || self.shape <= 0.0 {
243            return Err(GamlssError::NonFinite {
244                reason: "GammaLogFamily shape must be finite and > 0".to_string(),
245            }
246            .into());
247        }
248        Ok(())
249    }
250    fn validate_yi(&self, yi: f64, idx: usize) -> Result<(), String> {
251        if !yi.is_finite() || yi <= 0.0 {
252            return Err(GamlssError::InvalidInput {
253                reason: format!("GammaLogFamily requires positive finite y; found y[{idx}]={yi}"),
254            }
255            .into());
256        }
257        Ok::<(), _>(())
258    }
259    #[inline]
260    fn row_kernel(&self, yi: f64, e_clamped: f64, m: f64, prior_w: f64) -> DiagonalIrlsRow {
261        assert!(e_clamped.is_finite());
262        assert!((e_clamped.exp() - m).abs() <= 1.0e-8 * m.abs().max(1.0));
263        // Gamma(shape=k, scale=mu/k), dropping eta-independent constants.
264        let log_lik_increment = prior_w * (-self.shape * (yi / m + m.ln()));
265        // Gamma with log mean is non-canonical. Use the exact observed
266        // η-space curvature -d²ℓ/dη² = prior_w * shape * y / μ, not the
267        // Fisher weight prior_w * shape, so diagonal REML/LAML Hessians
268        // use the true Laplace curvature instead of a PQL/Fisher surrogate.
269        let observed_weight = prior_w * self.shape * yi / m;
270        let score = prior_w * self.shape * (yi / m - 1.0);
271        // Mirror the pre-extraction formula z = e + score / w_floored exactly;
272        // the driver applies MIN_WEIGHT *before* writing w[i], but the old
273        // code divided by the already-floored w[i] for non-degenerate rows,
274        // and the floor only activates on the degenerate `observed_weight <=
275        // MIN_WEIGHT` tail. Reproduce that branch here to preserve bitwise
276        // step shape on every row that used to hit the floor.
277        let w_floored = observed_weight.max(MIN_WEIGHT);
278        DiagonalIrlsRow {
279            log_lik_increment,
280            observed_weight,
281            working_step: score / w_floored,
282        }
283    }
284}
285
286impl CustomFamily for GammaLogFamily {
287    // Preserve the pre-gam#1395 behavior: the trait default flipped to OFF (the
288    // flat-prior exact-Newton objective carries no Jeffreys term), so families
289    // that historically armed the term by default opt back in explicitly.
290    fn joint_jeffreys_term_required(&self) -> bool {
291        true
292    }
293
294    fn evaluate(&self, block_states: &[ParameterBlockState]) -> Result<FamilyEvaluation, String> {
295        evaluate_log_link_diagonal_irls(self, block_states)
296    }
297
298    fn exact_newton_joint_gradient_evaluation(
299        &self,
300        block_states: &[ParameterBlockState],
301        specs: &[ParameterBlockSpec],
302    ) -> Result<Option<ExactNewtonJointGradientEvaluation>, String> {
303        // Assemble the exact score from the IRLS working set (X_bᵀ(w⊙(z−η))).
304        // The Gamma log link is non-canonical, but the working step z is defined
305        // relative to the row weight so w(z−η) is the exact observed ∂ℓ/∂η
306        // regardless — the assembled gradient matches FD of the log-likelihood.
307        let eval = self.evaluate(block_states)?;
308        gamlss_joint_gradient_from_working_sets(&eval, specs, block_states).map(Some)
309    }
310
311    fn diagonalworking_weights_directional_derivative(
312        &self,
313        block_states: &[ParameterBlockState],
314        block_idx: usize,
315        d_eta: &Array1<f64>,
316    ) -> Result<Option<Array1<f64>>, String> {
317        if block_idx != Self::BLOCK_ETA {
318            return Ok(None);
319        }
320        let eta = &expect_single_block(block_states, "GammaLogFamily")?.eta;
321        let n = self.y.len();
322        if eta.len() != n || self.weights.len() != n || d_eta.len() != n {
323            return Err(GamlssError::DimensionMismatch {
324                reason: "GammaLogFamily input size mismatch".to_string(),
325            }
326            .into());
327        }
328        if !self.shape.is_finite() || self.shape <= 0.0 {
329            return Err(GamlssError::NonFinite {
330                reason: "GammaLogFamily shape must be finite and > 0".to_string(),
331            }
332            .into());
333        }
334
335        let mut dw = Array1::<f64>::zeros(n);
336        for i in 0..n {
337            let yi = self.y[i];
338            if !yi.is_finite() || yi <= 0.0 {
339                return Err(GamlssError::InvalidInput {
340                    reason: format!("GammaLogFamily requires positive finite y; found y[{i}]={yi}"),
341                }
342                .into());
343            }
344            let e_raw = eta[i];
345            let e = e_raw.clamp(-ETA_HARD_CLAMP, ETA_HARD_CLAMP);
346            if self.weights[i] == 0.0 || e != e_raw {
347                dw[i] = 0.0;
348                continue;
349            }
350            let m = safe_exp(e).max(MIN_WEIGHT);
351            let observed_weight = self.weights[i] * self.shape * yi / m;
352            // d/dη [prior_weight * shape * y / exp(η)] = -W_obs.
353            // If the positive floor is active, match the evaluated local piece.
354            if observed_weight <= MIN_WEIGHT {
355                dw[i] = 0.0;
356            } else {
357                dw[i] = -observed_weight * d_eta[i];
358            }
359        }
360        Ok(Some(dw))
361    }
362}
363
364impl CustomFamilyGenerative for GammaLogFamily {
365    fn generativespec(
366        &self,
367        block_states: &[ParameterBlockState],
368    ) -> Result<GenerativeSpec, String> {
369        let eta = &expect_single_block(block_states, "GammaLogFamily")?.eta;
370        let mean = gamlss_rowwise_map(eta.len(), |i| saturated_exp_eta(eta[i]));
371        let shape = ndarray::Array1::from_elem(mean.len(), self.shape);
372        Ok(GenerativeSpec {
373            mean,
374            noise: NoiseModel::Gamma { shape },
375        })
376    }
377}