1use super::*;
2
3#[derive(Clone, Debug, PartialEq)]
12pub struct LocationScaleAloRowGeometry {
13 pub nll_score: Array1<f64>,
14 pub observed_hessian: Array2<f64>,
15}
16
17pub 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
31pub 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
87pub 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
172pub 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}