Skip to main content

gam_models/gamlss/
alo_replay.rs

1use super::*;
2
3/// Exact row-local saved-model geometry in affine likelihood coordinates.
4///
5/// Coordinates are `[primary predictor, log-scale predictor, wiggle
6/// coefficients...]`. The first two entries use their fitted design rows;
7/// every optional wiggle entry is the scalar coefficient itself and therefore
8/// has a one-column constant design. This augmented coordinate system retains
9/// every second derivative of `B(q0) beta_w` while remaining affine in the
10/// saved coefficient vector.
11#[derive(Clone, Debug, PartialEq)]
12pub struct LocationScaleAloRowGeometry {
13    pub nll_score: Array1<f64>,
14    pub observed_hessian: Array2<f64>,
15}
16
17/// Complete saved-row state for exact Gaussian location-scale ALO replay.
18pub struct GaussianLocationScaleAloRowInput<'a> {
19    pub row: usize,
20    pub y: f64,
21    pub base_mean: f64,
22    pub eta_log_sigma: f64,
23    pub prior_weight: f64,
24    pub response_scale: f64,
25    pub wiggle_basis: &'a [f64],
26    pub wiggle_basis_d1: &'a [f64],
27    pub wiggle_basis_d2: &'a [f64],
28    pub wiggle_beta: &'a [f64],
29}
30
31/// Complete saved-row state for exact binomial location-scale ALO replay.
32pub struct BinomialLocationScaleAloRowInput<'a> {
33    pub y: f64,
34    pub threshold_eta: f64,
35    pub eta_log_sigma: f64,
36    pub prior_weight: f64,
37    pub inverse_link: &'a InverseLink,
38    pub wiggle_basis: &'a [f64],
39    pub wiggle_basis_d1: &'a [f64],
40    pub wiggle_basis_d2: &'a [f64],
41    pub wiggle_beta: &'a [f64],
42}
43
44fn validate_saved_wiggle_row(
45    context: &str,
46    basis: &[f64],
47    basis_d1: &[f64],
48    basis_d2: &[f64],
49    beta: &[f64],
50) -> Result<(), String> {
51    let dimension = beta.len();
52    if basis.len() != dimension || basis_d1.len() != dimension || basis_d2.len() != dimension {
53        return Err(GamlssError::DimensionMismatch {
54            reason: format!(
55                "{context} saved wiggle row mismatch: beta={dimension}, basis={}, basis_d1={}, basis_d2={}",
56                basis.len(),
57                basis_d1.len(),
58                basis_d2.len(),
59            ),
60        }
61        .into());
62    }
63    if let Some((coordinate, value)) = basis
64        .iter()
65        .chain(basis_d1)
66        .chain(basis_d2)
67        .chain(beta)
68        .copied()
69        .enumerate()
70        .find(|(_, value)| !value.is_finite())
71    {
72        return Err(GamlssError::NonFinite {
73            reason: format!(
74                "{context} saved wiggle row has a non-finite flattened coordinate {coordinate}: {value}"
75            ),
76        }
77        .into());
78    }
79    Ok(())
80}
81
82#[inline]
83fn dot_slices(left: &[f64], right: &[f64]) -> f64 {
84    left.iter().zip(right).map(|(&a, &b)| a * b).sum()
85}
86
87/// Replay one Gaussian location-scale row in the raw saved coefficient frame.
88///
89/// The production certified kernel is evaluated in the standardized fit frame
90/// and then transformed analytically to raw coordinates. This preserves the
91/// fitter's extreme-value semantics while pairing the result with the raw
92/// precision persisted after response rescaling.
93pub fn gaussian_location_scale_alo_row_geometry(
94    input: GaussianLocationScaleAloRowInput<'_>,
95) -> Result<LocationScaleAloRowGeometry, String> {
96    let GaussianLocationScaleAloRowInput {
97        row,
98        y,
99        base_mean,
100        eta_log_sigma,
101        prior_weight,
102        response_scale,
103        wiggle_basis,
104        wiggle_basis_d1,
105        wiggle_basis_d2,
106        wiggle_beta,
107    } = input;
108    validate_saved_wiggle_row(
109        "Gaussian location-scale ALO",
110        wiggle_basis,
111        wiggle_basis_d1,
112        wiggle_basis_d2,
113        wiggle_beta,
114    )?;
115    if !(response_scale.is_finite() && response_scale > 0.0) {
116        return Err(GamlssError::InvalidInput {
117            reason: format!(
118                "Gaussian location-scale ALO response scale must be finite and positive, got {response_scale}"
119            ),
120        }
121        .into());
122    }
123
124    let warped_mean = base_mean + dot_slices(wiggle_basis, wiggle_beta);
125    let internal = gaussian_diagonal_row_kernel(
126        row,
127        y / response_scale,
128        warped_mean / response_scale,
129        eta_log_sigma - response_scale.ln(),
130        prior_weight,
131        (2.0 * std::f64::consts::PI).ln(),
132    )?;
133    let inverse_scale = response_scale.recip();
134    let nll_q = -internal.joint_m * inverse_scale;
135    let nll_s = internal.kappa * (prior_weight - internal.joint_n);
136    let h_qq = internal.joint_w * inverse_scale * inverse_scale;
137    let h_qs = 2.0 * internal.kappa * internal.joint_m * inverse_scale;
138    let h_ss = internal.kappa_prime * (prior_weight - internal.joint_n)
139        + 2.0 * internal.kappa * internal.kappa * internal.joint_n;
140
141    let wiggle_dimension = wiggle_beta.len();
142    let dimension = 2 + wiggle_dimension;
143    let warp_d1 = 1.0 + dot_slices(wiggle_basis_d1, wiggle_beta);
144    let warp_d2 = dot_slices(wiggle_basis_d2, wiggle_beta);
145    let mut score = Array1::<f64>::zeros(dimension);
146    let mut hessian = Array2::<f64>::zeros((dimension, dimension));
147    score[0] = nll_q * warp_d1;
148    score[1] = nll_s;
149    hessian[[0, 0]] = h_qq * warp_d1 * warp_d1 + nll_q * warp_d2;
150    hessian[[0, 1]] = h_qs * warp_d1;
151    hessian[[1, 0]] = hessian[[0, 1]];
152    hessian[[1, 1]] = h_ss;
153
154    for j in 0..wiggle_dimension {
155        let wj = 2 + j;
156        score[wj] = nll_q * wiggle_basis[j];
157        hessian[[0, wj]] = h_qq * warp_d1 * wiggle_basis[j] + nll_q * wiggle_basis_d1[j];
158        hessian[[wj, 0]] = hessian[[0, wj]];
159        hessian[[1, wj]] = h_qs * wiggle_basis[j];
160        hessian[[wj, 1]] = hessian[[1, wj]];
161        for k in 0..wiggle_dimension {
162            hessian[[wj, 2 + k]] = h_qq * wiggle_basis[j] * wiggle_basis[k];
163        }
164    }
165
166    Ok(LocationScaleAloRowGeometry {
167        nll_score: score,
168        observed_hessian: hessian,
169    })
170}
171
172/// Replay one binomial threshold-scale row in affine saved coordinates.
173///
174/// The q-space NLL derivatives are the same family-aware derivatives used by
175/// fitting. The surrounding chain rule retains the exact second derivatives of
176/// `q0 = -eta_t exp(-eta_s)` and optional `B(q0) beta_w`.
177pub fn binomial_location_scale_alo_row_geometry(
178    input: BinomialLocationScaleAloRowInput<'_>,
179) -> Result<LocationScaleAloRowGeometry, String> {
180    let BinomialLocationScaleAloRowInput {
181        y,
182        threshold_eta,
183        eta_log_sigma,
184        prior_weight,
185        inverse_link,
186        wiggle_basis,
187        wiggle_basis_d1,
188        wiggle_basis_d2,
189        wiggle_beta,
190    } = input;
191    validate_saved_wiggle_row(
192        "binomial location-scale ALO",
193        wiggle_basis,
194        wiggle_basis_d1,
195        wiggle_basis_d2,
196        wiggle_beta,
197    )?;
198    if !y.is_finite() || !(0.0..=1.0).contains(&y) {
199        return Err(GamlssError::InvalidInput {
200            reason: format!(
201                "binomial location-scale ALO response must be finite and inside [0, 1], got {y}"
202            ),
203        }
204        .into());
205    }
206    if !prior_weight.is_finite() || prior_weight < 0.0 {
207        return Err(GamlssError::InvalidInput {
208            reason: format!(
209                "binomial location-scale ALO prior weight must be finite and non-negative, got {prior_weight}"
210            ),
211        }
212        .into());
213    }
214
215    let wiggle_value = dot_slices(wiggle_basis, wiggle_beta);
216    let fitted_row = binomial_location_scalerow(
217        y,
218        prior_weight,
219        threshold_eta,
220        eta_log_sigma,
221        wiggle_value,
222        inverse_link,
223    )?;
224    let q = fitted_row.q0 + wiggle_value;
225    let (nll_q, h_qq, _) = binomial_neglog_q_derivatives_dispatch(
226        y,
227        prior_weight,
228        q,
229        fitted_row.inverse_link.mu,
230        fitted_row.inverse_link.d1,
231        fitted_row.inverse_link.d2,
232        fitted_row.inverse_link.d3,
233        inverse_link,
234    );
235
236    let wiggle_dimension = wiggle_beta.len();
237    let dimension = 2 + wiggle_dimension;
238    let q0_derivatives = nonwiggle_q_derivs(threshold_eta, fitted_row.sigma);
239    let warp_d1 = 1.0 + dot_slices(wiggle_basis_d1, wiggle_beta);
240    let warp_d2 = dot_slices(wiggle_basis_d2, wiggle_beta);
241    let mut dq = Array1::<f64>::zeros(dimension);
242    dq[0] = warp_d1 * q0_derivatives.q_t;
243    dq[1] = warp_d1 * q0_derivatives.q_ls;
244    for j in 0..wiggle_dimension {
245        dq[2 + j] = wiggle_basis[j];
246    }
247
248    let mut d2q = Array2::<f64>::zeros((dimension, dimension));
249    d2q[[0, 0]] = warp_d2 * q0_derivatives.q_t * q0_derivatives.q_t;
250    d2q[[0, 1]] =
251        warp_d2 * q0_derivatives.q_t * q0_derivatives.q_ls + warp_d1 * q0_derivatives.q_tl;
252    d2q[[1, 0]] = d2q[[0, 1]];
253    d2q[[1, 1]] =
254        warp_d2 * q0_derivatives.q_ls * q0_derivatives.q_ls + warp_d1 * q0_derivatives.q_ll;
255    for j in 0..wiggle_dimension {
256        let wj = 2 + j;
257        d2q[[0, wj]] = wiggle_basis_d1[j] * q0_derivatives.q_t;
258        d2q[[wj, 0]] = d2q[[0, wj]];
259        d2q[[1, wj]] = wiggle_basis_d1[j] * q0_derivatives.q_ls;
260        d2q[[wj, 1]] = d2q[[1, wj]];
261    }
262
263    let score = dq.mapv(|value| nll_q * value);
264    let mut hessian = Array2::<f64>::zeros((dimension, dimension));
265    for i in 0..dimension {
266        for j in 0..dimension {
267            hessian[[i, j]] = h_qq * dq[i] * dq[j] + nll_q * d2q[[i, j]];
268        }
269    }
270    Ok(LocationScaleAloRowGeometry {
271        nll_score: score,
272        observed_hessian: hessian,
273    })
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    fn assert_close(label: &str, actual: f64, expected: f64) {
281        assert!(
282            (actual - expected).abs() <= 2e-12 * (1.0 + expected.abs()),
283            "{label}: actual={actual:.16e}, expected={expected:.16e}"
284        );
285    }
286
287    #[test]
288    fn gaussian_saved_alo_geometry_matches_raw_scale_closed_form() {
289        let y = 7.0;
290        let mean = 4.0;
291        let eta_sigma = 0.3;
292        let weight = 1.4;
293        let response_scale = 5.0;
294        let geometry = gaussian_location_scale_alo_row_geometry(GaussianLocationScaleAloRowInput {
295            row: 0,
296            y,
297            base_mean: mean,
298            eta_log_sigma: eta_sigma,
299            prior_weight: weight,
300            response_scale,
301            wiggle_basis: &[],
302            wiggle_basis_d1: &[],
303            wiggle_basis_d2: &[],
304            wiggle_beta: &[],
305        })
306        .expect("Gaussian saved row must replay");
307
308        let sigma =
309            response_scale * gam_model_kernels::sigma_link::LOGB_SIGMA_FLOOR + eta_sigma.exp();
310        let kappa = eta_sigma.exp() / sigma;
311        let residual = y - mean;
312        let residual_sq = residual * residual / (sigma * sigma);
313        let expected_score = [
314            -weight * residual / (sigma * sigma),
315            weight * kappa * (1.0 - residual_sq),
316        ];
317        let expected_hessian = [
318            [
319                weight / (sigma * sigma),
320                2.0 * weight * kappa * residual / (sigma * sigma),
321            ],
322            [
323                2.0 * weight * kappa * residual / (sigma * sigma),
324                weight
325                    * (kappa * (1.0 - kappa) * (1.0 - residual_sq)
326                        + 2.0 * kappa * kappa * residual_sq),
327            ],
328        ];
329        for i in 0..2 {
330            assert_close("Gaussian score", geometry.nll_score[i], expected_score[i]);
331            for j in 0..2 {
332                assert_close(
333                    "Gaussian observed Hessian",
334                    geometry.observed_hessian[[i, j]],
335                    expected_hessian[i][j],
336                );
337            }
338        }
339    }
340
341    #[test]
342    fn binomial_saved_alo_wiggle_geometry_matches_logistic_chain_closed_form() {
343        let y = 1.0;
344        let threshold = 0.6;
345        let log_sigma = -0.2;
346        let weight = 1.3;
347        let basis = [0.4];
348        let basis_d1 = [-0.15];
349        let basis_d2 = [0.07];
350        let beta = [0.25];
351        let geometry = binomial_location_scale_alo_row_geometry(BinomialLocationScaleAloRowInput {
352            y,
353            threshold_eta: threshold,
354            eta_log_sigma: log_sigma,
355            prior_weight: weight,
356            inverse_link: &InverseLink::Standard(StandardLink::Logit),
357            wiggle_basis: &basis,
358            wiggle_basis_d1: &basis_d1,
359            wiggle_basis_d2: &basis_d2,
360            wiggle_beta: &beta,
361        })
362        .expect("binomial saved row must replay");
363
364        let q0 = -threshold * (-log_sigma).exp();
365        let q = q0 + basis[0] * beta[0];
366        let probability = gam_linalg::utils::stable_logistic(q);
367        let f1 = weight * (probability - y);
368        let f2 = weight * probability * (1.0 - probability);
369        let a = 1.0 + basis_d1[0] * beta[0];
370        let b = basis_d2[0] * beta[0];
371        let q0_t = -(-log_sigma).exp();
372        let q0_s = -q0;
373        let q0_ts = (-log_sigma).exp();
374        let q0_ss = q0;
375        let dq = [a * q0_t, a * q0_s, basis[0]];
376        let d2q = [
377            [
378                b * q0_t * q0_t,
379                b * q0_t * q0_s + a * q0_ts,
380                basis_d1[0] * q0_t,
381            ],
382            [
383                b * q0_s * q0_t + a * q0_ts,
384                b * q0_s * q0_s + a * q0_ss,
385                basis_d1[0] * q0_s,
386            ],
387            [basis_d1[0] * q0_t, basis_d1[0] * q0_s, 0.0],
388        ];
389        for i in 0..3 {
390            assert_close("binomial score", geometry.nll_score[i], f1 * dq[i]);
391            for j in 0..3 {
392                assert_close(
393                    "binomial observed Hessian",
394                    geometry.observed_hessian[[i, j]],
395                    f2 * dq[i] * dq[j] + f1 * d2q[i][j],
396                );
397            }
398        }
399    }
400}