Skip to main content

gam_solve/pirls/
working_model_trait.rs

1//! The `WorkingModel` / `WorkingLikelihood` trait surface plus the shared
2//! working-buffer machinery: candidate-screen results, the accepted-state cache
3//! key, and the contiguous mu/weights/z and Newton-derivative buffer slices that
4//! every per-family working-state writer routes through.
5
6use super::*;
7
8pub trait WorkingModel {
9    fn update(&mut self, beta: &Coefficients) -> Result<WorkingState, EstimationError>;
10
11    fn update_with_curvature(
12        &mut self,
13        beta: &Coefficients,
14        _: HessianCurvatureKind,
15    ) -> Result<WorkingState, EstimationError> {
16        self.update(beta)
17    }
18
19    fn update_candidate(
20        &mut self,
21        beta: &Coefficients,
22        curvature: HessianCurvatureKind,
23    ) -> Result<WorkingState, EstimationError> {
24        self.update_with_curvature(beta, curvature)
25    }
26
27    fn screen_candidate(
28        &mut self,
29        beta: &Coefficients,
30        arr: &Array1<f64>,
31        _: &LinearPredictor,
32        curvature: HessianCurvatureKind,
33    ) -> Result<CandidateEvaluation, EstimationError> {
34        assert!(arr.iter().all(|v| !v.is_nan()));
35        self.update_candidate(beta, curvature)
36            .map(CandidateEvaluation::Full)
37    }
38
39    fn supports_observed_information_curvature(&self) -> bool {
40        false
41    }
42
43    /// Solve the unconstrained LM system in the model's native numerical
44    /// representation.  Generic working models own only an assembled Hessian;
45    /// concrete models that retain a better-conditioned square root can
46    /// override this atom without changing constraints or trust-region logic.
47    fn solve_unconstrained_direction(
48        &mut self,
49        beta: &Coefficients,
50        state: &WorkingState,
51        loop_lambda: f64,
52        lm_d2: &Array1<f64>,
53        regularized_hessian: &Array2<f64>,
54        direction_out: &mut Array1<f64>,
55    ) -> Result<(), EstimationError> {
56        if beta.as_ref().len() != state.gradient.len() {
57            crate::bail_invalid_estim!(
58                "PIRLS coefficient length {} does not match gradient length {}",
59                beta.as_ref().len(),
60                state.gradient.len()
61            );
62        }
63        if !(loop_lambda.is_finite() && loop_lambda >= 0.0) {
64            crate::bail_invalid_estim!(
65                "PIRLS LM damping must be finite and nonnegative, got {loop_lambda}"
66            );
67        }
68        if lm_d2.len() != state.gradient.len() {
69            crate::bail_invalid_estim!(
70                "PIRLS LM diagonal length {} does not match gradient length {}",
71                lm_d2.len(),
72                state.gradient.len()
73            );
74        }
75        solve_newton_direction_dense(regularized_hessian, &state.gradient, direction_out)?;
76        Ok(())
77    }
78
79    /// Return an exact squared Newton decrement for the supplied coefficient
80    /// state when the model owns a numerically stronger representation than
81    /// the assembled coefficient-space gradient/Hessian.
82    ///
83    /// The certificate must describe `beta` and `state` themselves.  A
84    /// decrement computed while solving a previous LM step cannot be reused
85    /// after that step has changed the coefficients.
86    fn exact_unconstrained_decrement_sq(
87        &mut self,
88        beta: &Coefficients,
89        state: &WorkingState,
90    ) -> Result<Option<f64>, EstimationError> {
91        if beta.as_ref().len() != state.gradient.len() {
92            crate::bail_invalid_estim!(
93                "PIRLS coefficient length {} does not match gradient length {}",
94                beta.as_ref().len(),
95                state.gradient.len()
96            );
97        }
98        Ok(None)
99    }
100
101    /// Add the model-specific curvature term omitted from `WorkingState`'s
102    /// assembled statistical/penalty Hessian when evaluating the bare
103    /// objective quadratic `dᵀ H d`.
104    ///
105    /// Most models store the complete objective Hessian and return zero.  Firth
106    /// keeps `HΦ` beside its cancellation-safe root operands because the outer
107    /// Laplace layer also consumes `H₀` and `HΦ` separately; its correction is
108    /// therefore `-dᵀHΦd`.
109    fn objective_hessian_quadratic_correction(
110        &self,
111        direction: &Array1<f64>,
112    ) -> Result<f64, EstimationError> {
113        assert!(array_is_finite(direction));
114        Ok(0.0)
115    }
116
117    /// Dispersion factor `k` the inner working weight carries but the reported
118    /// deviance (`state.deviance` / `CandidateScreen::deviance`) does not, so the
119    /// LM gain-ratio / stall-detection objective must be
120    /// `½(k·deviance + penalty)`
121    /// to stay consistent with the `k`-scaled gradient and Hessian the step is
122    /// built from. `1.0` for families whose weight carries no such factor (the
123    /// solver objective is already self-consistent there). See
124    /// `curvature::penalized_objective_deviance_scale` and issue #2128.
125    fn penalized_deviance_scale(&self) -> Result<f64, EstimationError> {
126        Ok(1.0)
127    }
128}
129
130/// Result of a cheap LM-candidate screen: penalized objective + arithmetic
131/// finiteness, without the gradient/Hessian needed for an accepted step.
132#[derive(Debug, Clone)]
133pub struct CandidateScreen {
134    pub deviance: f64,
135    pub penalty_term: f64,
136    pub arithmetic_finite: bool,
137}
138
139/// Outcome of `WorkingModel::screen_candidate`: either a cheap screen result
140/// (LM loop must upgrade with `update_with_curvature` on acceptance) or the
141/// full state when screening was not applicable.
142pub enum CandidateEvaluation {
143    Screen(CandidateScreen),
144    Full(WorkingState),
145}
146
147impl CandidateEvaluation {
148    /// The penalized objective `½(dev_scale·deviance + penalty)` (minus the Firth
149    /// Jeffreys term when active). `dev_scale` is the family dispersion factor
150    /// `k` (see `WorkingModel::penalized_deviance_scale`): the trial's deviance
151    /// must be scaled by the SAME `k` the accepted state's is, so the LM
152    /// gain-ratio compares like with like (issue #2128).
153    #[inline]
154    pub(crate) fn penalized_objective(&self, firth_bias_reduction: bool, dev_scale: f64) -> f64 {
155        match self {
156            Self::Screen(s) => 0.5 * (dev_scale * s.deviance + s.penalty_term),
157            Self::Full(state) => {
158                let mut value = 0.5 * (dev_scale * state.deviance + state.penalty_term);
159                if firth_bias_reduction && let Some(j) = state.jeffreys_logdet() {
160                    value -= j;
161                }
162                value
163            }
164        }
165    }
166
167    #[inline]
168    pub(crate) fn arithmetic_finite(&self) -> bool {
169        match self {
170            Self::Screen(s) => s.arithmetic_finite,
171            Self::Full(state) => state.gradient.iter().all(|g| g.is_finite()),
172        }
173    }
174
175    #[inline]
176    pub(crate) fn into_full(self) -> Option<WorkingState> {
177        match self {
178            Self::Full(state) => Some(state),
179            Self::Screen(_) => None,
180        }
181    }
182}
183
184#[derive(Clone, Debug, PartialEq, Eq)]
185pub(super) struct PirlsAcceptedStateCacheKey {
186    curvature: HessianCurvatureKind,
187    firth_active: bool,
188    beta_bits: Vec<u64>,
189    arrow_latent_bits: Option<Vec<u64>>,
190}
191
192impl PirlsAcceptedStateCacheKey {
193    pub(crate) fn requested(
194        beta: &Coefficients,
195        curvature: HessianCurvatureKind,
196        options: &WorkingModelPirlsOptions,
197    ) -> Self {
198        Self::new(beta, curvature, options.firth_bias_reduction, options)
199    }
200
201    pub(crate) fn accepted(
202        beta: &Coefficients,
203        state: &WorkingState,
204        options: &WorkingModelPirlsOptions,
205    ) -> Self {
206        Self::new(
207            beta,
208            state.hessian_curvature,
209            matches!(state.firth, FirthDiagnostics::Active { .. }),
210            options,
211        )
212    }
213
214    pub(crate) fn new(
215        beta: &Coefficients,
216        curvature: HessianCurvatureKind,
217        firth_active: bool,
218        options: &WorkingModelPirlsOptions,
219    ) -> Self {
220        let arrow_latent_bits = options.arrow_schur.as_ref().map(|arrow_cfg| {
221            arrow_cfg.snapshot_t.as_ref()()
222                .iter()
223                .map(|value| value.to_bits())
224                .collect()
225        });
226        Self {
227            curvature,
228            firth_active,
229            beta_bits: beta.as_ref().iter().map(|value| value.to_bits()).collect(),
230            arrow_latent_bits,
231        }
232    }
233}
234
235/// Uncertainty inputs for integrated (GHQ) IRLS updates.
236#[derive(Clone, Copy)]
237pub(crate) struct IntegratedWorkingInput<'a> {
238    pub quadctx: &'a crate::quadrature::QuadratureContext,
239    pub se: ArrayView1<'a, f64>,
240    pub mixture_link_state: Option<&'a MixtureLinkState>,
241    pub sas_link_state: Option<&'a SasLinkState>,
242}
243
244pub struct WorkingDerivativeBuffersMut<'a> {
245    pub(crate) c: &'a mut Array1<f64>,
246    pub(crate) d: &'a mut Array1<f64>,
247    pub(crate) dmu_deta: &'a mut Array1<f64>,
248    pub(crate) d2mu_deta2: &'a mut Array1<f64>,
249    pub(crate) d3mu_deta3: &'a mut Array1<f64>,
250}
251
252/// Contiguous mutable views of the three core working buffers (`mu`, `weights`,
253/// `z`) shared by every PIRLS working-state writer.
254pub(super) struct WorkingSlices<'a> {
255    pub mu: &'a mut [f64],
256    pub weights: &'a mut [f64],
257    pub z: &'a mut [f64],
258}
259
260/// Contiguous mutable views of the Newton derivative/curvature buffers
261/// (`c`, `d`, `dmu/deta` jet) shared by the full-derivative PIRLS writers.
262pub(super) struct WorkingDerivSlices<'a> {
263    pub c: &'a mut [f64],
264    pub d: &'a mut [f64],
265    pub dmu: &'a mut [f64],
266    pub d2: &'a mut [f64],
267    pub d3: &'a mut [f64],
268}
269
270/// Canonical "contiguous-or-panic" unpacking of the three core working buffers.
271///
272/// Single source of truth for the contiguity contract and panic messages that
273/// every working-state writer relies on; every writer routes through this.
274#[inline]
275pub(super) fn working_slices<'a>(
276    mu: &'a mut Array1<f64>,
277    weights: &'a mut Array1<f64>,
278    z: &'a mut Array1<f64>,
279) -> WorkingSlices<'a> {
280    WorkingSlices {
281        mu: mu.as_slice_mut().expect("mu must be contiguous"),
282        weights: weights.as_slice_mut().expect("weights must be contiguous"),
283        z: z.as_slice_mut().expect("z must be contiguous"),
284    }
285}
286
287/// Canonical "contiguous-or-panic" unpacking of the Newton derivative buffers.
288///
289/// Single source of truth for the contiguity contract and panic messages of the
290/// `c`/`d`/`dmu`/`d2`/`d3` curvature buffers; every full-derivative writer routes
291/// through this.
292#[inline]
293pub(super) fn working_deriv_slices<'a>(
294    derivs: &'a mut WorkingDerivativeBuffersMut<'_>,
295) -> WorkingDerivSlices<'a> {
296    WorkingDerivSlices {
297        c: derivs.c.as_slice_mut().expect("c must be contiguous"),
298        d: derivs.d.as_slice_mut().expect("d must be contiguous"),
299        dmu: derivs
300            .dmu_deta
301            .as_slice_mut()
302            .expect("dmu_deta must be contiguous"),
303        d2: derivs
304            .d2mu_deta2
305            .as_slice_mut()
306            .expect("d2mu_deta2 must be contiguous"),
307        d3: derivs
308            .d3mu_deta3
309            .as_slice_mut()
310            .expect("d3mu_deta3 must be contiguous"),
311    }
312}
313
314#[derive(Clone, Copy)]
315pub(crate) struct WorkingBernoulliGeometry {
316    pub(crate) mu: f64,
317    pub(crate) weight: f64,
318    pub(crate) z: f64,
319    pub(crate) c: f64,
320    pub(crate) d: f64,
321}
322
323/// Shared likelihood interface used by PIRLS working updates.
324///
325/// This keeps the update/deviance math in one place so engine-level likelihoods
326/// and higher-level wrappers (custom family, GAMLSS warm starts) can share a
327/// consistent implementation.
328pub(crate) trait WorkingLikelihood {
329    fn irls_update(
330        &self,
331        y: ArrayView1<f64>,
332        eta: &Array1<f64>,
333        priorweights: ArrayView1<f64>,
334        mu: &mut Array1<f64>,
335        weights: &mut Array1<f64>,
336        z: &mut Array1<f64>,
337        integrated: Option<IntegratedWorkingInput<'_>>,
338        derivatives: Option<WorkingDerivativeBuffersMut<'_>>,
339    ) -> Result<(), EstimationError>;
340
341    fn loglik_deviance(
342        &self,
343        y: ArrayView1<f64>,
344        eta: &Array1<f64>,
345        inverse_link: &InverseLink,
346        priorweights: ArrayView1<f64>,
347    ) -> Result<f64, EstimationError>;
348}
349
350impl WorkingLikelihood for GlmLikelihoodSpec {
351    fn irls_update(
352        &self,
353        y: ArrayView1<f64>,
354        eta: &Array1<f64>,
355        priorweights: ArrayView1<f64>,
356        mu: &mut Array1<f64>,
357        weights: &mut Array1<f64>,
358        z: &mut Array1<f64>,
359        integrated: Option<IntegratedWorkingInput<'_>>,
360        derivatives: Option<WorkingDerivativeBuffersMut<'_>>,
361    ) -> Result<(), EstimationError> {
362        match (&self.spec.response, &self.spec.link, integrated.is_some()) {
363            (ResponseFamily::Binomial, _, true) => {
364                let integ = integrated.unwrap();
365                update_glmvectors_integrated_by_family(
366                    integ.quadctx,
367                    y,
368                    eta,
369                    integ.se,
370                    &self.spec,
371                    priorweights,
372                    mu,
373                    weights,
374                    z,
375                    derivatives,
376                    integ.mixture_link_state,
377                    integ.sas_link_state,
378                )?;
379                Ok(())
380            }
381            (ResponseFamily::Binomial, link, false) => {
382                if matches!(link, InverseLink::Mixture(_)) {
383                    crate::bail_invalid_estim!(
384                        "BinomialMixture IRLS update requires explicit mixture link state"
385                            .to_string(),
386                    );
387                }
388                update_glmvectors(
389                    y,
390                    eta,
391                    &self.spec.link,
392                    priorweights,
393                    mu,
394                    weights,
395                    z,
396                    derivatives,
397                )?;
398                Ok(())
399            }
400            (ResponseFamily::Gaussian, _, _) => {
401                let resolved_scale = self
402                    .resolved_scale()
403                    .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
404                update_glmvectors(
405                    y,
406                    eta,
407                    &InverseLink::Standard(StandardLink::Identity),
408                    priorweights,
409                    mu,
410                    weights,
411                    z,
412                    None,
413                )?;
414                // For Gaussian identity, the canonical IRLS working weight is
415                //     w_i = prior_i * (dmu/deta)^2 / Var(Y_i | mu_i) = prior_i / phi.
416                // When the scale metadata explicitly fixes phi (rather than
417                // profiling sigma out), the working weights must include 1/phi
418                // so that PIRLS minimises the scaled deviance / scaled negative
419                // log-likelihood that the calibrator and downstream variance
420                // calculations expect. `ProfiledGaussian` returns `None` here,
421                // preserving the historical "weights == prior" behaviour for
422                // the default profiled case.
423                if let gam_problem::ResolvedLikelihoodScale::FixedGaussian { phi } = resolved_scale
424                {
425                    let phi = phi.value();
426                    if phi != 1.0 {
427                        let inv_phi = 1.0 / phi;
428                        if !(inv_phi.is_finite() && inv_phi > 0.0) {
429                            crate::bail_invalid_estim!(
430                                "Gaussian reciprocal dispersion is not representable for phi={phi}: {inv_phi:?}"
431                            );
432                        }
433                        weights.mapv_inplace(|w| w * inv_phi);
434                    }
435                }
436                Ok(())
437            }
438            (ResponseFamily::Poisson, _, _) => {
439                write_poisson_log_working_state(y, eta, priorweights, mu, weights, z, derivatives)
440            }
441            (ResponseFamily::Tweedie { p }, _, _) => {
442                let p = *p;
443                write_tweedie_log_working_state(
444                    y,
445                    eta,
446                    priorweights,
447                    p,
448                    self.resolved_tweedie_phi()
449                        .map_err(|error| EstimationError::InvalidInput(error.to_string()))?,
450                    mu,
451                    weights,
452                    z,
453                    derivatives,
454                )?;
455                Ok(())
456            }
457            (ResponseFamily::NegativeBinomial { .. }, _, _) => {
458                let theta = self
459                    .resolved_negbin_theta()
460                    .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
461                write_negative_binomial_log_working_state(
462                    y,
463                    eta,
464                    priorweights,
465                    theta,
466                    mu,
467                    weights,
468                    z,
469                    derivatives,
470                )?;
471                Ok(())
472            }
473            (ResponseFamily::Beta { .. }, _, _) => {
474                let phi = self
475                    .resolved_beta_precision()
476                    .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
477                write_beta_logit_working_state(
478                    y,
479                    eta,
480                    priorweights,
481                    phi,
482                    mu,
483                    weights,
484                    z,
485                    derivatives,
486                )?;
487                Ok(())
488            }
489            (ResponseFamily::Gamma, _, _) => {
490                let shape = self
491                    .resolved_gamma_shape()
492                    .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
493                write_gamma_log_working_state(
494                    y,
495                    eta,
496                    priorweights,
497                    shape,
498                    mu,
499                    weights,
500                    z,
501                    derivatives,
502                )
503            }
504            (ResponseFamily::RoystonParmar, _, _) => Err(EstimationError::InvalidInput(
505                "RoystonParmar is survival-specific and not a GLM IRLS family".to_string(),
506            )),
507        }
508    }
509
510    fn loglik_deviance(
511        &self,
512        y: ArrayView1<f64>,
513        eta: &Array1<f64>,
514        inverse_link: &InverseLink,
515        priorweights: ArrayView1<f64>,
516    ) -> Result<f64, EstimationError> {
517        if matches!(self.spec.response, ResponseFamily::Tweedie { .. }) {
518            validate_tweedie_responses(&y, &priorweights)?;
519        }
520        calculate_deviance_from_eta(y, eta, self, inverse_link, priorweights)
521    }
522}