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