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    /// Dispersion factor `k` the inner working weight carries but the reported
44    /// deviance (`state.deviance` / `CandidateScreen::deviance`) does not, so the
45    /// LM gain-ratio / stall-detection objective must be `k·deviance + penalty`
46    /// to stay consistent with the `k`-scaled gradient and Hessian the step is
47    /// built from. `1.0` for families whose weight carries no such factor (the
48    /// solver objective is already self-consistent there). See
49    /// `curvature::penalized_objective_deviance_scale` and issue #2128.
50    fn penalized_deviance_scale(&self) -> f64 {
51        1.0
52    }
53}
54
55/// Result of a cheap LM-candidate screen: penalized objective + arithmetic
56/// finiteness, without the gradient/Hessian needed for an accepted step.
57#[derive(Debug, Clone)]
58pub struct CandidateScreen {
59    pub deviance: f64,
60    pub penalty_term: f64,
61    pub arithmetic_finite: bool,
62}
63
64/// Outcome of `WorkingModel::screen_candidate`: either a cheap screen result
65/// (LM loop must upgrade with `update_with_curvature` on acceptance) or the
66/// full state when screening was not applicable.
67pub enum CandidateEvaluation {
68    Screen(CandidateScreen),
69    Full(WorkingState),
70}
71
72impl CandidateEvaluation {
73    /// The penalized objective `dev_scale·deviance + penalty` (minus the Firth
74    /// Jeffreys term when active). `dev_scale` is the family dispersion factor
75    /// `k` (see `WorkingModel::penalized_deviance_scale`): the trial's deviance
76    /// must be scaled by the SAME `k` the accepted state's is, so the LM
77    /// gain-ratio compares like with like (issue #2128).
78    #[inline]
79    pub(crate) fn penalized_objective(&self, firth_bias_reduction: bool, dev_scale: f64) -> f64 {
80        match self {
81            Self::Screen(s) => dev_scale * s.deviance + s.penalty_term,
82            Self::Full(state) => {
83                let mut value = dev_scale * state.deviance + state.penalty_term;
84                if firth_bias_reduction && let Some(j) = state.jeffreys_logdet() {
85                    value -= 2.0 * j;
86                }
87                value
88            }
89        }
90    }
91
92    #[inline]
93    pub(crate) fn arithmetic_finite(&self) -> bool {
94        match self {
95            Self::Screen(s) => s.arithmetic_finite,
96            Self::Full(state) => state.gradient.iter().all(|g| g.is_finite()),
97        }
98    }
99
100    #[inline]
101    pub(crate) fn into_full(self) -> Option<WorkingState> {
102        match self {
103            Self::Full(state) => Some(state),
104            Self::Screen(_) => None,
105        }
106    }
107}
108
109#[derive(Clone, Debug, PartialEq, Eq)]
110pub(super) struct PirlsAcceptedStateCacheKey {
111    curvature: HessianCurvatureKind,
112    firth_active: bool,
113    beta_bits: Vec<u64>,
114    arrow_latent_bits: Option<Vec<u64>>,
115}
116
117impl PirlsAcceptedStateCacheKey {
118    pub(crate) fn requested(
119        beta: &Coefficients,
120        curvature: HessianCurvatureKind,
121        options: &WorkingModelPirlsOptions,
122    ) -> Self {
123        Self::new(beta, curvature, options.firth_bias_reduction, options)
124    }
125
126    pub(crate) fn accepted(
127        beta: &Coefficients,
128        state: &WorkingState,
129        options: &WorkingModelPirlsOptions,
130    ) -> Self {
131        Self::new(
132            beta,
133            state.hessian_curvature,
134            matches!(state.firth, FirthDiagnostics::Active { .. }),
135            options,
136        )
137    }
138
139    pub(crate) fn new(
140        beta: &Coefficients,
141        curvature: HessianCurvatureKind,
142        firth_active: bool,
143        options: &WorkingModelPirlsOptions,
144    ) -> Self {
145        let arrow_latent_bits = options.arrow_schur.as_ref().map(|arrow_cfg| {
146            arrow_cfg.snapshot_t.as_ref()()
147                .iter()
148                .map(|value| value.to_bits())
149                .collect()
150        });
151        Self {
152            curvature,
153            firth_active,
154            beta_bits: beta.as_ref().iter().map(|value| value.to_bits()).collect(),
155            arrow_latent_bits,
156        }
157    }
158}
159
160/// Uncertainty inputs for integrated (GHQ) IRLS updates.
161#[derive(Clone, Copy)]
162pub(crate) struct IntegratedWorkingInput<'a> {
163    pub quadctx: &'a crate::quadrature::QuadratureContext,
164    pub se: ArrayView1<'a, f64>,
165    pub mixture_link_state: Option<&'a MixtureLinkState>,
166    pub sas_link_state: Option<&'a SasLinkState>,
167}
168
169pub struct WorkingDerivativeBuffersMut<'a> {
170    pub(crate) c: &'a mut Array1<f64>,
171    pub(crate) d: &'a mut Array1<f64>,
172    pub(crate) dmu_deta: &'a mut Array1<f64>,
173    pub(crate) d2mu_deta2: &'a mut Array1<f64>,
174    pub(crate) d3mu_deta3: &'a mut Array1<f64>,
175}
176
177/// Contiguous mutable views of the three core working buffers (`mu`, `weights`,
178/// `z`) shared by every PIRLS working-state writer.
179pub(super) struct WorkingSlices<'a> {
180    pub mu: &'a mut [f64],
181    pub weights: &'a mut [f64],
182    pub z: &'a mut [f64],
183}
184
185/// Contiguous mutable views of the Newton derivative/curvature buffers
186/// (`c`, `d`, `dmu/deta` jet) shared by the full-derivative PIRLS writers.
187pub(super) struct WorkingDerivSlices<'a> {
188    pub c: &'a mut [f64],
189    pub d: &'a mut [f64],
190    pub dmu: &'a mut [f64],
191    pub d2: &'a mut [f64],
192    pub d3: &'a mut [f64],
193}
194
195/// Canonical "contiguous-or-panic" unpacking of the three core working buffers.
196///
197/// Single source of truth for the contiguity contract and panic messages that
198/// every working-state writer relies on; every writer routes through this.
199#[inline]
200pub(super) fn working_slices<'a>(
201    mu: &'a mut Array1<f64>,
202    weights: &'a mut Array1<f64>,
203    z: &'a mut Array1<f64>,
204) -> WorkingSlices<'a> {
205    WorkingSlices {
206        mu: mu.as_slice_mut().expect("mu must be contiguous"),
207        weights: weights.as_slice_mut().expect("weights must be contiguous"),
208        z: z.as_slice_mut().expect("z must be contiguous"),
209    }
210}
211
212/// Canonical "contiguous-or-panic" unpacking of the Newton derivative buffers.
213///
214/// Single source of truth for the contiguity contract and panic messages of the
215/// `c`/`d`/`dmu`/`d2`/`d3` curvature buffers; every full-derivative writer routes
216/// through this.
217#[inline]
218pub(super) fn working_deriv_slices<'a>(
219    derivs: &'a mut WorkingDerivativeBuffersMut<'_>,
220) -> WorkingDerivSlices<'a> {
221    WorkingDerivSlices {
222        c: derivs.c.as_slice_mut().expect("c must be contiguous"),
223        d: derivs.d.as_slice_mut().expect("d must be contiguous"),
224        dmu: derivs
225            .dmu_deta
226            .as_slice_mut()
227            .expect("dmu_deta must be contiguous"),
228        d2: derivs
229            .d2mu_deta2
230            .as_slice_mut()
231            .expect("d2mu_deta2 must be contiguous"),
232        d3: derivs
233            .d3mu_deta3
234            .as_slice_mut()
235            .expect("d3mu_deta3 must be contiguous"),
236    }
237}
238
239#[derive(Clone, Copy)]
240pub(crate) struct WorkingBernoulliGeometry {
241    pub(crate) mu: f64,
242    pub(crate) weight: f64,
243    pub(crate) z: f64,
244    pub(crate) c: f64,
245    pub(crate) d: f64,
246}
247
248/// Shared likelihood interface used by PIRLS working updates.
249///
250/// This keeps the update/deviance math in one place so engine-level likelihoods
251/// and higher-level wrappers (custom family, GAMLSS warm starts) can share a
252/// consistent implementation.
253pub(crate) trait WorkingLikelihood {
254    fn irls_update(
255        &self,
256        y: ArrayView1<f64>,
257        eta: &Array1<f64>,
258        priorweights: ArrayView1<f64>,
259        mu: &mut Array1<f64>,
260        weights: &mut Array1<f64>,
261        z: &mut Array1<f64>,
262        integrated: Option<IntegratedWorkingInput<'_>>,
263        derivatives: Option<WorkingDerivativeBuffersMut<'_>>,
264    ) -> Result<(), EstimationError>;
265
266    fn loglik_deviance(
267        &self,
268        y: ArrayView1<f64>,
269        mu: &Array1<f64>,
270        priorweights: ArrayView1<f64>,
271    ) -> Result<f64, EstimationError>;
272}
273
274impl WorkingLikelihood for GlmLikelihoodSpec {
275    fn irls_update(
276        &self,
277        y: ArrayView1<f64>,
278        eta: &Array1<f64>,
279        priorweights: ArrayView1<f64>,
280        mu: &mut Array1<f64>,
281        weights: &mut Array1<f64>,
282        z: &mut Array1<f64>,
283        integrated: Option<IntegratedWorkingInput<'_>>,
284        derivatives: Option<WorkingDerivativeBuffersMut<'_>>,
285    ) -> Result<(), EstimationError> {
286        match (&self.spec.response, &self.spec.link, integrated.is_some()) {
287            (ResponseFamily::Binomial, _, true) => {
288                let integ = integrated.unwrap();
289                update_glmvectors_integrated_by_family(
290                    integ.quadctx,
291                    y,
292                    eta,
293                    integ.se,
294                    &self.spec,
295                    priorweights,
296                    mu,
297                    weights,
298                    z,
299                    derivatives,
300                    integ.mixture_link_state,
301                    integ.sas_link_state,
302                )?;
303                Ok(())
304            }
305            (ResponseFamily::Binomial, link, false) => {
306                if matches!(link, InverseLink::Mixture(_)) {
307                    crate::bail_invalid_estim!(
308                        "BinomialMixture IRLS update requires explicit mixture link state"
309                            .to_string(),
310                    );
311                }
312                update_glmvectors(
313                    y,
314                    eta,
315                    &self.spec.link,
316                    priorweights,
317                    mu,
318                    weights,
319                    z,
320                    derivatives,
321                )?;
322                Ok(())
323            }
324            (ResponseFamily::Gaussian, _, _) => {
325                update_glmvectors(
326                    y,
327                    eta,
328                    &InverseLink::Standard(StandardLink::Identity),
329                    priorweights,
330                    mu,
331                    weights,
332                    z,
333                    None,
334                )?;
335                // For Gaussian identity, the canonical IRLS working weight is
336                //     w_i = prior_i * (dmu/deta)^2 / Var(Y_i | mu_i) = prior_i / phi.
337                // When the scale metadata explicitly fixes phi (rather than
338                // profiling sigma out), the working weights must include 1/phi
339                // so that PIRLS minimises the scaled deviance / scaled negative
340                // log-likelihood that the calibrator and downstream variance
341                // calculations expect. `ProfiledGaussian` returns `None` here,
342                // preserving the historical "weights == prior" behaviour for
343                // the default profiled case.
344                if let Some(phi) = self.scale.fixed_phi() {
345                    if !(phi.is_finite() && phi > 0.0) {
346                        crate::bail_invalid_estim!(
347                            "Gaussian fixed dispersion phi must be finite and positive (got {})",
348                            phi
349                        );
350                    }
351                    if phi != 1.0 {
352                        let inv_phi = 1.0 / phi;
353                        weights.mapv_inplace(|w| w * inv_phi);
354                    }
355                }
356                Ok(())
357            }
358            (ResponseFamily::Poisson, _, _) => {
359                write_poisson_log_working_state(y, eta, priorweights, mu, weights, z, derivatives);
360                Ok(())
361            }
362            (ResponseFamily::Tweedie { p }, _, _) => {
363                let p = *p;
364                write_tweedie_log_working_state(
365                    y,
366                    eta,
367                    priorweights,
368                    p,
369                    fixed_glm_dispersion(self),
370                    mu,
371                    weights,
372                    z,
373                    derivatives,
374                )?;
375                Ok(())
376            }
377            (ResponseFamily::NegativeBinomial { theta, .. }, _, _) => {
378                let theta = *theta;
379                write_negative_binomial_log_working_state(
380                    y,
381                    eta,
382                    priorweights,
383                    theta,
384                    mu,
385                    weights,
386                    z,
387                    derivatives,
388                )?;
389                Ok(())
390            }
391            (ResponseFamily::Beta { phi }, _, _) => {
392                let phi = *phi;
393                write_beta_logit_working_state(
394                    y,
395                    eta,
396                    priorweights,
397                    phi,
398                    mu,
399                    weights,
400                    z,
401                    derivatives,
402                )?;
403                Ok(())
404            }
405            (ResponseFamily::Gamma, _, _) => {
406                write_gamma_log_working_state(
407                    y,
408                    eta,
409                    priorweights,
410                    self.gamma_shape().unwrap_or(1.0),
411                    mu,
412                    weights,
413                    z,
414                    derivatives,
415                );
416                Ok(())
417            }
418            (ResponseFamily::RoystonParmar, _, _) => Err(EstimationError::InvalidInput(
419                "RoystonParmar is survival-specific and not a GLM IRLS family".to_string(),
420            )),
421        }
422    }
423
424    fn loglik_deviance(
425        &self,
426        y: ArrayView1<f64>,
427        mu: &Array1<f64>,
428        priorweights: ArrayView1<f64>,
429    ) -> Result<f64, EstimationError> {
430        if matches!(self.spec.response, ResponseFamily::Tweedie { .. }) {
431            validate_tweedie_responses(&y, &priorweights)?;
432        }
433        Ok(calculate_deviance(y, mu, self, priorweights))
434    }
435}