1use crate::estimate::EstimationError;
2use faer::Side;
3use gam_linalg::faer_ndarray::{
4 FaerCholesky, FaerEigh, fast_ab, fast_atb, fast_xt_diag_x, fast_xt_diag_y,
5};
6use ndarray::{
7 Array1, Array2, Array3, ArrayView1, ArrayView2, ArrayView3, ArrayViewMut1, ArrayViewMut2, Axis,
8 s,
9};
10use opt::{RidgeSchedule, escalate_ridge};
11use rayon::prelude::*;
12use std::sync::Once;
13
14static ILL_CONDITIONED_BACKWARD_WARNED: Once = Once::new();
22
23fn warn_ill_conditioned_backward_once(p: usize, d: usize, condition_number: f64) {
24 ILL_CONDITIONED_BACKWARD_WARNED.call_once(|| {
25 log::warn!(
26 "gaussian_reml_fit_backward: K = XᵀWX + λS is near-singular \
27 (p={p}, d={d}, cond≈{condition_number:.2e}); returning zero gradients \
28 for this fit (λ has saturated, atom is effectively unused). \
29 Further occurrences are silent."
30 );
31 });
32}
33
34fn zero_backward_result(n: usize, p: usize, d: usize) -> GaussianRemlBackwardResult {
35 GaussianRemlBackwardResult {
36 grad_x: Array2::<f64>::zeros((n, p)),
37 grad_y: Array2::<f64>::zeros((n, d)),
38 grad_penalty: Array2::<f64>::zeros((p, p)),
39 grad_weights: Array1::<f64>::zeros(n),
40 }
41}
42
43const RHO_LOWER: f64 = -30.0;
44const RHO_UPPER: f64 = 30.0;
45const EIGEN_REL_TOL: f64 = 1.0e-10;
46const GRAD_TOL: f64 = 1.0e-12;
47const MIN_DEVIANCE: f64 = 1.0e-300;
48const BLOCK_ORTHOGONAL_SCORE_TOL: f64 = 1.0e-7;
54const BLOCK_ORTHOGONAL_MAX_OUTER_PASSES: usize = 200;
58const BLOCK_ORTHOGONAL_BLOCK_UPDATES_PER_PASS: usize = 32;
62
63#[derive(Clone, Copy)]
64struct BlockOrthogonalControls {
65 score_tol: f64,
66 max_outer_passes: usize,
67 block_updates_per_pass: usize,
68}
69
70impl Default for BlockOrthogonalControls {
71 fn default() -> Self {
72 Self {
73 score_tol: BLOCK_ORTHOGONAL_SCORE_TOL,
74 max_outer_passes: BLOCK_ORTHOGONAL_MAX_OUTER_PASSES,
75 block_updates_per_pass: BLOCK_ORTHOGONAL_BLOCK_UPDATES_PER_PASS,
76 }
77 }
78}
79
80fn canonicalize_penalty(penalty: ArrayView2<'_, f64>) -> Array2<f64> {
91 let p = penalty.nrows();
92 let mut out = penalty.to_owned();
93 for i in 0..p {
94 for j in (i + 1)..p {
95 let avg = 0.5 * (out[[i, j]] + out[[j, i]]);
96 out[[i, j]] = avg;
97 out[[j, i]] = avg;
98 }
99 }
100 out
101}
102
103#[derive(Clone, Debug)]
104pub struct GaussianRemlEigenCache {
105 pub penalty_eigenvalues: Array1<f64>,
106 pub eigenvectors: Array2<f64>,
107 pub coefficient_basis: Array2<f64>,
108 pub xtwx_fingerprint: u64,
109 pub penalty_fingerprint: u64,
110 pub logdet_xtwx: f64,
111 pub logdet_penalty_positive: f64,
112 pub penalty_rank: usize,
113 pub nullity: usize,
114}
115
116#[derive(Clone, Debug, Default)]
117pub struct GaussianRemlWarmStart {
118 pub lambda: Option<f64>,
119 pub eigen_cache: Option<GaussianRemlEigenCache>,
120}
121
122impl GaussianRemlWarmStart {
123 pub fn from_multi_result(result: &GaussianRemlMultiResult) -> Self {
124 Self {
125 lambda: Some(result.lambda),
126 eigen_cache: Some(result.cache.clone()),
127 }
128 }
129}
130
131#[derive(Clone, Debug)]
132pub struct GaussianRemlResult {
133 pub lambda: f64,
134 pub rho: f64,
135 pub coefficients: Array1<f64>,
136 pub fitted: Array1<f64>,
137 pub reml_score: f64,
138 pub reml_grad_lambda: f64,
139 pub reml_hess_lambda: f64,
140 pub reml_grad_rho: f64,
141 pub reml_hess_rho: f64,
142 pub edf: f64,
143 pub sigma2: f64,
144 pub cache: GaussianRemlEigenCache,
145}
146
147#[derive(Clone, Debug)]
148pub struct GaussianRemlMultiResult {
149 pub lambda: f64,
150 pub rho: f64,
151 pub coefficients: Array2<f64>,
152 pub fitted: Array2<f64>,
153 pub reml_score: f64,
154 pub reml_grad_lambda: f64,
155 pub reml_hess_lambda: f64,
156 pub reml_grad_rho: f64,
157 pub reml_hess_rho: f64,
158 pub edf: f64,
159 pub sigma2: Array1<f64>,
160 pub cache: GaussianRemlEigenCache,
161}
162
163#[derive(Clone, Debug)]
164pub struct GaussianRemlFreeBScore {
165 pub reml_score: f64,
166 pub grad_coefficients: Array2<f64>,
167 pub grad_penalty: Array2<f64>,
168 pub grad_log_lambda: f64,
169 pub fitted: Array2<f64>,
170 pub sigma2: Array1<f64>,
171 pub edf: f64,
172}
173
174#[derive(Clone, Debug)]
175pub struct GaussianRemlBackwardResult {
176 pub grad_x: Array2<f64>,
177 pub grad_y: Array2<f64>,
178 pub grad_penalty: Array2<f64>,
179 pub grad_weights: Array1<f64>,
180}
181
182#[derive(Clone, Debug)]
183pub struct GaussianRemlMultiBackwardProblem<'a> {
184 pub x: ArrayView2<'a, f64>,
185 pub y: ArrayView2<'a, f64>,
186 pub weights: Option<ArrayView1<'a, f64>>,
187 pub fit: &'a GaussianRemlMultiResult,
188 pub grad_lambda: f64,
189 pub grad_coefficients: Option<ArrayView2<'a, f64>>,
190 pub grad_fitted: Option<ArrayView2<'a, f64>>,
191 pub grad_reml_score: f64,
192 pub grad_edf: f64,
193}
194
195#[derive(Clone, Debug)]
196pub struct GaussianRemlNoAllocWorkspace {
197 pub xtwy: Array2<f64>,
198 pub ywy: Array1<f64>,
199 pub projected_rhs: Array2<f64>,
200 pub projected_rhs_squared: Array2<f64>,
201 pub scaled_projected_rhs: Array2<f64>,
202}
203
204impl GaussianRemlNoAllocWorkspace {
205 pub fn new(n_coefficients: usize, n_outputs: usize) -> Self {
206 Self {
207 xtwy: Array2::zeros((n_coefficients, n_outputs)),
208 ywy: Array1::zeros(n_outputs),
209 projected_rhs: Array2::zeros((n_coefficients, n_outputs)),
210 projected_rhs_squared: Array2::zeros((n_coefficients, n_outputs)),
211 scaled_projected_rhs: Array2::zeros((n_coefficients, n_outputs)),
212 }
213 }
214
215 fn validate(&self, p: usize, d: usize) -> Result<(), EstimationError> {
216 if self.xtwy.dim() != (p, d)
217 || self.ywy.len() != d
218 || self.projected_rhs.dim() != (p, d)
219 || self.projected_rhs_squared.dim() != (p, d)
220 || self.scaled_projected_rhs.dim() != (p, d)
221 {
222 crate::bail_invalid_estim!(
223 "Gaussian REML no-alloc workspace shape mismatch: expected p={p}, d={d}"
224 );
225 }
226 Ok::<(), _>(())
227 }
228}
229
230#[derive(Clone, Copy, Debug)]
231pub struct GaussianRemlNoAllocFit {
232 pub lambda: f64,
233 pub rho: f64,
234 pub reml_score: f64,
235 pub reml_grad_lambda: f64,
236 pub reml_hess_lambda: f64,
237 pub reml_grad_rho: f64,
238 pub reml_hess_rho: f64,
239 pub edf: f64,
240}
241
242#[derive(Clone, Debug)]
243pub struct GaussianRemlMultiBatchProblem<'a> {
244 pub x: ArrayView2<'a, f64>,
245 pub y: ArrayView2<'a, f64>,
246 pub weights: Option<ArrayView1<'a, f64>>,
247 pub init_rho: Option<f64>,
248}
249
250#[derive(Clone, Debug)]
251pub struct GaussianRemlBlockOrthogonalResult {
252 pub coefficients: Vec<Array2<f64>>,
253 pub fitted: Array2<f64>,
254 pub lambdas: Array1<f64>,
255 pub log_lambdas: Array1<f64>,
256 pub reml_score: f64,
257 pub edf: Array1<f64>,
258}
259
260#[derive(Clone)]
261struct GaussianRemlPrepared {
262 cache: GaussianRemlEigenCache,
263 ywy: Array1<f64>,
264 projected_rhs_squared: Array2<f64>,
265 projected_rhs: Array2<f64>,
266 n_effective: usize,
270 n_outputs: usize,
271}
272
273#[derive(Clone, Copy)]
274struct ObjectiveEval {
275 cost: f64,
276 grad: f64,
277 hess: f64,
278 edf: f64,
279}
280
281#[derive(Clone, Copy)]
292struct TermDerivs {
293 value: f64,
294 grad: f64,
295 hess: f64,
296}
297
298#[derive(Clone, Copy)]
306struct ModalKernels {
307 log_one_plus_t: f64,
308 u: f64,
310 v: f64,
312 w: f64,
314 k: f64,
316}
317
318fn modal_kernels(rho: f64, delta: f64) -> ModalKernels {
319 if delta == 0.0 {
320 return ModalKernels {
321 log_one_plus_t: 0.0,
322 u: 0.0,
323 v: 1.0,
324 w: 0.0,
325 k: 0.0,
326 };
327 }
328 let log_t = rho + delta.ln();
329 let (log_one_plus_t, u, v) = if log_t >= 0.0 {
330 let reciprocal_t = (-log_t).exp();
331 let v = reciprocal_t / (1.0 + reciprocal_t);
332 (log_t + reciprocal_t.ln_1p(), 1.0 - v, v)
333 } else {
334 let t = log_t.exp();
335 let u = t / (1.0 + t);
336 (t.ln_1p(), u, 1.0 - u)
337 };
338 let w = u * v;
339 ModalKernels {
340 log_one_plus_t,
341 u,
342 v,
343 w,
344 k: w * (v - u),
345 }
346}
347
348impl std::ops::AddAssign<TermDerivs> for ObjectiveEval {
349 fn add_assign(&mut self, rhs: TermDerivs) {
352 self.cost += rhs.value;
353 self.grad += rhs.grad;
354 self.hess += rhs.hess;
355 }
356}
357
358fn gaussian_reml_logdet_term(
364 cache: &GaussianRemlEigenCache,
365 rho: f64,
366 n_outputs: f64,
367) -> (TermDerivs, f64) {
368 let mut logdet_h = cache.logdet_xtwx;
369 let mut trace_h = 0.0;
370 let mut trace_h_deriv = 0.0;
371 let mut edf = 0.0;
372 for &delta in &cache.penalty_eigenvalues {
373 let mode = modal_kernels(rho, delta);
374 logdet_h += mode.log_one_plus_t;
375 if delta > 0.0 {
376 trace_h += mode.u;
377 trace_h_deriv += mode.w;
378 }
379 edf += mode.v;
380 }
381 let logdet_s = cache.logdet_penalty_positive + (cache.penalty_rank as f64) * rho;
382 let term = TermDerivs {
383 value: 0.5 * n_outputs * (logdet_h - logdet_s),
384 grad: 0.5 * n_outputs * (trace_h - cache.penalty_rank as f64),
385 hess: 0.5 * n_outputs * trace_h_deriv,
386 };
387 (term, edf)
388}
389
390fn gaussian_reml_dispersion_term(
397 cache: &GaussianRemlEigenCache,
398 ywy: ArrayView1<'_, f64>,
399 projected_rhs_squared: ArrayView2<'_, f64>,
400 output: usize,
401 nu: f64,
402 rho: f64,
403) -> TermDerivs {
404 let mut fitted_quadratic = 0.0;
405 let mut dp_grad = 0.0;
406 let mut dp_hess = 0.0;
407 for eig in 0..cache.penalty_eigenvalues.len() {
408 let c2 = projected_rhs_squared[[eig, output]];
409 let mode = modal_kernels(rho, cache.penalty_eigenvalues[eig]);
410 fitted_quadratic += c2 * mode.v;
411 dp_grad += c2 * mode.w;
412 dp_hess += c2 * mode.k;
413 }
414 let dp = ywy[output] - fitted_quadratic;
415 TermDerivs {
416 value: 0.5 * nu * (1.0 + (2.0 * std::f64::consts::PI * dp / nu).ln()),
417 grad: 0.5 * nu * dp_grad / dp,
418 hess: 0.5 * nu * (dp_hess / dp - (dp_grad * dp_grad) / (dp * dp)),
419 }
420}
421
422pub fn gaussian_reml_closed_form(
423 x: ArrayView2<'_, f64>,
424 y: ArrayView1<'_, f64>,
425 penalty: ArrayView2<'_, f64>,
426 weights: Option<ArrayView1<'_, f64>>,
427 init_rho: Option<f64>,
428) -> Result<GaussianRemlResult, EstimationError> {
429 gaussian_reml_closed_form_with_nullspace_dim(x, y, penalty, None, weights, init_rho)
430}
431
432pub fn gaussian_reml_closed_form_with_nullspace_dim(
433 x: ArrayView2<'_, f64>,
434 y: ArrayView1<'_, f64>,
435 penalty: ArrayView2<'_, f64>,
436 nullspace_dim: Option<usize>,
437 weights: Option<ArrayView1<'_, f64>>,
438 init_rho: Option<f64>,
439) -> Result<GaussianRemlResult, EstimationError> {
440 let y2 = y.insert_axis(Axis(1));
441 let result = gaussian_reml_multi_closed_form_with_nullspace_dim(
442 x,
443 y2,
444 penalty,
445 nullspace_dim,
446 weights,
447 init_rho,
448 )?;
449 scalar_result_from_multi(result)
450}
451
452fn scalar_result_from_multi(
453 result: GaussianRemlMultiResult,
454) -> Result<GaussianRemlResult, EstimationError> {
455 Ok(GaussianRemlResult {
456 lambda: result.lambda,
457 rho: result.rho,
458 coefficients: result.coefficients.column(0).to_owned(),
459 fitted: result.fitted.column(0).to_owned(),
460 reml_score: result.reml_score,
461 reml_grad_lambda: result.reml_grad_lambda,
462 reml_hess_lambda: result.reml_hess_lambda,
463 reml_grad_rho: result.reml_grad_rho,
464 reml_hess_rho: result.reml_hess_rho,
465 edf: result.edf,
466 sigma2: result.sigma2[0],
467 cache: result.cache,
468 })
469}
470
471#[derive(Clone, Debug)]
477pub struct GaussianRemlPointEval {
478 pub rho: f64,
479 pub lambda: f64,
480 pub reml_score: f64,
481 pub edf: f64,
482 pub sigma2: f64,
483 pub coefficients: Array1<f64>,
484}
485
486pub fn gaussian_reml_point_eval_at_rho(
491 x: ArrayView2<'_, f64>,
492 y: ArrayView1<'_, f64>,
493 penalty: ArrayView2<'_, f64>,
494 nullspace_dim: Option<usize>,
495 weights: Option<ArrayView1<'_, f64>>,
496 rho: f64,
497) -> Result<GaussianRemlPointEval, EstimationError> {
498 let lambda = gam_problem::checked_exp_log_strength(rho)
499 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
500 let y2 = y.insert_axis(Axis(1));
501 let prepared = prepare_gaussian_reml(x, y2.view(), penalty, nullspace_dim, weights, None)?;
502 validate_reml_profile_residuals(
503 &prepared.cache,
504 prepared.ywy.view(),
505 prepared.projected_rhs_squared.view(),
506 rho,
507 )?;
508 let eval = prepared.evaluate(rho);
509 let coefficients = prepared.coefficients(lambda).column(0).to_owned();
510 let sigma2 = prepared.sigma2(lambda)[0];
511 Ok(GaussianRemlPointEval {
512 rho,
513 lambda,
514 reml_score: eval.cost,
515 edf: eval.edf,
516 sigma2,
517 coefficients,
518 })
519}
520
521#[derive(Clone, Debug)]
533pub struct GaussianRemlStationarySet {
534 pub roots: Vec<f64>,
535 pub root_brackets: Vec<[f64; 2]>,
536 pub root_gradients: Vec<f64>,
537 pub selected_rho: f64,
538 pub selected_projected_gradient_residual: f64,
539 pub endpoint_costs: [f64; 2],
540 pub rho_window: [f64; 2],
541 pub root_location_resolution: f64,
542}
543
544pub fn gaussian_reml_stationary_set(
550 x: ArrayView2<'_, f64>,
551 y: ArrayView1<'_, f64>,
552 penalty: ArrayView2<'_, f64>,
553 nullspace_dim: Option<usize>,
554 weights: Option<ArrayView1<'_, f64>>,
555 init_rho: Option<f64>,
556) -> Result<GaussianRemlStationarySet, EstimationError> {
557 if init_rho.is_some_and(|rho| !rho.is_finite()) {
558 crate::bail_invalid_estim!("Gaussian REML stationary search requires a finite rho hint");
559 }
560 let y2 = y.insert_axis(Axis(1));
561 let prepared = prepare_gaussian_reml(x, y2.view(), penalty, nullspace_dim, weights, None)?;
562 let endpoint_costs = [
563 prepared.evaluate(RHO_LOWER).cost,
564 prepared.evaluate(RHO_UPPER).cost,
565 ];
566 validate_reml_profile_residuals(
567 &prepared.cache,
568 prepared.ywy.view(),
569 prepared.projected_rhs_squared.view(),
570 RHO_LOWER,
571 )?;
572 if prepared.cache.penalty_rank == 0 {
573 return Ok(GaussianRemlStationarySet {
574 roots: Vec::new(),
575 root_brackets: Vec::new(),
576 root_gradients: Vec::new(),
577 selected_rho: init_rho.unwrap_or(0.0).clamp(RHO_LOWER, RHO_UPPER),
578 selected_projected_gradient_residual: 0.0,
579 endpoint_costs,
580 rho_window: [RHO_LOWER, RHO_UPPER],
581 root_location_resolution: RHO_BRACKET_RESOLUTION,
582 });
583 }
584 let eval = |rho: f64| prepared.evaluate(rho);
585 let enclose = |a: f64, b: f64| {
586 reml_deriv_enclosure(
587 &prepared.cache,
588 prepared.ywy.view(),
589 prepared.projected_rhs_squared.view(),
590 prepared.n_effective,
591 prepared.n_outputs,
592 a,
593 b,
594 )
595 };
596 let mut roots = Vec::new();
597 let mut root_brackets = Vec::new();
598 let mut root_gradients = Vec::new();
599 let selection = enumerate_and_select_rho(&eval, &enclose, init_rho, |root, e| {
600 roots.push(root.rho);
601 root_brackets.push(root.bracket);
602 root_gradients.push(e.grad);
603 })?;
604 Ok(GaussianRemlStationarySet {
605 roots,
606 root_brackets,
607 root_gradients,
608 selected_rho: selection.rho,
609 selected_projected_gradient_residual: selection.projected_gradient_residual,
610 endpoint_costs,
611 rho_window: [RHO_LOWER, RHO_UPPER],
612 root_location_resolution: RHO_BRACKET_RESOLUTION,
613 })
614}
615
616pub fn gaussian_reml_multi_closed_form(
617 x: ArrayView2<'_, f64>,
618 y: ArrayView2<'_, f64>,
619 penalty: ArrayView2<'_, f64>,
620 weights: Option<ArrayView1<'_, f64>>,
621 init_rho: Option<f64>,
622) -> Result<GaussianRemlMultiResult, EstimationError> {
623 gaussian_reml_multi_closed_form_with_nullspace_dim(x, y, penalty, None, weights, init_rho)
624}
625
626pub fn gaussian_reml_multi_shared_dispersion_closed_form(
644 x: ArrayView2<'_, f64>,
645 y: ArrayView2<'_, f64>,
646 penalty: ArrayView2<'_, f64>,
647 weights: Option<ArrayView1<'_, f64>>,
648 init_rho: Option<f64>,
649) -> Result<GaussianRemlMultiResult, EstimationError> {
650 if y.ncols() == 0 {
651 crate::bail_invalid_estim!(
652 "shared-dispersion Gaussian REML requires at least one response column"
653 );
654 }
655 let prepared = prepare_gaussian_reml(x, y, penalty, None, weights, None)?;
656 let init_rho = init_rho
657 .map(f64::exp)
658 .map(validate_initial_lambda)
659 .transpose()?
660 .map(f64::ln);
661 let d = prepared.n_outputs;
662 let mut pooled_ywy = Array1::<f64>::zeros(1);
663 pooled_ywy[0] = prepared.ywy.iter().copied().sum();
664 let mut pooled_projected_rhs_squared =
665 Array2::<f64>::zeros((prepared.cache.penalty_eigenvalues.len(), 1));
666 for eig in 0..prepared.cache.penalty_eigenvalues.len() {
667 pooled_projected_rhs_squared[[eig, 0]] = prepared
668 .projected_rhs_squared
669 .row(eig)
670 .iter()
671 .copied()
672 .sum();
673 }
674 let per_output_nu = prepared.n_effective as f64 - prepared.cache.nullity as f64;
675 let shared_nu = (d as f64) * per_output_nu;
676 validate_reml_profile_residuals(
677 &prepared.cache,
678 pooled_ywy.view(),
679 pooled_projected_rhs_squared.view(),
680 RHO_LOWER,
681 )?;
682 let eval = |rho: f64| {
683 evaluate_reml_profile(
684 &prepared.cache,
685 pooled_ywy.view(),
686 pooled_projected_rhs_squared.view(),
687 d,
688 shared_nu,
689 rho,
690 )
691 };
692 let rho = if prepared.cache.penalty_rank == 0 {
693 init_rho.unwrap_or(0.0).clamp(RHO_LOWER, RHO_UPPER)
694 } else {
695 let enclose = |a: f64, b: f64| {
696 reml_deriv_enclosure_profile(
697 &prepared.cache,
698 pooled_ywy.view(),
699 pooled_projected_rhs_squared.view(),
700 d,
701 shared_nu,
702 a,
703 b,
704 )
705 };
706 enumerate_and_select_rho(eval, enclose, init_rho, |_r, _e| {})?.rho
707 };
708 let objective = eval(rho);
709 let lambda = gam_problem::checked_exp_log_strength(rho)
710 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
711 let coefficients = prepared.coefficients(lambda);
712 let fitted = dense_ab(x, coefficients.view());
713 let mut fitted_quadratic = 0.0_f64;
714 for eig in 0..prepared.cache.penalty_eigenvalues.len() {
715 let denom = 1.0 + lambda * prepared.cache.penalty_eigenvalues[eig];
716 fitted_quadratic += pooled_projected_rhs_squared[[eig, 0]] / denom;
717 }
718 let shared_sigma2 = (pooled_ywy[0] - fitted_quadratic) / shared_nu;
719 let (reml_grad_lambda, reml_hess_lambda) =
720 rho_derivatives_to_lambda(lambda, objective.grad, objective.hess);
721 Ok(GaussianRemlMultiResult {
722 lambda,
723 rho,
724 coefficients,
725 fitted,
726 reml_score: objective.cost,
727 reml_grad_lambda,
728 reml_hess_lambda,
729 reml_grad_rho: objective.grad,
730 reml_hess_rho: objective.hess,
731 edf: objective.edf,
732 sigma2: Array1::from_elem(d, shared_sigma2),
733 cache: prepared.cache,
734 })
735}
736
737pub fn gaussian_reml_multi_closed_form_with_nullspace_dim(
738 x: ArrayView2<'_, f64>,
739 y: ArrayView2<'_, f64>,
740 penalty: ArrayView2<'_, f64>,
741 nullspace_dim: Option<usize>,
742 weights: Option<ArrayView1<'_, f64>>,
743 init_rho: Option<f64>,
744) -> Result<GaussianRemlMultiResult, EstimationError> {
745 let init_lambda = init_rho.map(f64::exp);
746 gaussian_reml_multi_closed_form_from_parts(
747 x,
748 y,
749 penalty,
750 nullspace_dim,
751 weights,
752 init_lambda,
753 None,
754 )
755}
756
757pub fn gaussian_reml_multi_closed_form_warm_started(
758 x: ArrayView2<'_, f64>,
759 y: ArrayView2<'_, f64>,
760 penalty: ArrayView2<'_, f64>,
761 weights: Option<ArrayView1<'_, f64>>,
762 warm_start: Option<&GaussianRemlWarmStart>,
763) -> Result<GaussianRemlMultiResult, EstimationError> {
764 gaussian_reml_multi_closed_form_warm_started_with_nullspace_dim(
765 x, y, penalty, None, weights, warm_start,
766 )
767}
768
769pub fn gaussian_reml_multi_closed_form_warm_started_with_nullspace_dim(
770 x: ArrayView2<'_, f64>,
771 y: ArrayView2<'_, f64>,
772 penalty: ArrayView2<'_, f64>,
773 nullspace_dim: Option<usize>,
774 weights: Option<ArrayView1<'_, f64>>,
775 warm_start: Option<&GaussianRemlWarmStart>,
776) -> Result<GaussianRemlMultiResult, EstimationError> {
777 let init_lambda = warm_start.and_then(|start| start.lambda);
778 let eigen_cache = warm_start.and_then(|start| start.eigen_cache.as_ref());
779 gaussian_reml_multi_closed_form_from_parts(
780 x,
781 y,
782 penalty,
783 nullspace_dim,
784 weights,
785 init_lambda,
786 eigen_cache,
787 )
788}
789
790pub fn gaussian_reml_multi_closed_form_with_cache(
791 x: ArrayView2<'_, f64>,
792 y: ArrayView2<'_, f64>,
793 penalty: ArrayView2<'_, f64>,
794 weights: Option<ArrayView1<'_, f64>>,
795 init_lambda: Option<f64>,
796 eigen_cache: Option<&GaussianRemlEigenCache>,
797) -> Result<GaussianRemlMultiResult, EstimationError> {
798 gaussian_reml_multi_closed_form_from_parts(
799 x,
800 y,
801 penalty,
802 None,
803 weights,
804 init_lambda,
805 eigen_cache,
806 )
807}
808
809pub fn gaussian_reml_multi_closed_form_with_cache_no_alloc(
810 x: ArrayView2<'_, f64>,
811 y: ArrayView2<'_, f64>,
812 penalty: ArrayView2<'_, f64>,
813 weights: Option<ArrayView1<'_, f64>>,
814 init_lambda: Option<f64>,
815 eigen_cache: &GaussianRemlEigenCache,
816 workspace: &mut GaussianRemlNoAllocWorkspace,
817 mut coefficients: ArrayViewMut2<'_, f64>,
818 mut fitted: ArrayViewMut2<'_, f64>,
819 mut sigma2: ArrayViewMut1<'_, f64>,
820) -> Result<GaussianRemlNoAllocFit, EstimationError> {
821 let penalty_owned = canonicalize_penalty(penalty);
825 let penalty = penalty_owned.view();
826 let n = x.nrows();
827 let p = x.ncols();
828 let d = y.ncols();
829 validate_gaussian_reml_design(x, penalty, weights)?;
830 validate_gaussian_reml_eigen_cache(eigen_cache, p)?;
831 if y.nrows() != n {
832 crate::bail_invalid_estim!(
833 "Gaussian REML row mismatch: X has {n} rows but Y has {}",
834 y.nrows()
835 );
836 }
837 if y.iter().any(|value| !value.is_finite()) {
838 crate::bail_invalid_estim!("Gaussian REML inputs must be finite");
839 }
840 let n_effective = match weights {
841 Some(w) => effective_observation_count(w),
842 None => n,
843 };
844 if n_effective <= eigen_cache.nullity {
845 crate::bail_invalid_estim!(
846 "Gaussian REML requires more positive-weight rows than the nullspace dimension; got n_effective={n_effective}, nullity={}",
847 eigen_cache.nullity
848 );
849 }
850 let penalty_fingerprint = matrix_fingerprint(penalty);
851 if eigen_cache.penalty_fingerprint != penalty_fingerprint {
852 crate::bail_invalid_estim!("Gaussian REML eigen cache penalty mismatch");
853 }
854 workspace.validate(p, d)?;
855 if coefficients.dim() != (p, d) || fitted.dim() != (n, d) || sigma2.len() != d {
856 crate::bail_invalid_estim!(
857 "Gaussian REML no-alloc output shape mismatch: expected coefficients=({p},{d}), fitted=({n},{d}), sigma2={d}"
858 );
859 }
860 if let Some(lambda) = init_lambda {
861 validate_initial_lambda(lambda)?;
862 }
863
864 fill_weighted_rhs_no_alloc(x, y, weights, workspace)?;
865 project_rhs_no_alloc(eigen_cache, workspace);
866
867 let init_rho = init_lambda.map(f64::ln);
868 let rho = optimize_rho_no_alloc(
869 eigen_cache,
870 workspace.ywy.view(),
871 workspace.projected_rhs_squared.view(),
872 n_effective,
873 d,
874 init_rho,
875 )?;
876 let eval = evaluate_reml_parts(
877 eigen_cache,
878 workspace.ywy.view(),
879 workspace.projected_rhs_squared.view(),
880 n_effective,
881 d,
882 rho,
883 );
884 let lambda = gam_problem::checked_exp_log_strength(rho)
885 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
886 fill_coefficients_no_alloc(eigen_cache, workspace, lambda, coefficients.view_mut());
887 fill_fitted_no_alloc(x, coefficients.view(), fitted.view_mut());
888 fill_sigma2_no_alloc(
889 eigen_cache,
890 workspace.ywy.view(),
891 workspace.projected_rhs_squared.view(),
892 n_effective,
893 d,
894 lambda,
895 sigma2.view_mut(),
896 );
897 let (reml_grad_lambda, reml_hess_lambda) =
898 rho_derivatives_to_lambda(lambda, eval.grad, eval.hess);
899 Ok(GaussianRemlNoAllocFit {
900 lambda,
901 rho,
902 reml_score: eval.cost,
903 reml_grad_lambda,
904 reml_hess_lambda,
905 reml_grad_rho: eval.grad,
906 reml_hess_rho: eval.hess,
907 edf: eval.edf,
908 })
909}
910
911pub fn gaussian_reml_multi_closed_form_batch<'a>(
912 problems: &[GaussianRemlMultiBatchProblem<'a>],
913 penalty: ArrayView2<'a, f64>,
914 nullspace_dim: Option<usize>,
915) -> Result<Vec<GaussianRemlMultiResult>, EstimationError> {
916 if problems.is_empty() {
917 return Ok(Vec::new());
918 }
919 let xtwx_per_problem: Vec<Array2<f64>> = problems
923 .par_iter()
924 .map(|problem| {
925 let weight = match problem.weights.as_ref() {
926 Some(w) => w.to_owned(),
927 None => Array1::ones(problem.x.nrows()),
928 };
929 dense_xt_diag_x(problem.x.view(), weight.view())
930 })
931 .collect();
932 let caches =
936 build_gaussian_reml_eigen_cache_batched(xtwx_per_problem, penalty.view(), nullspace_dim);
937 let fits: Vec<Result<GaussianRemlMultiResult, EstimationError>> = problems
941 .par_iter()
942 .zip(caches.into_par_iter())
943 .map(|(problem, cache_result)| {
944 let init_lambda = problem.init_rho.map(f64::exp);
945 let cache = cache_result?;
946 gaussian_reml_multi_closed_form_from_parts(
947 problem.x.view(),
948 problem.y.view(),
949 penalty.view(),
950 nullspace_dim,
951 problem.weights.as_ref().map(|weights| weights.view()),
952 init_lambda,
953 Some(&cache),
954 )
955 })
956 .collect();
957 fits.into_iter().collect()
958}
959
960struct BlockOrthogonalEval {
961 beta: Array2<f64>,
962 logdet: f64,
963 trace: f64,
964 trace_pair: f64,
965 fitted_energy: Array1<f64>,
966 penalty_energy: Array1<f64>,
967 curvature_energy: Array1<f64>,
968 edf: f64,
969}
970
971fn block_penalty_rank_logdet(
972 penalty: ArrayView2<'_, f64>,
973) -> Result<(usize, f64), EstimationError> {
974 let eigs = penalty
975 .to_owned()
976 .eigh(Side::Lower)
977 .map_err(|_| EstimationError::ModelIsIllConditioned {
978 condition_number: f64::INFINITY,
979 })?
980 .0;
981 let max_abs = eigs.iter().fold(0.0_f64, |m, &v| m.max(v.abs()));
982 let tol = (EIGEN_REL_TOL * max_abs).max(1.0e-14);
983 let mut rank = 0_usize;
984 let mut logdet = 0.0;
985 for eig in eigs.iter().copied() {
986 if eig > tol {
987 rank += 1;
988 logdet += eig.ln();
989 }
990 }
991 Ok((rank, logdet))
992}
993
994fn block_orthogonal_eval(
995 gram: &Array2<f64>,
996 rhs: &Array2<f64>,
997 penalty: &Array2<f64>,
998 rho: f64,
999) -> Result<BlockOrthogonalEval, EstimationError> {
1000 let lambda = gam_problem::checked_exp_log_strength(rho)
1001 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
1002 validate_initial_lambda(lambda)?;
1003 let scaled_penalty = penalty * lambda;
1004 let hessian = canonicalize_penalty((gram + &scaled_penalty).view());
1005 let chol = gaussian_reml_cholesky_lower(hessian)?;
1006 let beta = solve_spd_from_lower_factor(&chol, rhs)?;
1007 let solved_penalty = solve_spd_from_lower_factor(&chol, &scaled_penalty)?;
1008 let logdet = 2.0 * chol.diag().iter().map(|value| value.ln()).sum::<f64>();
1009 let trace = (0..solved_penalty.nrows())
1010 .map(|i| solved_penalty[[i, i]])
1011 .sum::<f64>();
1012 let trace_pair =
1013 gam_linalg::utils::trace_of_product(solved_penalty.view(), solved_penalty.view());
1014 let fitted_energy = (rhs * &beta).sum_axis(Axis(0));
1015 let p_beta = scaled_penalty.dot(&beta);
1016 let penalty_energy = (&beta * &p_beta).sum_axis(Axis(0));
1017 let solved_p_beta = solve_spd_from_lower_factor(&chol, &p_beta)?;
1018 let curvature_energy = (&p_beta * &solved_p_beta).sum_axis(Axis(0));
1019 Ok(BlockOrthogonalEval {
1020 beta,
1021 logdet,
1022 trace,
1023 trace_pair,
1024 fitted_energy,
1025 penalty_energy,
1026 curvature_energy,
1027 edf: penalty.nrows() as f64 - trace,
1028 })
1029}
1030
1031struct BlockOrthogonalScaleDerivs {
1042 value: f64,
1043 value_roundoff: f64,
1054 grad: f64,
1055 hess: f64,
1056}
1057
1058fn block_orthogonal_scale_objective(
1059 eval: &BlockOrthogonalEval,
1060 rho: f64,
1061 scale_precision: ArrayView1<'_, f64>,
1062 rank: usize,
1063) -> BlockOrthogonalScaleDerivs {
1064 let d = scale_precision.len() as f64;
1065 let fit_term = scale_precision
1066 .iter()
1067 .zip(eval.fitted_energy.iter())
1068 .map(|(scale, energy)| scale * energy)
1069 .sum::<f64>();
1070 let logdet_term = 0.5 * d * eval.logdet;
1072 let rank_term = 0.5 * d * (rank as f64) * rho;
1073 let value = logdet_term - 0.5 * fit_term - rank_term;
1074 let value_roundoff =
1078 f64::EPSILON * (logdet_term.abs() + 0.5 * fit_term.abs() + rank_term.abs());
1079 let grad = 0.5 * d * (eval.trace - rank as f64)
1083 + 0.5
1084 * scale_precision
1085 .iter()
1086 .zip(eval.penalty_energy.iter())
1087 .map(|(scale, energy)| scale * energy)
1088 .sum::<f64>();
1089 let hess = 0.5 * d * (eval.trace - eval.trace_pair)
1092 + 0.5
1093 * scale_precision
1094 .iter()
1095 .zip(eval.penalty_energy.iter().zip(eval.curvature_energy.iter()))
1096 .map(|(scale, (energy, curvature))| scale * (energy - 2.0 * curvature))
1097 .sum::<f64>();
1098 BlockOrthogonalScaleDerivs {
1099 value,
1100 value_roundoff,
1101 grad,
1102 hess,
1103 }
1104}
1105
1106fn solve_block_orthogonal_rho(
1113 gram: &Array2<f64>,
1114 rhs: &Array2<f64>,
1115 penalty: &Array2<f64>,
1116 rho0: f64,
1117 scale_precision: ArrayView1<'_, f64>,
1118 rank: usize,
1119 max_iter: usize,
1120) -> Result<(f64, BlockOrthogonalEval), EstimationError> {
1121 let mut rho = rho0;
1122 let mut current = block_orthogonal_eval(gram, rhs, penalty, rho)?;
1123 for _ in 0..max_iter {
1124 let derivs = block_orthogonal_scale_objective(¤t, rho, scale_precision, rank);
1127 let grad = derivs.grad;
1128 let hess = derivs.hess;
1129 if !(grad.is_finite() && hess.is_finite()) {
1130 return Err(EstimationError::ModelIsIllConditioned {
1131 condition_number: f64::INFINITY,
1132 });
1133 }
1134 if grad == 0.0 {
1135 break;
1136 }
1137 let direction = if hess > 0.0 { -grad / hess } else { -grad };
1143 if !direction.is_finite() || grad * direction >= 0.0 {
1144 return Err(EstimationError::ModelIsIllConditioned {
1145 condition_number: f64::INFINITY,
1146 });
1147 }
1148 let current_value = derivs.value;
1149 let model_decrease = -grad * direction - 0.5 * hess * direction * direction;
1159 let value_decides = model_decrease.is_finite() && model_decrease > derivs.value_roundoff;
1160 let accepted = if value_decides {
1161 let mut step_scale = 1.0_f64;
1162 loop {
1163 let candidate_rho = rho + step_scale * direction;
1164 if candidate_rho == rho {
1165 break None;
1166 }
1167 if let Ok(candidate_eval) = block_orthogonal_eval(gram, rhs, penalty, candidate_rho)
1168 {
1169 let candidate_value = block_orthogonal_scale_objective(
1170 &candidate_eval,
1171 candidate_rho,
1172 scale_precision,
1173 rank,
1174 )
1175 .value;
1176 if candidate_value.is_finite() && candidate_value < current_value {
1177 break Some((candidate_rho, candidate_eval));
1178 }
1179 }
1180 step_scale *= 0.5;
1184 }
1185 } else {
1186 None
1187 };
1188 let accepted = accepted.or_else(|| {
1198 if hess <= 0.0 {
1199 return None;
1200 }
1201 let mut step_scale = 1.0_f64;
1202 loop {
1203 let candidate_rho = rho + step_scale * direction;
1204 if candidate_rho == rho {
1205 break None;
1206 }
1207 if let Ok(candidate_eval) = block_orthogonal_eval(gram, rhs, penalty, candidate_rho)
1208 {
1209 let candidate = block_orthogonal_scale_objective(
1210 &candidate_eval,
1211 candidate_rho,
1212 scale_precision,
1213 rank,
1214 );
1215 if candidate.grad.is_finite() && candidate.grad.abs() < grad.abs() {
1216 break Some((candidate_rho, candidate_eval));
1217 }
1218 }
1219 step_scale *= 0.5;
1220 }
1221 });
1222 let Some((next_rho, next_eval)) = accepted else {
1223 break;
1224 };
1225 rho = next_rho;
1226 current = next_eval;
1227 }
1228 Ok((rho, current))
1229}
1230
1231fn block_orthogonal_conditional_scale(
1232 evals: &[BlockOrthogonalEval],
1233 ywy: ArrayView1<'_, f64>,
1234 nu: f64,
1235) -> Result<Array1<f64>, EstimationError> {
1236 let mut explained = Array1::<f64>::zeros(ywy.len());
1237 for eval in evals {
1238 explained += &eval.fitted_energy;
1239 }
1240 let q = &ywy - &explained;
1241 if q.iter().any(|value| !value.is_finite() || *value <= 0.0) {
1242 return Err(EstimationError::ModelIsIllConditioned {
1243 condition_number: f64::INFINITY,
1244 });
1245 }
1246 let scale = q.mapv(|value| nu / value);
1247 if scale
1248 .iter()
1249 .any(|value| !value.is_finite() || *value <= 0.0)
1250 {
1251 return Err(EstimationError::ModelIsIllConditioned {
1252 condition_number: f64::INFINITY,
1253 });
1254 }
1255 Ok(scale)
1256}
1257
1258fn validate_weighted_block_orthogonality(
1264 designs: &[Array2<f64>],
1265 weight: ArrayView1<'_, f64>,
1266) -> Result<(), EstimationError> {
1267 let unit_roundoff = 0.5 * f64::EPSILON;
1268 let operation_count = weight.len().saturating_mul(4);
1269 let accumulated = operation_count as f64 * unit_roundoff;
1270 if accumulated >= 1.0 {
1271 crate::bail_invalid_estim!(
1272 "block-orthogonality verification has no finite floating-point error bound for {} rows",
1273 weight.len()
1274 );
1275 }
1276 let gamma = accumulated / (1.0 - accumulated);
1277 for left_block in 0..designs.len() {
1278 for right_block in (left_block + 1)..designs.len() {
1279 let left = &designs[left_block];
1280 let right = &designs[right_block];
1281 for left_col in 0..left.ncols() {
1282 for right_col in 0..right.ncols() {
1283 let mut cross_product = 0.0_f64;
1284 let mut magnitude_sum = 0.0_f64;
1285 for row in 0..weight.len() {
1286 let term = weight[row] * left[[row, left_col]] * right[[row, right_col]];
1287 cross_product += term;
1288 magnitude_sum += term.abs();
1289 }
1290 let roundoff = gamma * magnitude_sum;
1291 if !cross_product.is_finite()
1292 || !roundoff.is_finite()
1293 || cross_product.abs() > roundoff
1294 {
1295 crate::bail_invalid_estim!(
1296 "block-orthogonal Gaussian REML requires X[{left_block}]' W X[{right_block}] = 0, but columns ({left_col}, {right_col}) have weighted cross-product {cross_product:.6e} beyond the arithmetic bound {roundoff:.3e}"
1297 );
1298 }
1299 }
1300 }
1301 }
1302 }
1303 Ok(())
1304}
1305
1306#[derive(Clone, Copy, Debug)]
1307struct BlockOrthogonalProfileCurvature {
1308 min_eigenvalue: f64,
1309 roundoff: f64,
1310}
1311
1312fn block_orthogonal_profile_hessian(
1322 evals: &[BlockOrthogonalEval],
1323 rhos: ArrayView1<'_, f64>,
1324 scale_precision: ArrayView1<'_, f64>,
1325 ranks: &[usize],
1326 nu: f64,
1327) -> Result<Array2<f64>, EstimationError> {
1328 let blocks = evals.len();
1329 let mut hessian = Array2::<f64>::zeros((blocks, blocks));
1330 for block in 0..blocks {
1331 hessian[[block, block]] = block_orthogonal_scale_objective(
1332 &evals[block],
1333 rhos[block],
1334 scale_precision.view(),
1335 ranks[block],
1336 )
1337 .hess;
1338 }
1339 for left in 0..blocks {
1340 for right in 0..=left {
1341 let correction = evals[left]
1342 .penalty_energy
1343 .iter()
1344 .zip(evals[right].penalty_energy.iter())
1345 .zip(scale_precision.iter())
1346 .map(|((&left_energy, &right_energy), &scale)| {
1347 0.5 * scale * scale * left_energy * right_energy / nu
1348 })
1349 .sum::<f64>();
1350 hessian[[left, right]] -= correction;
1351 if left != right {
1352 hessian[[right, left]] -= correction;
1353 }
1354 }
1355 }
1356 if hessian.iter().any(|value| !value.is_finite()) {
1357 return Err(EstimationError::ModelIsIllConditioned {
1358 condition_number: f64::INFINITY,
1359 });
1360 }
1361 Ok(hessian)
1362}
1363
1364struct BlockOrthogonalProfileSpectrum {
1372 curvature: BlockOrthogonalProfileCurvature,
1373 eigenvalues: Array1<f64>,
1374 eigenvectors: Array2<f64>,
1375}
1376
1377fn block_orthogonal_profile_spectrum(
1378 hessian: &Array2<f64>,
1379) -> Result<BlockOrthogonalProfileSpectrum, EstimationError> {
1380 let blocks = hessian.nrows();
1381 let (eigenvalues, eigenvectors) =
1382 hessian
1383 .clone()
1384 .eigh(Side::Lower)
1385 .map_err(|_| EstimationError::ModelIsIllConditioned {
1386 condition_number: f64::INFINITY,
1387 })?;
1388 let min_eigenvalue = eigenvalues.iter().copied().fold(f64::INFINITY, f64::min);
1389 let spectral_scale = eigenvalues
1390 .iter()
1391 .copied()
1392 .map(f64::abs)
1393 .fold(0.0_f64, f64::max);
1394 let roundoff = f64::EPSILON * blocks.max(1) as f64 * spectral_scale.max(f64::MIN_POSITIVE);
1395 Ok(BlockOrthogonalProfileSpectrum {
1396 curvature: BlockOrthogonalProfileCurvature {
1397 min_eigenvalue,
1398 roundoff,
1399 },
1400 eigenvalues,
1401 eigenvectors,
1402 })
1403}
1404
1405impl BlockOrthogonalProfileSpectrum {
1406 fn newton_direction(&self, gradient: ArrayView1<'_, f64>) -> Option<Array1<f64>> {
1410 if self.curvature.min_eigenvalue.is_nan() || self.curvature.min_eigenvalue <= 0.0 {
1411 return None;
1412 }
1413 let projected = self.eigenvectors.t().dot(&gradient);
1414 let scaled = Array1::from_iter(
1415 projected
1416 .iter()
1417 .zip(self.eigenvalues.iter())
1418 .map(|(component, eigenvalue)| -component / eigenvalue),
1419 );
1420 let direction = self.eigenvectors.dot(&scaled);
1421 direction
1422 .iter()
1423 .all(|value| value.is_finite())
1424 .then_some(direction)
1425 }
1426}
1427
1428struct BlockOrthogonalProfileValue {
1439 value: f64,
1440 roundoff: f64,
1441}
1442
1443fn block_orthogonal_profile_value(
1444 evals: &[BlockOrthogonalEval],
1445 rhos: ArrayView1<'_, f64>,
1446 ranks: &[usize],
1447 ywy: ArrayView1<'_, f64>,
1448 nu: f64,
1449 d: usize,
1450) -> Option<BlockOrthogonalProfileValue> {
1451 let mut explained = Array1::<f64>::zeros(ywy.len());
1452 for eval in evals {
1453 explained += &eval.fitted_energy;
1454 }
1455 let mut q = ywy.to_owned();
1456 q -= &explained;
1457 if q.iter().any(|value| !value.is_finite() || *value <= 0.0) {
1458 return None;
1459 }
1460 let determinant_term = 0.5
1461 * d as f64
1462 * evals
1463 .iter()
1464 .enumerate()
1465 .map(|(block, eval)| eval.logdet - ranks[block] as f64 * rhos[block])
1466 .sum::<f64>();
1467 let deviance_term = 0.5 * nu * q.iter().map(|value| value.ln()).sum::<f64>();
1468 let value = determinant_term + deviance_term;
1469 if !value.is_finite() {
1470 return None;
1471 }
1472 Some(BlockOrthogonalProfileValue {
1473 value,
1474 roundoff: f64::EPSILON * (determinant_term.abs() + deviance_term.abs()),
1475 })
1476}
1477
1478struct BlockOrthogonalStateMeasurement {
1483 score_residual: f64,
1484 gradient: Array1<f64>,
1485 spectrum: BlockOrthogonalProfileSpectrum,
1486}
1487
1488fn measure_block_orthogonal_state(
1489 evals: &[BlockOrthogonalEval],
1490 rhos: ArrayView1<'_, f64>,
1491 scale_precision: ArrayView1<'_, f64>,
1492 ranks: &[usize],
1493 nu: f64,
1494 d: usize,
1495) -> Result<BlockOrthogonalStateMeasurement, EstimationError> {
1496 let mut gradient = Array1::<f64>::zeros(evals.len());
1497 let mut score_residual = 0.0_f64;
1498 for (block, eval) in evals.iter().enumerate() {
1499 let derivs =
1500 block_orthogonal_scale_objective(eval, rhos[block], scale_precision, ranks[block]);
1501 let residual = derivs.grad.abs() / ((d as f64) * (ranks[block].max(1) as f64));
1502 if !residual.is_finite() {
1503 return Err(EstimationError::ModelIsIllConditioned {
1504 condition_number: f64::INFINITY,
1505 });
1506 }
1507 gradient[block] = derivs.grad;
1508 score_residual = score_residual.max(residual);
1509 }
1510 let hessian = block_orthogonal_profile_hessian(evals, rhos, scale_precision, ranks, nu)?;
1511 Ok(BlockOrthogonalStateMeasurement {
1512 score_residual,
1513 gradient,
1514 spectrum: block_orthogonal_profile_spectrum(&hessian)?,
1515 })
1516}
1517
1518pub fn gaussian_reml_blocks_orthogonal_shared_scale(
1519 designs: &[Array2<f64>],
1520 penalties: &[Array2<f64>],
1521 y: ArrayView2<'_, f64>,
1522 weights: Option<ArrayView1<'_, f64>>,
1523 init_rhos: Option<&[f64]>,
1524) -> Result<GaussianRemlBlockOrthogonalResult, EstimationError> {
1525 gaussian_reml_blocks_orthogonal_shared_scale_with_controls(
1526 designs,
1527 penalties,
1528 y,
1529 weights,
1530 init_rhos,
1531 BlockOrthogonalControls::default(),
1532 )
1533}
1534
1535fn gaussian_reml_blocks_orthogonal_shared_scale_with_controls(
1536 designs: &[Array2<f64>],
1537 penalties: &[Array2<f64>],
1538 y: ArrayView2<'_, f64>,
1539 weights: Option<ArrayView1<'_, f64>>,
1540 init_rhos: Option<&[f64]>,
1541 controls: BlockOrthogonalControls,
1542) -> Result<GaussianRemlBlockOrthogonalResult, EstimationError> {
1543 if designs.is_empty() {
1544 crate::bail_invalid_estim!("block-orthogonal Gaussian REML requires at least one block");
1545 }
1546 if designs.len() != penalties.len() {
1547 crate::bail_invalid_estim!(
1548 "block-orthogonal Gaussian REML block mismatch: {} designs, {} penalties",
1549 designs.len(),
1550 penalties.len()
1551 );
1552 }
1553 let n = y.nrows();
1554 let d = y.ncols();
1555 if d == 0 {
1556 crate::bail_invalid_estim!("block-orthogonal Gaussian REML requires at least one output");
1557 }
1558 if y.iter().any(|value| !value.is_finite()) {
1559 crate::bail_invalid_estim!("block-orthogonal Gaussian REML response must be finite");
1560 }
1561 let weight = gaussian_reml_weights(n, weights)?;
1562 if let Some(rhos) = init_rhos {
1563 if rhos.len() != designs.len() {
1564 crate::bail_invalid_estim!(
1565 "block-orthogonal Gaussian REML init_rhos length mismatch: expected {}, got {}",
1566 designs.len(),
1567 rhos.len()
1568 );
1569 }
1570 if rhos.iter().any(|value| !value.is_finite()) {
1571 crate::bail_invalid_estim!("block-orthogonal Gaussian REML init_rhos must be finite");
1572 }
1573 }
1574
1575 let mut ywy = Array1::<f64>::zeros(d);
1576 for row in 0..n {
1577 for output in 0..d {
1578 ywy[output] += weight[row] * y[[row, output]] * y[[row, output]];
1579 }
1580 }
1581 let mut grams = Vec::with_capacity(designs.len());
1582 let mut rhs_blocks = Vec::with_capacity(designs.len());
1583 let mut penalties_owned = Vec::with_capacity(penalties.len());
1584 let mut ranks = Vec::with_capacity(penalties.len());
1585 let mut penalty_logdets = Vec::with_capacity(penalties.len());
1586 let mut nullity_total = 0_usize;
1587 for (block, (design, penalty)) in designs.iter().zip(penalties.iter()).enumerate() {
1588 let penalty_owned = canonicalize_penalty(penalty.view());
1589 validate_gaussian_reml_design(design.view(), penalty_owned.view(), Some(weight.view()))?;
1590 if design.nrows() != n {
1591 crate::bail_invalid_estim!(
1592 "block-orthogonal Gaussian REML designs[{block}] has {} rows, expected {n}",
1593 design.nrows()
1594 );
1595 }
1596 let gram = dense_xt_diag_x(design.view(), weight.view());
1597 let rhs = dense_xt_diag_y(design.view(), weight.view(), y);
1598 let (rank, logdet) = block_penalty_rank_logdet(penalty_owned.view())?;
1599 nullity_total += penalty_owned.nrows().saturating_sub(rank);
1600 grams.push(canonicalize_penalty(gram.view()));
1601 rhs_blocks.push(rhs);
1602 penalties_owned.push(penalty_owned);
1603 ranks.push(rank);
1604 penalty_logdets.push(logdet);
1605 }
1606 validate_weighted_block_orthogonality(designs, weight.view())?;
1607 let n_effective = effective_observation_count(weight.view());
1608 if n_effective <= nullity_total {
1609 crate::bail_invalid_estim!(
1610 "block-orthogonal Gaussian REML requires more positive-weight rows than the total penalty nullity; got n_effective={n_effective}, nullity={nullity_total}"
1611 );
1612 }
1613 let nu = (n_effective - nullity_total) as f64;
1614 let mut rhos = match init_rhos {
1615 Some(values) => Array1::from_vec(values.to_vec()),
1616 None => Array1::zeros(designs.len()),
1617 };
1618 let mut evals = (0..designs.len())
1623 .map(|block| {
1624 block_orthogonal_eval(
1625 &grams[block],
1626 &rhs_blocks[block],
1627 &penalties_owned[block],
1628 rhos[block],
1629 )
1630 })
1631 .collect::<Result<Vec<_>, _>>()?;
1632 let mut scale_precision = block_orthogonal_conditional_scale(&evals, ywy.view(), nu)?;
1633 let mut converged = false;
1666 let mut cycle_detected = false;
1667 let mut outer_passes = 0usize;
1668 let mut last_score_residual = f64::INFINITY;
1669 let mut last_min_profile_curvature = f64::NEG_INFINITY;
1670 let mut last_profile_curvature_roundoff = 0.0_f64;
1671 let mut last_scale_step = f64::INFINITY;
1672 let mut recent_states: [Option<(Array1<f64>, Array1<f64>)>; 2] = [None, None];
1673 while outer_passes < controls.max_outer_passes {
1674 outer_passes += 1;
1675 let scale_at_pass_start = scale_precision.clone();
1676 evals.clear();
1677 for block in 0..designs.len() {
1678 let (rho, eval) = solve_block_orthogonal_rho(
1679 &grams[block],
1680 &rhs_blocks[block],
1681 &penalties_owned[block],
1682 rhos[block],
1683 scale_precision.view(),
1684 ranks[block],
1685 controls.block_updates_per_pass,
1686 )?;
1687 rhos[block] = rho;
1688 evals.push(eval);
1689 }
1690 scale_precision = block_orthogonal_conditional_scale(&evals, ywy.view(), nu)?;
1691 let mut measured = measure_block_orthogonal_state(
1692 &evals,
1693 rhos.view(),
1694 scale_precision.view(),
1695 &ranks,
1696 nu,
1697 d,
1698 )?;
1699 let alternation_certified = measured.score_residual <= controls.score_tol
1702 && measured.spectrum.curvature.min_eigenvalue >= -measured.spectrum.curvature.roundoff;
1703 let newton_step = if alternation_certified {
1704 None
1705 } else {
1706 measured
1707 .spectrum
1708 .newton_direction(measured.gradient.view())
1709 .zip(block_orthogonal_profile_value(
1710 &evals,
1711 rhos.view(),
1712 &ranks,
1713 ywy.view(),
1714 nu,
1715 d,
1716 ))
1717 };
1718 if let Some((direction, current_profile)) = newton_step {
1719 let model_decrease = -0.5 * measured.gradient.dot(&direction);
1725 let value_decides =
1726 model_decrease.is_finite() && model_decrease > current_profile.roundoff;
1727 let mut step_scale = 1.0_f64;
1728 let accepted = loop {
1729 let candidate_rhos = &rhos + &direction.mapv(|value| step_scale * value);
1730 if candidate_rhos == rhos {
1731 break None;
1732 }
1733 let candidate = (0..designs.len())
1734 .map(|block| {
1735 block_orthogonal_eval(
1736 &grams[block],
1737 &rhs_blocks[block],
1738 &penalties_owned[block],
1739 candidate_rhos[block],
1740 )
1741 })
1742 .collect::<Result<Vec<_>, _>>()
1743 .ok()
1744 .and_then(|candidate_evals| {
1745 let candidate_scale =
1746 block_orthogonal_conditional_scale(&candidate_evals, ywy.view(), nu)
1747 .ok()?;
1748 let candidate_measured = measure_block_orthogonal_state(
1749 &candidate_evals,
1750 candidate_rhos.view(),
1751 candidate_scale.view(),
1752 &ranks,
1753 nu,
1754 d,
1755 )
1756 .ok()?;
1757 let improves = if value_decides {
1758 block_orthogonal_profile_value(
1759 &candidate_evals,
1760 candidate_rhos.view(),
1761 &ranks,
1762 ywy.view(),
1763 nu,
1764 d,
1765 )
1766 .is_some_and(|profile| profile.value < current_profile.value)
1767 } else {
1768 candidate_measured.score_residual < measured.score_residual
1769 };
1770 improves.then_some((candidate_evals, candidate_scale, candidate_measured))
1771 });
1772 if let Some((candidate_evals, candidate_scale, candidate_measured)) = candidate {
1773 break Some((
1774 candidate_rhos,
1775 candidate_evals,
1776 candidate_scale,
1777 candidate_measured,
1778 ));
1779 }
1780 step_scale *= 0.5;
1783 };
1784 if let Some((next_rhos, next_evals, next_scale, next_measured)) = accepted {
1785 rhos = next_rhos;
1786 evals = next_evals;
1787 scale_precision = next_scale;
1788 measured = next_measured;
1789 }
1790 }
1791 last_scale_step = scale_precision
1792 .iter()
1793 .zip(scale_at_pass_start.iter())
1794 .map(|(next, old)| (next.ln() - old.ln()).abs())
1795 .fold(0.0_f64, f64::max);
1796 last_score_residual = measured.score_residual;
1797 last_min_profile_curvature = measured.spectrum.curvature.min_eigenvalue;
1798 last_profile_curvature_roundoff = measured.spectrum.curvature.roundoff;
1799 if last_score_residual <= controls.score_tol
1800 && last_min_profile_curvature >= -last_profile_curvature_roundoff
1801 {
1802 converged = true;
1803 break;
1804 }
1805 let state = (rhos.clone(), scale_precision.clone());
1811 if recent_states
1812 .iter()
1813 .flatten()
1814 .any(|prev| prev.0 == state.0 && prev.1 == state.1)
1815 {
1816 cycle_detected = true;
1817 break;
1818 }
1819 recent_states[1] = recent_states[0].take();
1820 recent_states[0] = Some(state);
1821 }
1822 if !converged {
1823 return Err(EstimationError::BlockOrthogonalRemlDidNotConverge {
1824 iterations: outer_passes,
1825 max_score_residual: last_score_residual,
1826 score_tol: controls.score_tol,
1827 min_profile_curvature: last_min_profile_curvature,
1828 profile_curvature_roundoff: last_profile_curvature_roundoff,
1829 last_scale_step,
1830 cycle_detected,
1831 rho_checkpoint: rhos.to_vec(),
1832 });
1833 }
1834
1835 let coefficients = evals
1836 .iter()
1837 .map(|eval| eval.beta.clone())
1838 .collect::<Vec<_>>();
1839 let mut fitted = Array2::<f64>::zeros((n, d));
1840 for (design, coef) in designs.iter().zip(coefficients.iter()) {
1841 fitted += &fast_ab(&design.view(), &coef.view());
1842 }
1843 let mut explained = Array1::<f64>::zeros(d);
1844 for eval in evals.iter() {
1845 explained += &eval.fitted_energy;
1846 }
1847 let q = &ywy - &explained;
1848 if q.iter().any(|value| !value.is_finite() || *value <= 0.0) {
1849 return Err(EstimationError::ModelIsIllConditioned {
1850 condition_number: f64::INFINITY,
1851 });
1852 }
1853 let lambdas = Array1::from_vec(gam_problem::checked_exp_log_strengths(
1854 rhos.iter().copied(),
1855 )?);
1856 let edf = Array1::from_iter(evals.iter().map(|eval| eval.edf));
1857 let logdet_term = evals
1858 .iter()
1859 .enumerate()
1860 .map(|(block, eval)| {
1861 eval.logdet - penalty_logdets[block] - (ranks[block] as f64) * rhos[block]
1862 })
1863 .sum::<f64>();
1864 let scale_term = q
1865 .iter()
1866 .map(|value| nu * (1.0 + (2.0 * std::f64::consts::PI * value / nu).ln()))
1867 .sum::<f64>();
1868 Ok(GaussianRemlBlockOrthogonalResult {
1869 coefficients,
1870 fitted,
1871 lambdas,
1872 log_lambdas: rhos,
1873 reml_score: 0.5 * (d as f64) * logdet_term + 0.5 * scale_term,
1874 edf,
1875 })
1876}
1877
1878pub fn gaussian_reml_multi_shared_dispersion_penalty_gradient_from_fit(
1889 x: ArrayView2<'_, f64>,
1890 y: ArrayView2<'_, f64>,
1891 penalty: ArrayView2<'_, f64>,
1892 weights: Option<ArrayView1<'_, f64>>,
1893 fit: &GaussianRemlMultiResult,
1894) -> Result<Array2<f64>, EstimationError> {
1895 validate_gaussian_reml_forward_fit(x, y, penalty, weights, fit)?;
1896 let n = x.nrows();
1897 let p = x.ncols();
1898 let d = y.ncols();
1899 if d == 0 {
1900 crate::bail_invalid_estim!(
1901 "shared-dispersion REML penalty gradient requires at least one response column"
1902 );
1903 }
1904 let weight = gaussian_reml_weights(n, weights)?;
1905 let n_effective = effective_observation_count(weight.view());
1906 let per_output_nu = n_effective.checked_sub(fit.cache.nullity).ok_or_else(|| {
1907 EstimationError::InvalidInput(
1908 "shared-dispersion REML penalty gradient has non-positive residual degrees of freedom"
1909 .to_string(),
1910 )
1911 })?;
1912 if per_output_nu == 0 {
1913 crate::bail_invalid_estim!(
1914 "shared-dispersion REML penalty gradient requires positive residual degrees of freedom"
1915 );
1916 }
1917 let shared_nu = (d as f64) * (per_output_nu as f64);
1918 let shared_sigma2 = fit.sigma2[0];
1929 if fit
1930 .sigma2
1931 .iter()
1932 .any(|sigma2| sigma2.to_bits() != shared_sigma2.to_bits())
1933 {
1934 crate::bail_invalid_estim!(
1935 "shared-dispersion REML penalty gradient requires one shared forward dispersion"
1936 );
1937 }
1938 let pooled_deviance = shared_sigma2 * shared_nu;
1939 if !(pooled_deviance.is_finite() && pooled_deviance > 0.0) {
1940 crate::bail_invalid_estim!(
1941 "shared-dispersion REML penalty gradient requires positive forward deviance"
1942 );
1943 }
1944
1945 let inverse_hessian = gaussian_reml_inverse_hessian_from_cache(&fit.cache, fit.lambda)?;
1946 let penalty_pseudoinverse = gaussian_reml_penalty_pseudoinverse_from_cache(&fit.cache);
1947 let mut gradient = Array2::<f64>::zeros((p, p));
1948 for row in 0..p {
1949 for col in 0..p {
1950 gradient[[row, col]] = 0.5
1951 * (d as f64)
1952 * (fit.lambda * inverse_hessian[[col, row]] - penalty_pseudoinverse[[col, row]]);
1953 }
1954 }
1955 let deviance_scale = 0.5 * shared_nu * fit.lambda / pooled_deviance;
1956 for output in 0..d {
1957 add_rank_one_penalty_vjp(
1958 deviance_scale,
1959 fit.coefficients.column(output),
1960 &mut gradient,
1961 );
1962 }
1963 for row in 0..p {
1964 for col in (row + 1)..p {
1965 let mean = 0.5 * (gradient[[row, col]] + gradient[[col, row]]);
1966 gradient[[row, col]] = mean;
1967 gradient[[col, row]] = mean;
1968 }
1969 }
1970 if gradient.iter().any(|value| !value.is_finite()) {
1971 crate::bail_invalid_estim!(
1972 "shared-dispersion REML penalty gradient produced a non-finite value"
1973 );
1974 }
1975 Ok(gradient)
1976}
1977
1978fn gaussian_reml_multi_closed_form_from_parts(
1979 x: ArrayView2<'_, f64>,
1980 y: ArrayView2<'_, f64>,
1981 penalty: ArrayView2<'_, f64>,
1982 nullspace_dim: Option<usize>,
1983 weights: Option<ArrayView1<'_, f64>>,
1984 init_lambda: Option<f64>,
1985 eigen_cache: Option<&GaussianRemlEigenCache>,
1986) -> Result<GaussianRemlMultiResult, EstimationError> {
1987 let prepared = prepare_gaussian_reml(x, y, penalty, nullspace_dim, weights, eigen_cache)?;
1988 let init_rho = init_lambda
1989 .map(validate_initial_lambda)
1990 .transpose()?
1991 .map(f64::ln);
1992 let rho = optimize_rho(&prepared, init_rho)?;
1993 let eval = prepared.evaluate(rho);
1994 let lambda = gam_problem::checked_exp_log_strength(rho)
1995 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
1996 let coefficients = prepared.coefficients(lambda);
1997 let fitted = dense_ab(x, coefficients.view());
1998 let sigma2 = prepared.sigma2(lambda);
1999 let (reml_grad_lambda, reml_hess_lambda) =
2000 rho_derivatives_to_lambda(lambda, eval.grad, eval.hess);
2001 Ok(GaussianRemlMultiResult {
2002 lambda,
2003 rho,
2004 coefficients,
2005 fitted,
2006 reml_score: eval.cost,
2007 reml_grad_lambda,
2008 reml_hess_lambda,
2009 reml_grad_rho: eval.grad,
2010 reml_hess_rho: eval.hess,
2011 edf: eval.edf,
2012 sigma2,
2013 cache: prepared.cache,
2014 })
2015}
2016
2017pub fn gaussian_reml_free_b_score(
2018 x: ArrayView2<'_, f64>,
2019 y: ArrayView2<'_, f64>,
2020 coefficients: ArrayView2<'_, f64>,
2021 log_lambda: f64,
2022 penalty: ArrayView2<'_, f64>,
2023 weights: Option<ArrayView1<'_, f64>>,
2024) -> Result<GaussianRemlFreeBScore, EstimationError> {
2025 let lambda = gam_problem::checked_exp_log_strength(log_lambda)
2026 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
2027 let penalty_owned = canonicalize_penalty(penalty);
2028 let penalty = penalty_owned.view();
2029 let n = x.nrows();
2030 let p = x.ncols();
2031 let d = y.ncols();
2032 validate_gaussian_reml_design(x, penalty, weights)?;
2033 if y.nrows() != n {
2034 crate::bail_invalid_estim!(
2035 "Gaussian REML row mismatch: X has {n} rows but Y has {}",
2036 y.nrows()
2037 );
2038 }
2039 if coefficients.dim() != (p, d) {
2040 crate::bail_invalid_estim!(
2041 "Gaussian REML coefficient shape mismatch: expected {p}x{d}, got {}x{}",
2042 coefficients.nrows(),
2043 coefficients.ncols()
2044 );
2045 }
2046 if y.iter().chain(coefficients.iter()).any(|v| !v.is_finite()) {
2047 crate::bail_invalid_estim!("Gaussian REML inputs must be finite");
2048 }
2049
2050 let weight = gaussian_reml_weights(n, weights)?;
2051 let n_effective = effective_observation_count(weight.view());
2052 let cache =
2053 build_gaussian_reml_eigen_cache_with_nullspace_dim(x, penalty, None, Some(weight.view()))?;
2054 if n_effective <= cache.nullity {
2055 crate::bail_invalid_estim!(
2056 "Gaussian REML requires more positive-weight rows than the nullspace dimension; got n_effective={n_effective}, nullity={}",
2057 cache.nullity
2058 );
2059 }
2060 let nu = n_effective as f64 - cache.nullity as f64;
2061 let fitted = dense_ab(x, coefficients);
2062 let residual = y.to_owned() - &fitted;
2063 let xtw_residual = dense_xt_diag_y(x, weight.view(), residual.view());
2064 let s_beta = dense_ab(penalty, coefficients);
2065
2066 let mut logdet_h = cache.logdet_xtwx;
2067 let mut trace_h = 0.0;
2068 let mut edf = 0.0;
2069 for &delta in &cache.penalty_eigenvalues {
2070 let t = lambda * delta;
2071 logdet_h += (1.0 + t).ln();
2072 if delta > 0.0 {
2073 trace_h += t / (1.0 + t);
2074 }
2075 edf += 1.0 / (1.0 + t);
2076 }
2077 let logdet_s = cache.logdet_penalty_positive + (cache.penalty_rank as f64) * log_lambda;
2078 let mut reml_score = 0.5 * (d as f64) * (logdet_h - logdet_s);
2079 let mut grad_log_lambda = 0.5 * (d as f64) * (trace_h - cache.penalty_rank as f64);
2080 let mut grad_coefficients = Array2::<f64>::zeros((p, d));
2081 let inverse_hessian = {
2082 let xtwx = dense_xt_diag_x(x, weight.view());
2083 let mut hessian = xtwx;
2084 hessian += &(penalty.to_owned() * lambda);
2085 hessian
2086 .cholesky(Side::Lower)
2087 .map_err(EstimationError::LinearSystemSolveFailed)?
2088 .solve_mat(&Array2::<f64>::eye(p))
2089 };
2090 let penalty_pinv = gaussian_reml_penalty_pseudoinverse_from_cache(&cache);
2091 let mut grad_penalty = Array2::<f64>::zeros((p, p));
2092 for row in 0..p {
2093 for col in 0..p {
2094 grad_penalty[[row, col]] += 0.5
2095 * (d as f64)
2096 * (lambda * inverse_hessian[[col, row]] - penalty_pinv[[col, row]]);
2097 }
2098 }
2099 let mut sigma2 = Array1::<f64>::zeros(d);
2100
2101 for output in 0..d {
2102 let mut weighted_rss = 0.0;
2103 for row in 0..n {
2104 let r = residual[[row, output]];
2105 weighted_rss += weight[row] * r * r;
2106 }
2107 let beta_col = coefficients.column(output);
2108 let s_beta_col = s_beta.column(output);
2109 let penalty_quadratic = beta_col.dot(&s_beta_col);
2110 let dp = (weighted_rss + lambda * penalty_quadratic).max(MIN_DEVIANCE);
2111 sigma2[output] = dp / nu;
2112 reml_score += 0.5 * nu * (1.0 + (2.0 * std::f64::consts::PI * dp / nu).ln());
2113 grad_log_lambda += 0.5 * nu * lambda * penalty_quadratic / dp;
2114 let scale = nu / dp;
2115 for coeff in 0..p {
2116 grad_coefficients[[coeff, output]] =
2117 scale * (-xtw_residual[[coeff, output]] + lambda * s_beta[[coeff, output]]);
2118 }
2119 add_rank_one_penalty_vjp(0.5 * scale * lambda, beta_col, &mut grad_penalty);
2120 }
2121 for i in 0..p {
2122 for j in (i + 1)..p {
2123 let avg = 0.5 * (grad_penalty[[i, j]] + grad_penalty[[j, i]]);
2124 grad_penalty[[i, j]] = avg;
2125 grad_penalty[[j, i]] = avg;
2126 }
2127 }
2128
2129 Ok(GaussianRemlFreeBScore {
2130 reml_score,
2131 grad_coefficients,
2132 grad_penalty,
2133 grad_log_lambda,
2134 fitted,
2135 sigma2,
2136 edf,
2137 })
2138}
2139
2140pub fn gaussian_reml_multi_closed_form_backward(
2141 x: ArrayView2<'_, f64>,
2142 y: ArrayView2<'_, f64>,
2143 penalty: ArrayView2<'_, f64>,
2144 weights: Option<ArrayView1<'_, f64>>,
2145 init_lambda: Option<f64>,
2146 upstream_lambda: f64,
2147 upstream_coefficients: Option<ArrayView2<'_, f64>>,
2148 upstream_fitted: Option<ArrayView2<'_, f64>>,
2149 upstream_reml_score: f64,
2150 upstream_edf: f64,
2151) -> Result<GaussianRemlBackwardResult, EstimationError> {
2152 let fit =
2153 gaussian_reml_multi_closed_form_with_cache(x, y, penalty, weights, init_lambda, None)?;
2154 gaussian_reml_multi_closed_form_backward_from_fit(
2155 x,
2156 y,
2157 penalty,
2158 weights,
2159 &fit,
2160 upstream_lambda,
2161 upstream_coefficients,
2162 upstream_fitted,
2163 upstream_reml_score,
2164 upstream_edf,
2165 )
2166}
2167
2168pub fn gaussian_reml_multi_closed_form_backward_from_fit(
2169 x: ArrayView2<'_, f64>,
2170 y: ArrayView2<'_, f64>,
2171 penalty: ArrayView2<'_, f64>,
2172 weights: Option<ArrayView1<'_, f64>>,
2173 fit: &GaussianRemlMultiResult,
2174 upstream_lambda: f64,
2175 upstream_coefficients: Option<ArrayView2<'_, f64>>,
2176 upstream_fitted: Option<ArrayView2<'_, f64>>,
2177 upstream_reml_score: f64,
2178 upstream_edf: f64,
2179) -> Result<GaussianRemlBackwardResult, EstimationError> {
2180 validate_gaussian_reml_backward_upstreams(
2181 x,
2182 y,
2183 penalty,
2184 upstream_lambda,
2185 upstream_coefficients,
2186 upstream_fitted,
2187 upstream_reml_score,
2188 upstream_edf,
2189 )?;
2190 validate_gaussian_reml_forward_fit(x, y, penalty, weights, fit)?;
2191 let lambda = fit.lambda;
2192 let n = x.nrows();
2193 let p = x.ncols();
2194 let d = y.ncols();
2195 let rho_hat = lambda.ln();
2206 let rho_at_bound =
2207 (rho_hat - RHO_UPPER).abs() <= 1.0e-9 || (rho_hat - RHO_LOWER).abs() <= 1.0e-9;
2208 let implicit_rho_usable =
2209 fit.reml_hess_rho.is_finite() && fit.reml_hess_rho.abs() > 1.0e-14 && !rho_at_bound;
2210 let weight = gaussian_reml_weights(n, weights)?;
2211 let inverse_hessian = match gaussian_reml_inverse_hessian_from_cache(&fit.cache, lambda) {
2212 Ok(inv) => inv,
2213 Err(EstimationError::ModelIsIllConditioned { condition_number }) => {
2214 warn_ill_conditioned_backward_once(p, d, condition_number);
2215 return Ok(zero_backward_result(n, p, d));
2216 }
2217 Err(err) => return Err(err),
2218 };
2219 gaussian_reml_multi_closed_form_backward_from_fit_with_inverse_hessian_impl(
2220 x,
2221 y,
2222 penalty,
2223 weight,
2224 fit,
2225 inverse_hessian,
2226 upstream_lambda,
2227 upstream_coefficients,
2228 upstream_fitted,
2229 upstream_reml_score,
2230 upstream_edf,
2231 implicit_rho_usable,
2232 n,
2233 p,
2234 d,
2235 )
2236}
2237
2238fn gaussian_reml_multi_closed_form_backward_from_fit_with_inverse_hessian_impl(
2239 x: ArrayView2<'_, f64>,
2240 y: ArrayView2<'_, f64>,
2241 penalty: ArrayView2<'_, f64>,
2242 weight: Array1<f64>,
2243 fit: &GaussianRemlMultiResult,
2244 inverse_hessian: Array2<f64>,
2245 upstream_lambda: f64,
2246 upstream_coefficients: Option<ArrayView2<'_, f64>>,
2247 upstream_fitted: Option<ArrayView2<'_, f64>>,
2248 upstream_reml_score: f64,
2249 upstream_edf: f64,
2250 implicit_rho_usable: bool,
2251 n: usize,
2252 p: usize,
2253 d: usize,
2254) -> Result<GaussianRemlBackwardResult, EstimationError> {
2255 let penalty_owned = canonicalize_penalty(penalty);
2259 let penalty = penalty_owned.view();
2260 let lambda = fit.lambda;
2261 let beta = &fit.coefficients;
2262 let residual = y.to_owned() - &fit.fitted;
2263 let nu = effective_observation_count(weight.view()) as f64 - fit.cache.nullity as f64;
2267
2268 let mut grad_x = Array2::<f64>::zeros((n, p));
2269 let mut grad_y = Array2::<f64>::zeros((n, d));
2270 let mut grad_penalty = Array2::<f64>::zeros((p, p));
2271 let mut grad_weights = Array1::<f64>::zeros(n);
2272
2273 let mut upstream_beta = Array2::<f64>::zeros((p, d));
2274 if let Some(upstream_coefficients) = upstream_coefficients {
2275 upstream_beta += &upstream_coefficients;
2276 }
2277 if let Some(upstream_fitted) = upstream_fitted {
2278 upstream_beta += &dense_atb(x, upstream_fitted);
2279 grad_x += &dense_ab(upstream_fitted, beta.t());
2280 }
2281
2282 let mut lambda_adjoint = upstream_lambda;
2283 if upstream_beta.iter().any(|value| *value != 0.0) {
2284 add_ridge_profile_vjp_with_lambda_grad(
2289 1.0,
2290 x,
2291 y,
2292 penalty,
2293 &weight,
2294 lambda,
2295 &inverse_hessian,
2296 beta,
2297 upstream_beta.view(),
2298 &mut grad_x,
2299 &mut grad_y,
2300 &mut grad_penalty,
2301 &mut grad_weights,
2302 &mut lambda_adjoint,
2303 );
2304 }
2305
2306 if upstream_reml_score != 0.0 {
2307 add_reml_score_vjp(
2308 upstream_reml_score,
2309 x,
2310 &weight,
2311 &inverse_hessian,
2312 beta,
2313 &residual,
2314 &fit.sigma2,
2315 nu,
2316 lambda,
2317 &fit.cache,
2318 &mut grad_x,
2319 &mut grad_y,
2320 &mut grad_penalty,
2321 &mut grad_weights,
2322 );
2323 lambda_adjoint += upstream_reml_score * fit.reml_grad_lambda;
2324 }
2325
2326 if upstream_edf != 0.0 {
2327 lambda_adjoint += add_edf_vjp(
2328 upstream_edf,
2329 x,
2330 penalty,
2331 &weight,
2332 lambda,
2333 &inverse_hessian,
2334 &mut grad_x,
2335 &mut grad_penalty,
2336 &mut grad_weights,
2337 );
2338 }
2339
2340 if lambda_adjoint != 0.0 && implicit_rho_usable {
2341 let root_scale = -lambda_adjoint * lambda / fit.reml_hess_rho;
2342 add_reml_rho_gradient_vjp(
2343 root_scale,
2344 x,
2345 y,
2346 penalty,
2347 &weight,
2348 lambda,
2349 &inverse_hessian,
2350 beta,
2351 &residual,
2352 &fit.sigma2,
2353 nu,
2354 &mut grad_x,
2355 &mut grad_y,
2356 &mut grad_penalty,
2357 &mut grad_weights,
2358 );
2359 }
2360
2361 let p = grad_penalty.nrows();
2370 for i in 0..p {
2371 for j in (i + 1)..p {
2372 let avg = 0.5 * (grad_penalty[[i, j]] + grad_penalty[[j, i]]);
2373 grad_penalty[[i, j]] = avg;
2374 grad_penalty[[j, i]] = avg;
2375 }
2376 }
2377 Ok(GaussianRemlBackwardResult {
2378 grad_x,
2379 grad_y,
2380 grad_penalty,
2381 grad_weights,
2382 })
2383}
2384
2385pub fn gaussian_reml_multi_closed_form_backward_batch<'a>(
2386 problems: &[GaussianRemlMultiBackwardProblem<'a>],
2387 penalty: ArrayView2<'a, f64>,
2388) -> Vec<Result<GaussianRemlBackwardResult, EstimationError>> {
2389 let inverse_hessians = batched_inverse_hessians_from_caches(problems);
2390 let results: Vec<Result<GaussianRemlBackwardResult, EstimationError>> = problems
2391 .par_iter()
2392 .zip(inverse_hessians.into_par_iter())
2393 .map(|(problem, inverse_hessian_result)| {
2394 validate_gaussian_reml_backward_upstreams(
2395 problem.x.view(),
2396 problem.y.view(),
2397 penalty,
2398 problem.grad_lambda,
2399 problem.grad_coefficients.as_ref().map(|g| g.view()),
2400 problem.grad_fitted.as_ref().map(|g| g.view()),
2401 problem.grad_reml_score,
2402 problem.grad_edf,
2403 )?;
2404 validate_gaussian_reml_forward_fit(
2405 problem.x.view(),
2406 problem.y.view(),
2407 penalty,
2408 problem.weights.as_ref().map(|w| w.view()),
2409 problem.fit,
2410 )?;
2411 let n = problem.x.nrows();
2412 let p = problem.x.ncols();
2413 let d = problem.y.ncols();
2414 if !(problem.fit.reml_hess_rho.is_finite() && problem.fit.reml_hess_rho.abs() > 1.0e-14)
2415 {
2416 warn_ill_conditioned_backward_once(p, d, f64::INFINITY);
2418 return Ok(zero_backward_result(n, p, d));
2419 }
2420 let weight = gaussian_reml_weights(n, problem.weights.as_ref().map(|w| w.view()))?;
2421 let inverse_hessian = match inverse_hessian_result {
2422 Ok(inv) => inv,
2423 Err(EstimationError::ModelIsIllConditioned { condition_number }) => {
2424 warn_ill_conditioned_backward_once(p, d, condition_number);
2425 return Ok(zero_backward_result(n, p, d));
2426 }
2427 Err(err) => return Err(err),
2428 };
2429 let rho_hat = problem.fit.lambda.ln();
2434 let rho_at_bound =
2435 (rho_hat - RHO_UPPER).abs() <= 1.0e-9 || (rho_hat - RHO_LOWER).abs() <= 1.0e-9;
2436 let implicit_rho_usable = problem.fit.reml_hess_rho.is_finite()
2437 && problem.fit.reml_hess_rho.abs() > 1.0e-14
2438 && !rho_at_bound;
2439 gaussian_reml_multi_closed_form_backward_from_fit_with_inverse_hessian_impl(
2440 problem.x.view(),
2441 problem.y.view(),
2442 penalty,
2443 weight,
2444 problem.fit,
2445 inverse_hessian,
2446 problem.grad_lambda,
2447 problem.grad_coefficients.as_ref().map(|g| g.view()),
2448 problem.grad_fitted.as_ref().map(|g| g.view()),
2449 problem.grad_reml_score,
2450 problem.grad_edf,
2451 implicit_rho_usable,
2452 n,
2453 p,
2454 d,
2455 )
2456 })
2457 .collect();
2458 results
2459}
2460
2461fn rho_derivatives_to_lambda(lambda: f64, grad_rho: f64, hess_rho: f64) -> (f64, f64) {
2462 (grad_rho / lambda, (hess_rho - grad_rho) / (lambda * lambda))
2463}
2464
2465fn validate_gaussian_reml_backward_upstreams(
2466 x: ArrayView2<'_, f64>,
2467 y: ArrayView2<'_, f64>,
2468 penalty: ArrayView2<'_, f64>,
2469 upstream_lambda: f64,
2470 upstream_coefficients: Option<ArrayView2<'_, f64>>,
2471 upstream_fitted: Option<ArrayView2<'_, f64>>,
2472 upstream_reml_score: f64,
2473 upstream_edf: f64,
2474) -> Result<(), EstimationError> {
2475 if !(upstream_lambda.is_finite() && upstream_reml_score.is_finite() && upstream_edf.is_finite())
2476 {
2477 crate::bail_invalid_estim!("Gaussian REML backward upstream scalars must be finite");
2478 }
2479 if let Some(upstream_coefficients) = upstream_coefficients {
2480 if upstream_coefficients.dim() != (x.ncols(), y.ncols()) {
2481 crate::bail_invalid_estim!(
2482 "Gaussian REML backward coefficient upstream shape mismatch: expected {}x{}, got {}x{}",
2483 x.ncols(),
2484 y.ncols(),
2485 upstream_coefficients.nrows(),
2486 upstream_coefficients.ncols()
2487 );
2488 }
2489 if upstream_coefficients.iter().any(|value| !value.is_finite()) {
2490 crate::bail_invalid_estim!(
2491 "Gaussian REML backward coefficient upstream must be finite"
2492 );
2493 }
2494 }
2495 if let Some(upstream_fitted) = upstream_fitted {
2496 if upstream_fitted.dim() != y.dim() {
2497 crate::bail_invalid_estim!(
2498 "Gaussian REML backward fitted upstream shape mismatch: expected {}x{}, got {}x{}",
2499 y.nrows(),
2500 y.ncols(),
2501 upstream_fitted.nrows(),
2502 upstream_fitted.ncols()
2503 );
2504 }
2505 if upstream_fitted.iter().any(|value| !value.is_finite()) {
2506 crate::bail_invalid_estim!("Gaussian REML backward fitted upstream must be finite");
2507 }
2508 }
2509 validate_gaussian_reml_design(x, penalty, None)?;
2510 Ok(())
2511}
2512
2513fn validate_gaussian_reml_forward_fit(
2514 x: ArrayView2<'_, f64>,
2515 y: ArrayView2<'_, f64>,
2516 penalty: ArrayView2<'_, f64>,
2517 weights: Option<ArrayView1<'_, f64>>,
2518 fit: &GaussianRemlMultiResult,
2519) -> Result<(), EstimationError> {
2520 let penalty_owned = canonicalize_penalty(penalty);
2524 let penalty = penalty_owned.view();
2525 let n = x.nrows();
2526 let p = x.ncols();
2527 let d = y.ncols();
2528 validate_gaussian_reml_design(x, penalty, weights)?;
2529 validate_gaussian_reml_eigen_cache(&fit.cache, p)?;
2530 if y.nrows() != n
2531 || fit.coefficients.dim() != (p, d)
2532 || fit.fitted.dim() != (n, d)
2533 || fit.sigma2.len() != d
2534 {
2535 crate::bail_invalid_estim!(
2536 "Gaussian REML backward forward-state shape mismatch: expected coefficients=({p},{d}), fitted=({n},{d}), sigma2={d}"
2537 );
2538 }
2539 if !(fit.lambda.is_finite()
2540 && fit.lambda > 0.0
2541 && fit.rho.is_finite()
2542 && fit.reml_score.is_finite()
2543 && fit.reml_hess_rho.is_finite()
2544 && fit.edf.is_finite())
2545 || fit.coefficients.iter().any(|value| !value.is_finite())
2546 || fit.fitted.iter().any(|value| !value.is_finite())
2547 || fit.sigma2.iter().any(|value| !value.is_finite())
2548 {
2549 crate::bail_invalid_estim!("Gaussian REML backward forward state must be finite");
2550 }
2551 let penalty_fingerprint = matrix_fingerprint(penalty);
2552 if fit.cache.penalty_fingerprint != penalty_fingerprint {
2553 crate::bail_invalid_estim!("Gaussian REML backward forward-state penalty mismatch");
2554 }
2555 let weight = gaussian_reml_weights(n, weights)?;
2556 let xtwx = dense_xt_diag_x(x, weight.view());
2557 if fit.cache.xtwx_fingerprint != matrix_fingerprint(xtwx.view()) {
2558 crate::bail_invalid_estim!("Gaussian REML backward forward-state X'WX mismatch");
2559 }
2560 Ok(())
2561}
2562
2563fn gaussian_reml_inverse_hessian_from_cache(
2564 cache: &GaussianRemlEigenCache,
2565 lambda: f64,
2566) -> Result<Array2<f64>, EstimationError> {
2567 if !(lambda.is_finite() && lambda > 0.0) {
2568 crate::bail_invalid_estim!(
2569 "Gaussian REML lambda must be finite and positive; got {lambda}"
2570 );
2571 }
2572 let p = cache.penalty_eigenvalues.len();
2573 let mut scaled_basis = cache.coefficient_basis.clone();
2574 for eig in 0..p {
2575 let scale = 1.0 / (1.0 + lambda * cache.penalty_eigenvalues[eig]);
2576 for row in 0..p {
2577 scaled_basis[[row, eig]] *= scale;
2578 }
2579 }
2580 let inverse = dense_ab(scaled_basis.view(), cache.coefficient_basis.t());
2581 if inverse.iter().any(|value| !value.is_finite()) {
2582 return Err(EstimationError::ModelIsIllConditioned {
2583 condition_number: f64::INFINITY,
2584 });
2585 }
2586 Ok(inverse)
2587}
2588
2589fn batched_inverse_hessians_from_caches(
2590 problems: &[GaussianRemlMultiBackwardProblem<'_>],
2591) -> Vec<Result<Array2<f64>, EstimationError>> {
2592 if problems.is_empty() {
2593 return Vec::new();
2594 }
2595 let p = problems[0].fit.cache.coefficient_basis.nrows();
2596 let uniform = p > 0
2597 && problems.iter().all(|problem| {
2598 let cache = &problem.fit.cache;
2599 cache.coefficient_basis.dim() == (p, p) && cache.penalty_eigenvalues.len() == p
2600 });
2601 if uniform && problems.len() > 1 {
2602 let mut scaled_basis = Array3::<f64>::zeros((problems.len(), p, p));
2603 let mut basis = Array3::<f64>::zeros((problems.len(), p, p));
2604 let mut valid = true;
2605 for (idx, problem) in problems.iter().enumerate() {
2606 let lambda = problem.fit.lambda;
2607 if !(lambda.is_finite() && lambda > 0.0) {
2608 valid = false;
2609 break;
2610 }
2611 let cache = &problem.fit.cache;
2612 basis
2613 .slice_mut(s![idx, .., ..])
2614 .assign(&cache.coefficient_basis);
2615 for eig in 0..p {
2616 let scale = 1.0 / (1.0 + lambda * cache.penalty_eigenvalues[eig]);
2617 for row in 0..p {
2618 scaled_basis[[idx, row, eig]] = cache.coefficient_basis[[row, eig]] * scale;
2619 }
2620 }
2621 }
2622 if valid
2623 && let Some(inverses) =
2624 gam_gpu::try_fast_abt_strided_batched(scaled_basis.view(), basis.view())
2625 {
2626 return inverses
2627 .axis_iter(Axis(0))
2628 .map(|inverse| Ok(inverse.to_owned()))
2629 .collect();
2630 }
2631 }
2632 problems
2633 .iter()
2634 .map(|problem| {
2635 gaussian_reml_inverse_hessian_from_cache(&problem.fit.cache, problem.fit.lambda)
2636 })
2637 .collect()
2638}
2639
2640fn ridge_profile_vjp_data_partials(
2647 scale: f64,
2648 x: ArrayView2<'_, f64>,
2649 y: ArrayView2<'_, f64>,
2650 penalty: ArrayView2<'_, f64>,
2651 weights: &Array1<f64>,
2652 lambda: f64,
2653 inverse_hessian: &Array2<f64>,
2654 beta: &Array2<f64>,
2655 upstream_beta: ArrayView2<'_, f64>,
2656 grad_x: &mut Array2<f64>,
2657 grad_y: &mut Array2<f64>,
2658 grad_penalty: &mut Array2<f64>,
2659 grad_weights: &mut Array1<f64>,
2660) -> Array2<f64> {
2661 let m = dense_ab(inverse_hessian.view(), upstream_beta);
2662 let c = dense_ab(m.view(), beta.t());
2663 let c_sym = &c + &c.t();
2664 let ymt = dense_ab(y, m.t());
2665 let xcs = dense_ab(x, c_sym.view());
2666 for i in 0..x.nrows() {
2667 let wi = weights[i] * scale;
2668 for k in 0..x.ncols() {
2669 grad_x[[i, k]] += wi * (ymt[[i, k]] - xcs[[i, k]]);
2670 }
2671 }
2672
2673 let xm = dense_ab(x, m.view());
2674 for i in 0..x.nrows() {
2675 let wi = weights[i] * scale;
2676 for j in 0..y.ncols() {
2677 grad_y[[i, j]] += wi * xm[[i, j]];
2678 }
2679 }
2680
2681 let xc = dense_ab(x, c.view());
2682 for i in 0..x.nrows() {
2683 let mut from_b = 0.0;
2684 for j in 0..y.ncols() {
2685 from_b += y[[i, j]] * xm[[i, j]];
2686 }
2687 let mut from_a = 0.0;
2688 for k in 0..x.ncols() {
2689 from_a += x[[i, k]] * xc[[i, k]];
2690 }
2691 grad_weights[i] += scale * (from_b - from_a);
2692 }
2693
2694 for row in 0..penalty.nrows() {
2695 for col in 0..penalty.ncols() {
2696 let mut value = 0.0;
2697 for output in 0..beta.ncols() {
2698 value += m[[row, output]] * beta[[col, output]];
2699 }
2700 grad_penalty[[row, col]] -= scale * lambda * value;
2701 }
2702 }
2703 m
2704}
2705
2706fn add_ridge_profile_vjp_with_lambda_grad(
2711 scale: f64,
2712 x: ArrayView2<'_, f64>,
2713 y: ArrayView2<'_, f64>,
2714 penalty: ArrayView2<'_, f64>,
2715 weights: &Array1<f64>,
2716 lambda: f64,
2717 inverse_hessian: &Array2<f64>,
2718 beta: &Array2<f64>,
2719 upstream_beta: ArrayView2<'_, f64>,
2720 grad_x: &mut Array2<f64>,
2721 grad_y: &mut Array2<f64>,
2722 grad_penalty: &mut Array2<f64>,
2723 grad_weights: &mut Array1<f64>,
2724 lambda_adjoint_out: &mut f64,
2725) {
2726 let m = ridge_profile_vjp_data_partials(
2727 scale,
2728 x,
2729 y,
2730 penalty,
2731 weights,
2732 lambda,
2733 inverse_hessian,
2734 beta,
2735 upstream_beta,
2736 grad_x,
2737 grad_y,
2738 grad_penalty,
2739 grad_weights,
2740 );
2741 let penalty_beta = dense_ab(penalty, beta.view());
2742 let dot = m
2743 .iter()
2744 .zip(penalty_beta.iter())
2745 .map(|(left, right)| left * right)
2746 .sum::<f64>();
2747 *lambda_adjoint_out += -scale * dot;
2748}
2749
2750fn add_ridge_profile_vjp_fixed_lambda(
2754 scale: f64,
2755 x: ArrayView2<'_, f64>,
2756 y: ArrayView2<'_, f64>,
2757 penalty: ArrayView2<'_, f64>,
2758 weights: &Array1<f64>,
2759 lambda: f64,
2760 inverse_hessian: &Array2<f64>,
2761 beta: &Array2<f64>,
2762 upstream_beta: ArrayView2<'_, f64>,
2763 grad_x: &mut Array2<f64>,
2764 grad_y: &mut Array2<f64>,
2765 grad_penalty: &mut Array2<f64>,
2766 grad_weights: &mut Array1<f64>,
2767) {
2768 ridge_profile_vjp_data_partials(
2769 scale,
2770 x,
2771 y,
2772 penalty,
2773 weights,
2774 lambda,
2775 inverse_hessian,
2776 beta,
2777 upstream_beta,
2778 grad_x,
2779 grad_y,
2780 grad_penalty,
2781 grad_weights,
2782 );
2783}
2784
2785fn add_reml_score_vjp(
2786 scale: f64,
2787 x: ArrayView2<'_, f64>,
2788 weights: &Array1<f64>,
2789 inverse_hessian: &Array2<f64>,
2790 beta: &Array2<f64>,
2791 residual: &Array2<f64>,
2792 sigma2: &Array1<f64>,
2793 nu: f64,
2794 lambda: f64,
2795 cache: &GaussianRemlEigenCache,
2796 grad_x: &mut Array2<f64>,
2797 grad_y: &mut Array2<f64>,
2798 grad_penalty: &mut Array2<f64>,
2799 grad_weights: &mut Array1<f64>,
2800) {
2801 let d = beta.ncols() as f64;
2802 let xp = dense_ab(x, inverse_hessian.view());
2803 let penalty_pinv = gaussian_reml_penalty_pseudoinverse_from_cache(cache);
2804 for row in 0..grad_penalty.nrows() {
2805 for col in 0..grad_penalty.ncols() {
2806 grad_penalty[[row, col]] +=
2807 scale * 0.5 * d * (lambda * inverse_hessian[[col, row]] - penalty_pinv[[col, row]]);
2808 }
2809 }
2810 for i in 0..x.nrows() {
2811 let wi = weights[i] * scale * d;
2812 for k in 0..x.ncols() {
2813 grad_x[[i, k]] += wi * xp[[i, k]];
2814 }
2815 let mut leverage = 0.0;
2816 for k in 0..x.ncols() {
2817 leverage += x[[i, k]] * xp[[i, k]];
2818 }
2819 grad_weights[i] += scale * 0.5 * d * leverage;
2820 }
2821
2822 for j in 0..beta.ncols() {
2823 let dp = (sigma2[j] * nu).max(MIN_DEVIANCE);
2824 let coef = scale * 0.5 * nu / dp;
2825 add_deviance_profile_vjp(
2826 coef,
2827 j,
2828 x,
2829 weights,
2830 beta,
2831 residual,
2832 grad_x,
2833 grad_y,
2834 grad_weights,
2835 );
2836 add_rank_one_penalty_vjp(coef * lambda, beta.column(j), grad_penalty);
2837 }
2838}
2839
2840fn add_edf_vjp(
2851 scale: f64,
2852 x: ArrayView2<'_, f64>,
2853 penalty: ArrayView2<'_, f64>,
2854 weights: &Array1<f64>,
2855 lambda: f64,
2856 inverse_hessian: &Array2<f64>,
2857 grad_x: &mut Array2<f64>,
2858 grad_penalty: &mut Array2<f64>,
2859 grad_weights: &mut Array1<f64>,
2860) -> f64 {
2861 let m_inv_s = dense_ab(inverse_hessian.view(), penalty);
2863 let mut g_a = dense_ab(m_inv_s.view(), inverse_hessian.view());
2864 g_a.mapv_inplace(|v| v * lambda);
2865
2866 let xg = dense_ab(x, g_a.view());
2870 let leading_scale = 2.0 * scale;
2874 for i in 0..xg.nrows() {
2875 let row_scale = leading_scale * weights[i];
2876 for k in 0..xg.ncols() {
2877 grad_x[[i, k]] += row_scale * xg[[i, k]];
2878 }
2879 }
2880 for i in 0..x.nrows() {
2881 let mut quad = 0.0;
2882 for k in 0..x.ncols() {
2883 quad += x[[i, k]] * xg[[i, k]];
2884 }
2885 grad_weights[i] += scale * quad;
2886 }
2887
2888 for row in 0..grad_penalty.nrows() {
2891 for col in 0..grad_penalty.ncols() {
2892 grad_penalty[[row, col]] +=
2893 scale * (-lambda * inverse_hessian[[row, col]] + lambda * g_a[[row, col]]);
2894 }
2895 }
2896
2897 let p_dim = m_inv_s.nrows();
2899 let mut tr_m_inv_s = 0.0;
2900 for i in 0..p_dim {
2901 tr_m_inv_s += m_inv_s[[i, i]];
2902 }
2903 let mut tr_squared = 0.0;
2904 for i in 0..p_dim {
2905 for j in 0..p_dim {
2906 tr_squared += m_inv_s[[i, j]] * m_inv_s[[j, i]];
2907 }
2908 }
2909 scale * (-tr_m_inv_s + lambda * tr_squared)
2910}
2911
2912fn add_reml_rho_gradient_vjp(
2913 scale: f64,
2914 x: ArrayView2<'_, f64>,
2915 y: ArrayView2<'_, f64>,
2916 penalty: ArrayView2<'_, f64>,
2917 weights: &Array1<f64>,
2918 lambda: f64,
2919 inverse_hessian: &Array2<f64>,
2920 beta: &Array2<f64>,
2921 residual: &Array2<f64>,
2922 sigma2: &Array1<f64>,
2923 nu: f64,
2924 grad_x: &mut Array2<f64>,
2925 grad_y: &mut Array2<f64>,
2926 grad_penalty: &mut Array2<f64>,
2927 grad_weights: &mut Array1<f64>,
2928) {
2929 let d = beta.ncols() as f64;
2930 let inverse_s = dense_ab(inverse_hessian.view(), penalty);
2931 let trace_kernel = dense_ab(inverse_s.view(), inverse_hessian.view());
2932 for row in 0..grad_penalty.nrows() {
2933 for col in 0..grad_penalty.ncols() {
2934 grad_penalty[[row, col]] += scale
2935 * 0.5
2936 * d
2937 * lambda
2938 * (inverse_hessian[[col, row]] - lambda * trace_kernel[[col, row]]);
2939 }
2940 }
2941 let xt = dense_ab(x, trace_kernel.view());
2942 for i in 0..x.nrows() {
2943 let wi = -scale * d * lambda * weights[i];
2944 for k in 0..x.ncols() {
2945 grad_x[[i, k]] += wi * xt[[i, k]];
2946 }
2947 let mut quad = 0.0;
2948 for k in 0..x.ncols() {
2949 quad += x[[i, k]] * xt[[i, k]];
2950 }
2951 grad_weights[i] -= scale * 0.5 * d * lambda * quad;
2952 }
2953
2954 let s_beta = dense_ab(penalty, beta.view());
2955 let mut upstream_beta = Array2::<f64>::zeros(beta.dim());
2956 for j in 0..beta.ncols() {
2957 let dp = (sigma2[j] * nu).max(MIN_DEVIANCE);
2958 let q = lambda * beta.column(j).dot(&s_beta.column(j));
2959 let q_coef = scale * nu / dp;
2960 for row in 0..beta.nrows() {
2961 upstream_beta[[row, j]] = q_coef * lambda * s_beta[[row, j]];
2962 }
2963 let dp_coef = -scale * 0.5 * nu * q / (dp * dp);
2964 add_rank_one_penalty_vjp(
2965 (0.5 * q_coef + dp_coef) * lambda,
2966 beta.column(j),
2967 grad_penalty,
2968 );
2969 add_deviance_profile_vjp(
2970 dp_coef,
2971 j,
2972 x,
2973 weights,
2974 beta,
2975 residual,
2976 grad_x,
2977 grad_y,
2978 grad_weights,
2979 );
2980 }
2981 add_ridge_profile_vjp_fixed_lambda(
2984 1.0,
2985 x,
2986 y,
2987 penalty,
2988 weights,
2989 lambda,
2990 inverse_hessian,
2991 beta,
2992 upstream_beta.view(),
2993 grad_x,
2994 grad_y,
2995 grad_penalty,
2996 grad_weights,
2997 );
2998}
2999
3000fn add_rank_one_penalty_vjp(
3001 scale: f64,
3002 beta_col: ArrayView1<'_, f64>,
3003 grad_penalty: &mut Array2<f64>,
3004) {
3005 for row in 0..beta_col.len() {
3006 for col in 0..beta_col.len() {
3007 grad_penalty[[row, col]] += scale * beta_col[row] * beta_col[col];
3008 }
3009 }
3010}
3011
3012fn gaussian_reml_penalty_pseudoinverse_from_cache(cache: &GaussianRemlEigenCache) -> Array2<f64> {
3013 let p = cache.penalty_eigenvalues.len();
3014 let mut scaled_basis = Array2::<f64>::zeros((p, p));
3015 for eig in 0..p {
3016 let delta = cache.penalty_eigenvalues[eig];
3017 if delta > 0.0 {
3018 for row in 0..p {
3019 scaled_basis[[row, eig]] = cache.coefficient_basis[[row, eig]] / delta;
3020 }
3021 }
3022 }
3023 dense_ab(scaled_basis.view(), cache.coefficient_basis.t())
3024}
3025
3026fn add_deviance_profile_vjp(
3027 scale: f64,
3028 output: usize,
3029 x: ArrayView2<'_, f64>,
3030 weights: &Array1<f64>,
3031 beta: &Array2<f64>,
3032 residual: &Array2<f64>,
3033 grad_x: &mut Array2<f64>,
3034 grad_y: &mut Array2<f64>,
3035 grad_weights: &mut Array1<f64>,
3036) {
3037 for i in 0..x.nrows() {
3038 let r = residual[[i, output]];
3039 let wr_scale = scale * weights[i] * r;
3040 grad_y[[i, output]] += 2.0 * wr_scale;
3041 for k in 0..x.ncols() {
3042 grad_x[[i, k]] -= 2.0 * wr_scale * beta[[k, output]];
3043 }
3044 grad_weights[i] += scale * r * r;
3045 }
3046}
3047
3048fn validate_initial_lambda(lambda: f64) -> Result<f64, EstimationError> {
3049 if lambda.is_finite() && lambda > 0.0 {
3050 Ok(lambda)
3051 } else {
3052 Err(EstimationError::InvalidInput(format!(
3053 "Gaussian REML initial lambda must be finite and positive; got {lambda}"
3054 )))
3055 }
3056}
3057
3058fn dense_ab(a: ArrayView2<'_, f64>, b: ArrayView2<'_, f64>) -> Array2<f64> {
3059 fast_ab(&a, &b)
3060}
3061
3062fn dense_atb(a: ArrayView2<'_, f64>, b: ArrayView2<'_, f64>) -> Array2<f64> {
3063 fast_atb(&a, &b)
3064}
3065
3066fn dense_xt_diag_x(x: ArrayView2<'_, f64>, w: ArrayView1<'_, f64>) -> Array2<f64> {
3067 fast_xt_diag_x(&x, &w)
3068}
3069
3070fn dense_xt_diag_y(
3071 x: ArrayView2<'_, f64>,
3072 w: ArrayView1<'_, f64>,
3073 y: ArrayView2<'_, f64>,
3074) -> Array2<f64> {
3075 fast_xt_diag_y(&x, &w, &y)
3076}
3077
3078fn matrix_fingerprint(matrix: ArrayView2<'_, f64>) -> u64 {
3079 let mut hash = 0xcbf29ce484222325_u64;
3080 hash = fnv1a_mix(hash, matrix.nrows() as u64);
3081 hash = fnv1a_mix(hash, matrix.ncols() as u64);
3082 for &value in matrix {
3083 hash = fnv1a_mix(hash, value.to_bits());
3084 }
3085 hash
3086}
3087
3088fn fnv1a_mix(hash: u64, value: u64) -> u64 {
3089 (hash ^ value).wrapping_mul(0x100000001b3)
3090}
3091
3092pub fn build_gaussian_reml_eigen_cache_batched(
3097 xtwx_matrices: Vec<Array2<f64>>,
3098 penalty: ArrayView2<'_, f64>,
3099 nullspace_dim: Option<usize>,
3100) -> Vec<Result<GaussianRemlEigenCache, EstimationError>> {
3101 let penalty_owned = canonicalize_penalty(penalty);
3102 let penalty = penalty_owned.view();
3103 let k = xtwx_matrices.len();
3104 if k == 0 {
3105 return Vec::new();
3106 }
3107 let fingerprints: Vec<u64> = xtwx_matrices
3108 .iter()
3109 .map(|m| matrix_fingerprint(m.view()))
3110 .collect();
3111
3112 let p = xtwx_matrices[0].nrows();
3113 let uniform_square = p > 0 && xtwx_matrices.iter().all(|matrix| matrix.dim() == (p, p));
3114 if uniform_square && k > 1 {
3115 let mut lower_matrices = xtwx_matrices.clone();
3116 if gam_gpu::try_cholesky_batched_lower_inplace(&mut lower_matrices).is_some() {
3117 let transforms = batched_whitened_penalty_transforms(&lower_matrices, penalty);
3125 return lower_matrices
3126 .into_iter()
3127 .enumerate()
3128 .map(|(b, lower)| {
3129 let precomputed_transform = transforms.as_ref().map(|t| t[b].clone());
3130 gaussian_reml_eigen_cache_from_lower_with_transform(
3131 lower,
3132 penalty,
3133 nullspace_dim,
3134 fingerprints[b],
3135 precomputed_transform,
3136 )
3137 })
3138 .collect();
3139 }
3140 }
3141
3142 let mut results = Vec::with_capacity(k);
3143 for (b, xtwx) in xtwx_matrices.into_iter().enumerate() {
3144 let lower = match gaussian_reml_cholesky_lower(xtwx) {
3145 Ok(l) => l,
3146 Err(err) => {
3147 results.push(Err(err));
3148 continue;
3149 }
3150 };
3151 results.push(gaussian_reml_eigen_cache_from_lower_with_transform(
3152 lower,
3153 penalty,
3154 nullspace_dim,
3155 fingerprints[b],
3156 None,
3157 ));
3158 }
3159 results
3160}
3161
3162fn batched_whitened_penalty_transforms(
3163 lowers: &[Array2<f64>],
3164 penalty: ArrayView2<'_, f64>,
3165) -> Option<Vec<Array2<f64>>> {
3166 let first = lowers.first()?;
3167 let p = first.nrows();
3168 if p == 0 || first.ncols() != p || lowers.iter().any(|lower| lower.dim() != (p, p)) {
3169 return None;
3170 }
3171 let mut linv_stack = Array3::<f64>::zeros((lowers.len(), p, p));
3172 for (idx, lower) in lowers.iter().enumerate() {
3173 let l_inv = invert_lower_triangular(lower).ok()?;
3174 linv_stack.slice_mut(s![idx, .., ..]).assign(&l_inv);
3175 }
3176 let penalty_in_metric = gam_gpu::try_fast_ab_broadcast_b_batched(linv_stack.view(), penalty)?;
3177 let transformed =
3178 gam_gpu::try_fast_abt_strided_batched(penalty_in_metric.view(), linv_stack.view())?;
3179 Some(
3180 transformed
3181 .axis_iter(Axis(0))
3182 .map(|matrix| matrix.to_owned())
3183 .collect(),
3184 )
3185}
3186
3187pub fn build_gaussian_reml_eigen_cache(
3188 x: ArrayView2<'_, f64>,
3189 penalty: ArrayView2<'_, f64>,
3190 weights: Option<ArrayView1<'_, f64>>,
3191) -> Result<GaussianRemlEigenCache, EstimationError> {
3192 build_gaussian_reml_eigen_cache_with_nullspace_dim(x, penalty, None, weights)
3193}
3194
3195pub fn build_gaussian_reml_eigen_cache_with_nullspace_dim(
3196 x: ArrayView2<'_, f64>,
3197 penalty: ArrayView2<'_, f64>,
3198 nullspace_dim: Option<usize>,
3199 weights: Option<ArrayView1<'_, f64>>,
3200) -> Result<GaussianRemlEigenCache, EstimationError> {
3201 let penalty_owned = canonicalize_penalty(penalty);
3202 let penalty = penalty_owned.view();
3203 let n = x.nrows();
3204 validate_gaussian_reml_design(x, penalty, weights)?;
3205 let weight = gaussian_reml_weights(n, weights)?;
3206
3207 let xtwx = dense_xt_diag_x(x, weight.view());
3208 gaussian_reml_eigen_cache_from_xtwx(xtwx, penalty, nullspace_dim)
3209}
3210
3211fn validate_gaussian_reml_design(
3212 x: ArrayView2<'_, f64>,
3213 penalty: ArrayView2<'_, f64>,
3214 weights: Option<ArrayView1<'_, f64>>,
3215) -> Result<(), EstimationError> {
3216 let n = x.nrows();
3217 let p = x.ncols();
3218 if penalty.nrows() != p || penalty.ncols() != p {
3219 crate::bail_invalid_estim!(
3220 "Gaussian REML penalty shape mismatch: expected {p}x{p}, got {}x{}",
3221 penalty.nrows(),
3222 penalty.ncols()
3223 );
3224 }
3225 if x.iter().chain(penalty.iter()).any(|v| !v.is_finite()) {
3226 crate::bail_invalid_estim!("Gaussian REML inputs must be finite");
3227 }
3228 if let Some(w) = weights {
3229 if w.len() != n {
3230 crate::bail_invalid_estim!(
3231 "Gaussian REML weights length mismatch: expected {n}, got {}",
3232 w.len()
3233 );
3234 }
3235 if w.iter().any(|value| !value.is_finite() || *value < 0.0) {
3236 crate::bail_invalid_estim!("Gaussian REML weights must be finite and non-negative");
3237 }
3238 }
3239 Ok(())
3240}
3241
3242fn effective_observation_count(weight: ArrayView1<'_, f64>) -> usize {
3256 weight.iter().filter(|&&w| w > 0.0).count()
3257}
3258
3259fn gaussian_reml_weights(
3260 n: usize,
3261 weights: Option<ArrayView1<'_, f64>>,
3262) -> Result<Array1<f64>, EstimationError> {
3263 match weights {
3264 Some(w) => {
3265 if w.len() != n {
3266 crate::bail_invalid_estim!(
3267 "Gaussian REML weights length mismatch: expected {n}, got {}",
3268 w.len()
3269 );
3270 }
3271 if w.iter().any(|value| !value.is_finite() || *value < 0.0) {
3272 crate::bail_invalid_estim!("Gaussian REML weights must be finite and non-negative");
3273 }
3274 Ok(w.to_owned())
3275 }
3276 None => Ok(Array1::ones(n)),
3277 }
3278}
3279
3280fn gaussian_reml_eigen_cache_from_xtwx(
3281 xtwx: Array2<f64>,
3282 penalty: ArrayView2<'_, f64>,
3283 nullspace_dim: Option<usize>,
3284) -> Result<GaussianRemlEigenCache, EstimationError> {
3285 let xtwx_fingerprint = matrix_fingerprint(xtwx.view());
3286 let lower = gaussian_reml_cholesky_lower(xtwx)?;
3287 gaussian_reml_eigen_cache_from_lower(lower, penalty, nullspace_dim, xtwx_fingerprint)
3288}
3289
3290fn gaussian_reml_eigen_cache_from_lower(
3295 lower: Array2<f64>,
3296 penalty: ArrayView2<'_, f64>,
3297 nullspace_dim: Option<usize>,
3298 xtwx_fingerprint: u64,
3299) -> Result<GaussianRemlEigenCache, EstimationError> {
3300 gaussian_reml_eigen_cache_from_lower_with_transform(
3301 lower,
3302 penalty,
3303 nullspace_dim,
3304 xtwx_fingerprint,
3305 None,
3306 )
3307}
3308
3309fn gaussian_reml_eigen_cache_from_lower_with_transform(
3312 lower: Array2<f64>,
3313 penalty: ArrayView2<'_, f64>,
3314 nullspace_dim: Option<usize>,
3315 xtwx_fingerprint: u64,
3316 precomputed_transform: Option<Array2<f64>>,
3317) -> Result<GaussianRemlEigenCache, EstimationError> {
3318 let p = lower.nrows();
3319 if lower.ncols() != p {
3320 crate::bail_invalid_estim!("Gaussian REML Cholesky factor must be square");
3321 }
3322 let penalty_fingerprint = matrix_fingerprint(penalty);
3323 let logdet_xtwx = 2.0 * lower.diag().iter().map(|v| v.ln()).sum::<f64>();
3324 let transformed_penalty = match precomputed_transform {
3325 Some(transformed) => transformed,
3326 None => {
3327 let l_inv = invert_lower_triangular(&lower)?;
3328 let penalty_in_metric = dense_ab(l_inv.view(), penalty);
3329 dense_ab(penalty_in_metric.view(), l_inv.t())
3330 }
3331 };
3332 let (mut penalty_eigenvalues, eigenvectors) =
3333 transformed_penalty.eigh(Side::Lower).map_err(|_| {
3334 EstimationError::ModelIsIllConditioned {
3335 condition_number: f64::INFINITY,
3336 }
3337 })?;
3338 let max_abs_eig = penalty_eigenvalues
3348 .iter()
3349 .fold(0.0_f64, |acc, &value| acc.max(value.abs()));
3350 let eig_tol = max_abs_eig * EIGEN_REL_TOL;
3351 for value in &mut penalty_eigenvalues {
3352 if *value < 0.0 && value.abs() <= eig_tol {
3353 *value = 0.0;
3354 }
3355 if *value < 0.0 {
3356 crate::bail_invalid_estim!(
3357 "Gaussian REML penalty is not positive semidefinite; eigenvalue={value:.3e}"
3358 );
3359 }
3360 }
3361 let penalty_rank = penalty_eigenvalues
3362 .iter()
3363 .filter(|&&value| value > eig_tol)
3364 .count();
3365 let nullity = p - penalty_rank;
3366 if let Some(expected_nullity) = nullspace_dim
3367 && expected_nullity != nullity
3368 {
3369 crate::bail_invalid_estim!(
3370 "Gaussian REML penalty nullspace mismatch: expected {expected_nullity}, inferred {nullity}"
3371 );
3372 }
3373 let logdet_penalty_positive = gaussian_penalty_positive_logdet(penalty, penalty_rank)?;
3374 let coefficient_basis = solve_upper_triangular_matrix(&lower.t().to_owned(), &eigenvectors)?;
3375
3376 Ok(GaussianRemlEigenCache {
3377 penalty_eigenvalues,
3378 eigenvectors,
3379 coefficient_basis,
3380 xtwx_fingerprint,
3381 penalty_fingerprint,
3382 logdet_xtwx,
3383 logdet_penalty_positive,
3384 penalty_rank,
3385 nullity,
3386 })
3387}
3388
3389fn gaussian_reml_cholesky_lower(xtwx: Array2<f64>) -> Result<Array2<f64>, EstimationError> {
3390 let mut gpu_candidate = xtwx.clone();
3400 if gam_gpu::try_cholesky_lower_inplace(&mut gpu_candidate).is_some() {
3401 return Ok(gpu_candidate);
3402 }
3403 if let Ok(chol) = xtwx.cholesky(Side::Lower) {
3404 return Ok(chol.lower_triangular());
3405 }
3406 let p = xtwx.nrows();
3407 let trace: f64 = (0..p).map(|i| xtwx[[i, i]]).sum();
3408 if !trace.is_finite() || trace <= 0.0 {
3409 return Err(EstimationError::ModelIsIllConditioned {
3410 condition_number: f64::INFINITY,
3411 });
3412 }
3413 escalate_ridge(
3414 RidgeSchedule::geometric(1e-12 * trace / (p as f64), 6),
3415 |jitter| {
3416 let mut jittered = xtwx.clone();
3417 for i in 0..p {
3418 jittered[[i, i]] += jitter;
3419 }
3420 let mut gpu_candidate = jittered.clone();
3421 if gam_gpu::try_cholesky_lower_inplace(&mut gpu_candidate).is_some() {
3422 return Some(gpu_candidate);
3423 }
3424 jittered
3425 .cholesky(Side::Lower)
3426 .ok()
3427 .map(|chol| chol.lower_triangular())
3428 },
3429 )
3430 .map(|success| success.value)
3431 .map_err(|_exhausted| EstimationError::ModelIsIllConditioned {
3432 condition_number: f64::INFINITY,
3433 })
3434}
3435
3436fn gaussian_penalty_positive_logdet(
3437 penalty: ArrayView2<'_, f64>,
3438 penalty_rank: usize,
3439) -> Result<f64, EstimationError> {
3440 if penalty_rank == 0 {
3441 return Ok(0.0);
3442 }
3443 let (pen_eigs, _) = penalty.to_owned().eigh(Side::Lower).map_err(|_| {
3444 EstimationError::ModelIsIllConditioned {
3445 condition_number: f64::INFINITY,
3446 }
3447 })?;
3448 let pen_scale = pen_eigs
3452 .iter()
3453 .fold(0.0_f64, |acc, &value| acc.max(value.abs()));
3454 let pen_tol = pen_scale * EIGEN_REL_TOL;
3455 let mut positive_eigs: Vec<f64> = pen_eigs
3456 .iter()
3457 .copied()
3458 .filter(|&value| value > pen_tol)
3459 .collect();
3460 if positive_eigs.len() != penalty_rank {
3461 positive_eigs = pen_eigs
3462 .iter()
3463 .copied()
3464 .filter(|&value| value > 0.0)
3465 .collect();
3466 positive_eigs.sort_by(|a, b| b.total_cmp(a));
3467 if positive_eigs.len() < penalty_rank {
3468 return Err(EstimationError::ModelIsIllConditioned {
3469 condition_number: f64::INFINITY,
3470 });
3471 }
3472 positive_eigs.truncate(penalty_rank);
3473 }
3474 Ok(positive_eigs.iter().map(|value| value.ln()).sum())
3475}
3476
3477fn validate_gaussian_reml_eigen_cache(
3478 cache: &GaussianRemlEigenCache,
3479 p: usize,
3480) -> Result<(), EstimationError> {
3481 if cache.penalty_eigenvalues.len() != p
3482 || cache.eigenvectors.dim() != (p, p)
3483 || cache.coefficient_basis.dim() != (p, p)
3484 {
3485 crate::bail_invalid_estim!(
3486 "Gaussian REML eigen cache dimension mismatch: expected {p} coefficients"
3487 );
3488 }
3489 if cache.penalty_rank > p || cache.nullity > p || cache.penalty_rank + cache.nullity != p {
3490 crate::bail_invalid_estim!(
3491 "Gaussian REML eigen cache rank/nullity mismatch: rank={}, nullity={}, p={p}",
3492 cache.penalty_rank,
3493 cache.nullity
3494 );
3495 }
3496 if !(cache.logdet_xtwx.is_finite() && cache.logdet_penalty_positive.is_finite()) {
3497 crate::bail_invalid_estim!("Gaussian REML eigen cache log-determinants must be finite");
3498 }
3499 if cache
3500 .penalty_eigenvalues
3501 .iter()
3502 .any(|value| !value.is_finite() || *value < 0.0)
3503 || cache.eigenvectors.iter().any(|value| !value.is_finite())
3504 || cache
3505 .coefficient_basis
3506 .iter()
3507 .any(|value| !value.is_finite())
3508 {
3509 crate::bail_invalid_estim!(
3510 "Gaussian REML eigen cache entries must be finite with non-negative eigenvalues"
3511 .to_string(),
3512 );
3513 }
3514 Ok::<(), _>(())
3515}
3516
3517fn prepare_gaussian_reml(
3518 x: ArrayView2<'_, f64>,
3519 y: ArrayView2<'_, f64>,
3520 penalty: ArrayView2<'_, f64>,
3521 nullspace_dim: Option<usize>,
3522 weights: Option<ArrayView1<'_, f64>>,
3523 eigen_cache: Option<&GaussianRemlEigenCache>,
3524) -> Result<GaussianRemlPrepared, EstimationError> {
3525 let penalty_owned = canonicalize_penalty(penalty);
3528 let penalty = penalty_owned.view();
3529 let n = x.nrows();
3530 let p = x.ncols();
3531 let d = y.ncols();
3532 validate_gaussian_reml_design(x, penalty, weights)?;
3533 if y.nrows() != n {
3534 crate::bail_invalid_estim!(
3535 "Gaussian REML row mismatch: X has {n} rows but Y has {}",
3536 y.nrows()
3537 );
3538 }
3539 if y.iter().any(|v| !v.is_finite()) {
3540 crate::bail_invalid_estim!("Gaussian REML inputs must be finite");
3541 }
3542 let weight = gaussian_reml_weights(n, weights)?;
3543 let n_effective = effective_observation_count(weight.view());
3544
3545 let xtwy = dense_xt_diag_y(x, weight.view(), y);
3546 let ywy = Array1::from_iter((0..d).map(|j| {
3547 let mut value = 0.0;
3548 for row in 0..n {
3549 value += weight[row] * y[[row, j]] * y[[row, j]];
3550 }
3551 value
3552 }));
3553 let xtwx = dense_xt_diag_x(x, weight.view());
3554
3555 if let Some(cache) = eigen_cache {
3556 validate_gaussian_reml_eigen_cache(cache, p)?;
3557 let xtwx_fingerprint = matrix_fingerprint(xtwx.view());
3558 if cache.xtwx_fingerprint != xtwx_fingerprint {
3559 crate::bail_invalid_estim!("Gaussian REML eigen cache X'WX mismatch");
3560 }
3561 let penalty_fingerprint = matrix_fingerprint(penalty);
3562 if cache.penalty_fingerprint != penalty_fingerprint {
3563 crate::bail_invalid_estim!("Gaussian REML eigen cache penalty mismatch");
3564 }
3565 if let Some(expected_nullity) = nullspace_dim
3566 && expected_nullity != cache.nullity
3567 {
3568 crate::bail_invalid_estim!(
3569 "Gaussian REML eigen cache nullspace mismatch: expected {expected_nullity}, got {}",
3570 cache.nullity
3571 );
3572 }
3573 if n_effective <= cache.nullity {
3574 crate::bail_invalid_estim!(
3575 "Gaussian REML requires more positive-weight rows than the nullspace dimension; got n_effective={n_effective}, nullity={}",
3576 cache.nullity
3577 );
3578 }
3579 let projected_rhs = dense_atb(cache.coefficient_basis.view(), xtwy.view());
3580 let projected_rhs_squared = projected_rhs.mapv(|value| value * value);
3581 return Ok(GaussianRemlPrepared {
3582 cache: cache.clone(),
3583 ywy,
3584 projected_rhs_squared,
3585 projected_rhs,
3586 n_effective,
3587 n_outputs: d,
3588 });
3589 }
3590
3591 let cache = gaussian_reml_eigen_cache_from_xtwx(xtwx, penalty, nullspace_dim)?;
3592 if n_effective <= cache.nullity {
3593 crate::bail_invalid_estim!(
3594 "Gaussian REML requires more positive-weight rows than the nullspace dimension; got n_effective={n_effective}, nullity={}",
3595 cache.nullity
3596 );
3597 }
3598 let projected_rhs = dense_atb(cache.coefficient_basis.view(), xtwy.view());
3599 let projected_rhs_squared = projected_rhs.mapv(|value| value * value);
3600
3601 Ok(GaussianRemlPrepared {
3602 cache,
3603 ywy,
3604 projected_rhs_squared,
3605 projected_rhs,
3606 n_effective,
3607 n_outputs: d,
3608 })
3609}
3610
3611impl GaussianRemlPrepared {
3612 fn nu(&self) -> f64 {
3613 self.n_effective as f64 - self.cache.nullity as f64
3614 }
3615
3616 fn evaluate(&self, rho: f64) -> ObjectiveEval {
3617 evaluate_reml_parts(
3618 &self.cache,
3619 self.ywy.view(),
3620 self.projected_rhs_squared.view(),
3621 self.n_effective,
3622 self.n_outputs,
3623 rho,
3624 )
3625 }
3626
3627 fn coefficients(&self, lambda: f64) -> Array2<f64> {
3628 let mut scaled = self.projected_rhs.clone();
3629 for i in 0..self.cache.penalty_eigenvalues.len() {
3630 let scale = 1.0 / (1.0 + lambda * self.cache.penalty_eigenvalues[i]);
3631 for value in scaled.row_mut(i) {
3632 *value *= scale;
3633 }
3634 }
3635 dense_ab(self.cache.coefficient_basis.view(), scaled.view())
3636 }
3637
3638 fn sigma2(&self, lambda: f64) -> Array1<f64> {
3639 let nu = self.nu();
3640 Array1::from_iter((0..self.n_outputs).map(|j| {
3641 let mut fitted_quadratic = 0.0;
3642 for i in 0..self.cache.penalty_eigenvalues.len() {
3643 let denom = 1.0 + lambda * self.cache.penalty_eigenvalues[i];
3644 fitted_quadratic += self.projected_rhs_squared[[i, j]] / denom;
3645 }
3646 (self.ywy[j] - fitted_quadratic) / nu
3647 }))
3648 }
3649}
3650
3651fn validate_reml_profile_residuals(
3658 cache: &GaussianRemlEigenCache,
3659 ywy: ArrayView1<'_, f64>,
3660 projected_rhs_squared: ArrayView2<'_, f64>,
3661 rho: f64,
3662) -> Result<(), EstimationError> {
3663 for output in 0..ywy.len() {
3664 let mut fitted_quadratic = 0.0;
3665 for eig in 0..cache.penalty_eigenvalues.len() {
3666 fitted_quadratic += projected_rhs_squared[[eig, output]]
3667 * modal_kernels(rho, cache.penalty_eigenvalues[eig]).v;
3668 }
3669 let residual = ywy[output] - fitted_quadratic;
3670 if !(residual.is_finite() && residual > 0.0) {
3671 return Err(EstimationError::InvalidInput(format!(
3672 "Gaussian REML profiled residual {output} is not strictly positive at rho={rho}: {residual}; the profiled dispersion has no finite value"
3673 )));
3674 }
3675 }
3676 Ok(())
3677}
3678
3679const RHO_BRACKET_RESOLUTION: f64 = 1.0e-12;
3767
3768const fn dfs_max_depth(range: f64, resolution: f64) -> usize {
3773 let mut width = range;
3774 let mut depth = 0usize;
3775 while width > resolution {
3776 width *= 0.5;
3777 depth += 1;
3778 }
3779 depth
3780}
3781
3782const MAX_DEPTH: usize = dfs_max_depth(RHO_UPPER - RHO_LOWER, RHO_BRACKET_RESOLUTION);
3784
3785#[derive(Clone, Copy)]
3787struct Interval {
3788 lo: f64,
3789 hi: f64,
3790}
3791
3792impl Interval {
3793 fn entire() -> Self {
3794 Self {
3795 lo: f64::NEG_INFINITY,
3796 hi: f64::INFINITY,
3797 }
3798 }
3799}
3800
3801fn round_down(x: f64) -> f64 {
3804 if x.is_nan() || x == f64::NEG_INFINITY {
3805 return x;
3806 }
3807 if x == 0.0 {
3808 return -f64::from_bits(1);
3809 }
3810 let bits = x.to_bits();
3811 let next = if x > 0.0 { bits - 1 } else { bits + 1 };
3812 f64::from_bits(next)
3813}
3814
3815fn round_up(x: f64) -> f64 {
3818 if x.is_nan() || x == f64::INFINITY {
3819 return x;
3820 }
3821 if x == 0.0 {
3822 return f64::from_bits(1);
3823 }
3824 let bits = x.to_bits();
3825 let next = if x > 0.0 { bits + 1 } else { bits - 1 };
3826 f64::from_bits(next)
3827}
3828
3829fn add_down(lhs: f64, rhs: f64) -> f64 {
3830 round_down(lhs + rhs)
3831}
3832
3833fn add_up(lhs: f64, rhs: f64) -> f64 {
3834 round_up(lhs + rhs)
3835}
3836
3837fn nonnegative_product_interval(lhs: f64, rhs: Interval) -> Option<Interval> {
3841 if !(lhs.is_finite()
3842 && lhs >= 0.0
3843 && rhs.lo.is_finite()
3844 && rhs.hi.is_finite()
3845 && rhs.lo >= 0.0
3846 && rhs.hi >= rhs.lo)
3847 {
3848 return None;
3849 }
3850 Some(Interval {
3851 lo: round_down(lhs * rhs.lo).max(0.0),
3852 hi: round_up(lhs * rhs.hi),
3853 })
3854}
3855
3856fn nonnegative_square_interval(bounds: Interval) -> Option<Interval> {
3858 if !(bounds.lo.is_finite()
3859 && bounds.hi.is_finite()
3860 && bounds.lo >= 0.0
3861 && bounds.hi >= bounds.lo)
3862 {
3863 return None;
3864 }
3865 Some(Interval {
3866 lo: round_down(bounds.lo * bounds.lo).max(0.0),
3867 hi: round_up(bounds.hi * bounds.hi),
3868 })
3869}
3870
3871fn conservative_interval(lo: f64, hi: f64, magnitude: f64, operations: usize) -> Interval {
3877 if !(lo.is_finite() && hi.is_finite() && magnitude.is_finite() && lo <= hi) {
3878 return Interval::entire();
3879 }
3880 let n_eps = (operations as f64) * f64::EPSILON;
3881 if n_eps >= 1.0 {
3882 return Interval::entire();
3883 }
3884 let pad =
3885 (n_eps / (1.0 - n_eps)) * magnitude.max(lo.abs()).max(hi.abs()).max(f64::MIN_POSITIVE);
3886 Interval {
3887 lo: round_down(lo - pad),
3888 hi: round_up(hi + pad),
3889 }
3890}
3891
3892#[derive(Clone, Copy)]
3897struct KernelRange {
3898 u_lo: f64,
3899 u_hi: f64,
3900 v_lo: f64,
3901 v_hi: f64,
3902 w_lo: f64,
3903 w_hi: f64,
3904 k_lo: f64,
3905 k_hi: f64,
3906}
3907
3908fn kernel_ranges(log_t_lo: f64, log_t_hi: f64) -> KernelRange {
3909 let kernels = |log_t: f64| modal_kernels(log_t, 1.0);
3910 let left = kernels(log_t_lo);
3911 let right = kernels(log_t_hi);
3912
3913 let u_lo = left.u;
3916 let u_hi = right.u;
3917 let v_lo = right.v;
3918 let v_hi = left.v;
3919
3920 let w_a = left.w;
3922 let w_b = right.w;
3923 let w_lo = w_a.min(w_b);
3924 let w_hi = if log_t_lo <= 0.0 && 0.0 <= log_t_hi {
3925 0.25
3926 } else {
3927 w_a.max(w_b)
3928 };
3929
3930 let sqrt3 = 3.0_f64.sqrt();
3933 let cp_lo = (2.0 - sqrt3).ln();
3934 let cp_hi = (2.0 + sqrt3).ln();
3935 let mut k_lo = left.k.min(right.k);
3936 let mut k_hi = left.k.max(right.k);
3937 if log_t_lo < cp_lo && cp_lo < log_t_hi {
3938 let kc = kernels(cp_lo).k;
3939 k_lo = k_lo.min(kc);
3940 k_hi = k_hi.max(kc);
3941 }
3942 if log_t_lo < cp_hi && cp_hi < log_t_hi {
3943 let kc = kernels(cp_hi).k;
3944 k_lo = k_lo.min(kc);
3945 k_hi = k_hi.max(kc);
3946 }
3947
3948 KernelRange {
3949 u_lo: round_down(u_lo).max(0.0),
3950 u_hi: round_up(u_hi),
3951 v_lo: round_down(v_lo).max(0.0),
3952 v_hi: round_up(v_hi),
3953 w_lo: round_down(w_lo).max(0.0),
3954 w_hi: round_up(w_hi),
3955 k_lo: round_down(k_lo),
3956 k_hi: round_up(k_hi),
3957 }
3958}
3959
3960fn reml_deriv_enclosure(
3968 cache: &GaussianRemlEigenCache,
3969 ywy: ArrayView1<'_, f64>,
3970 projected_rhs_squared: ArrayView2<'_, f64>,
3971 n_effective: usize,
3972 n_outputs: usize,
3973 a: f64,
3974 b: f64,
3975) -> (Interval, Interval) {
3976 reml_deriv_enclosure_profile(
3977 cache,
3978 ywy,
3979 projected_rhs_squared,
3980 n_outputs,
3981 n_effective as f64 - cache.nullity as f64,
3982 a,
3983 b,
3984 )
3985}
3986
3987fn reml_deriv_enclosure_profile(
3994 cache: &GaussianRemlEigenCache,
3995 ywy: ArrayView1<'_, f64>,
3996 projected_rhs_squared: ArrayView2<'_, f64>,
3997 logdet_output_count: usize,
3998 dispersion_dof: f64,
3999 a: f64,
4000 b: f64,
4001) -> (Interval, Interval) {
4002 let d = logdet_output_count as f64;
4003 let rank = cache.penalty_rank as f64;
4004 let half_d = 0.5 * d;
4005 let half_nu = 0.5 * dispersion_dof;
4006 let mut sum_u_lo = 0.0;
4009 let mut sum_u_hi = 0.0;
4010 let mut sum_w_lo = 0.0;
4011 let mut sum_w_hi = 0.0;
4012 for &delta in &cache.penalty_eigenvalues {
4013 if delta > 0.0 {
4014 let log_delta = delta.ln();
4015 let kr = kernel_ranges(a + log_delta, b + log_delta);
4016 sum_u_lo = add_down(sum_u_lo, kr.u_lo);
4017 sum_u_hi = add_up(sum_u_hi, kr.u_hi);
4018 sum_w_lo = add_down(sum_w_lo, kr.w_lo);
4019 sum_w_hi = add_up(sum_w_hi, kr.w_hi);
4020 }
4021 }
4022 let g1_lo = round_down(half_d * round_down(sum_u_lo - rank));
4023 let g1_hi = round_up(half_d * round_up(sum_u_hi - rank));
4024
4025 let mut g2_lo = 0.0;
4028 let mut g2_hi = 0.0;
4029 let mut vpp_disp_lo = 0.0;
4030 let mut vpp_disp_hi = 0.0;
4031 for j in 0..ywy.len() {
4032 let mut num_lo = 0.0; let mut num_hi = 0.0;
4034 let mut sv_lo = 0.0; let mut sv_hi = 0.0;
4036 let mut dph_lo = 0.0; let mut dph_hi = 0.0;
4038 for eig in 0..cache.penalty_eigenvalues.len() {
4039 let delta = cache.penalty_eigenvalues[eig];
4040 let c2 = projected_rhs_squared[[eig, j]];
4041 let log_delta = if delta == 0.0 {
4042 f64::NEG_INFINITY
4043 } else {
4044 delta.ln()
4045 };
4046 let kr = kernel_ranges(a + log_delta, b + log_delta);
4047 let Some(w_product) = nonnegative_product_interval(
4048 c2,
4049 Interval {
4050 lo: kr.w_lo,
4051 hi: kr.w_hi,
4052 },
4053 ) else {
4054 return (Interval::entire(), Interval::entire());
4055 };
4056 let Some(v_product) = nonnegative_product_interval(
4057 c2,
4058 Interval {
4059 lo: kr.v_lo,
4060 hi: kr.v_hi,
4061 },
4062 ) else {
4063 return (Interval::entire(), Interval::entire());
4064 };
4065 num_lo = add_down(num_lo, w_product.lo);
4066 num_hi = add_up(num_hi, w_product.hi);
4067 sv_lo = add_down(sv_lo, v_product.lo);
4068 sv_hi = add_up(sv_hi, v_product.hi);
4069 dph_lo = add_down(dph_lo, round_down(c2 * kr.k_lo));
4070 dph_hi = add_up(dph_hi, round_up(c2 * kr.k_hi));
4071 }
4072 let dp_lo = round_down(ywy[j] - sv_hi);
4076 let dp_hi = round_up(ywy[j] - sv_lo);
4077 if !(dp_lo.is_finite() && dp_hi.is_finite() && dp_lo > 0.0 && dp_hi >= dp_lo) {
4078 return (Interval::entire(), Interval::entire());
4079 }
4080
4081 let ratio_lo = round_down(num_lo / dp_hi).max(0.0);
4083 let ratio_hi = round_up(num_hi / dp_lo);
4084 g2_lo = add_down(g2_lo, ratio_lo);
4085 g2_hi = add_up(g2_hi, ratio_hi);
4086
4087 let quotients = [
4090 dph_lo / dp_lo,
4091 dph_lo / dp_hi,
4092 dph_hi / dp_lo,
4093 dph_hi / dp_hi,
4094 ];
4095 let adp_lo = round_down(quotients.iter().copied().fold(f64::INFINITY, f64::min));
4096 let adp_hi = round_up(quotients.iter().copied().fold(f64::NEG_INFINITY, f64::max));
4097
4098 let bl = round_down(num_lo / dp_hi).max(0.0);
4100 let bh = round_up(num_hi / dp_lo);
4101 let Some(squared_ratio) = nonnegative_square_interval(Interval { lo: bl, hi: bh }) else {
4102 return (Interval::entire(), Interval::entire());
4103 };
4104
4105 vpp_disp_lo = add_down(vpp_disp_lo, round_down(adp_lo - squared_ratio.hi));
4107 vpp_disp_hi = add_up(vpp_disp_hi, round_up(adp_hi - squared_ratio.lo));
4108 }
4109
4110 let vp_lo = add_down(g1_lo, round_down(half_nu * g2_lo));
4111 let vp_hi = add_up(g1_hi, round_up(half_nu * g2_hi));
4112 let vpp_lo = add_down(
4113 round_down(half_d * sum_w_lo),
4114 round_down(half_nu * vpp_disp_lo),
4115 );
4116 let vpp_hi = add_up(round_up(half_d * sum_w_hi), round_up(half_nu * vpp_disp_hi));
4117
4118 let operations = 64usize.saturating_add(
4119 32usize.saturating_mul(
4120 cache
4121 .penalty_eigenvalues
4122 .len()
4123 .saturating_mul(ywy.len().max(1)),
4124 ),
4125 );
4126 let vp_magnitude = g1_lo.abs() + g1_hi.abs() + half_nu.abs() * (g2_lo.abs() + g2_hi.abs());
4127 let vpp_magnitude = half_d.abs() * (sum_w_lo.abs() + sum_w_hi.abs())
4128 + half_nu.abs() * (vpp_disp_lo.abs() + vpp_disp_hi.abs());
4129 (
4130 conservative_interval(vp_lo, vp_hi, vp_magnitude, operations),
4131 conservative_interval(vpp_lo, vpp_hi, vpp_magnitude, operations),
4132 )
4133}
4134
4135#[derive(Clone, Copy, Debug)]
4136struct StationaryRoot {
4137 rho: f64,
4138 bracket: [f64; 2],
4139}
4140
4141#[derive(Clone, Copy, Debug)]
4142struct ProfileSelection {
4143 rho: f64,
4144 projected_gradient_residual: f64,
4145}
4146
4147#[derive(Clone, Copy)]
4148struct ProfileSearchControls {
4149 lower: f64,
4150 upper: f64,
4151 resolution: f64,
4152 max_depth: usize,
4153}
4154
4155impl ProfileSearchControls {
4156 const PRODUCTION: Self = Self {
4157 lower: RHO_LOWER,
4158 upper: RHO_UPPER,
4159 resolution: RHO_BRACKET_RESOLUTION,
4160 max_depth: MAX_DEPTH,
4161 };
4162}
4163
4164fn profile_search_refusal(
4165 eval: &impl Fn(f64) -> ObjectiveEval,
4166 checkpoint: f64,
4167 reason: String,
4168) -> EstimationError {
4169 let e = eval(checkpoint);
4170 EstimationError::RemlDidNotConverge {
4171 context: "closed-form Gaussian profiled REML stationary search".to_string(),
4172 reason,
4173 iterations: 0,
4174 final_value: e.cost,
4175 projected_grad_norm: e.grad.is_finite().then_some(e.grad.abs()),
4176 stationarity_bound: GRAD_TOL * (1.0 + e.cost.abs()),
4177 stationarity_bound_rung: None,
4180 rho_checkpoint: vec![checkpoint],
4181 }
4182}
4183
4184fn refine_stationary_rho_core(
4190 eval: &impl Fn(f64) -> ObjectiveEval,
4191 mut lo: f64,
4192 mut hi: f64,
4193 resolution: f64,
4194 mut hint: Option<f64>,
4195) -> Result<StationaryRoot, EstimationError> {
4196 let mut left = eval(lo);
4197 let mut right = eval(hi);
4198 if left.grad == 0.0 {
4199 return Ok(StationaryRoot {
4200 rho: lo,
4201 bracket: [lo, lo],
4202 });
4203 }
4204 if right.grad == 0.0 {
4205 return Ok(StationaryRoot {
4206 rho: hi,
4207 bracket: [hi, hi],
4208 });
4209 }
4210 if left.grad.is_sign_positive() == right.grad.is_sign_positive() {
4211 return Err(profile_search_refusal(
4212 eval,
4213 0.5 * (lo + hi),
4214 format!("stationary refinement received a non-bracketing cell [{lo}, {hi}]"),
4215 ));
4216 }
4217
4218 loop {
4219 let width = hi - lo;
4220 let scale = 1.0 + lo.abs().max(hi.abs());
4221 if width <= resolution * scale {
4222 let midpoint = lo + 0.5 * width;
4223 let middle = if midpoint > lo && midpoint < hi {
4224 Some((midpoint, eval(midpoint)))
4225 } else {
4226 None
4227 };
4228 let mut representative = (lo, left);
4229 if right.grad.abs() < representative.1.grad.abs() {
4230 representative = (hi, right);
4231 }
4232 if let Some(candidate) = middle
4233 && candidate.1.grad.abs() < representative.1.grad.abs()
4234 {
4235 representative = candidate;
4236 }
4237 return Ok(StationaryRoot {
4238 rho: representative.0,
4239 bracket: [lo, hi],
4240 });
4241 }
4242
4243 let midpoint = lo + 0.5 * width;
4244 if !(midpoint > lo && midpoint < hi) {
4245 return Err(profile_search_refusal(
4246 eval,
4247 midpoint,
4248 format!(
4249 "stationary root on [{lo}, {hi}] reached floating-point spacing before rho resolution {resolution}"
4250 ),
4251 ));
4252 }
4253 let guard = 0.25 * width;
4254 let base = if left.grad.abs() <= right.grad.abs() {
4255 (lo, left)
4256 } else {
4257 (hi, right)
4258 };
4259 let newton = if base.1.hess != 0.0 {
4260 base.0 - base.1.grad / base.1.hess
4261 } else {
4262 f64::NAN
4263 };
4264 let candidate = hint
4265 .take()
4266 .filter(|&rho| rho >= lo + guard && rho <= hi - guard)
4267 .or_else(|| {
4268 (newton.is_finite() && newton >= lo + guard && newton <= hi - guard)
4269 .then_some(newton)
4270 })
4271 .unwrap_or(midpoint);
4272 if !(candidate > lo && candidate < hi) {
4273 return Err(profile_search_refusal(
4274 eval,
4275 midpoint,
4276 format!(
4277 "stationary refinement could not represent an interior point on [{lo}, {hi}]"
4278 ),
4279 ));
4280 }
4281 let current = eval(candidate);
4282 if current.grad == 0.0 {
4283 return Ok(StationaryRoot {
4284 rho: candidate,
4285 bracket: [candidate, candidate],
4286 });
4287 }
4288 if current.grad.is_sign_positive() == left.grad.is_sign_positive() {
4289 lo = candidate;
4290 left = current;
4291 } else {
4292 hi = candidate;
4293 right = current;
4294 }
4295 }
4296}
4297
4298fn interval_contains(interval: Interval, value: f64) -> bool {
4299 value.is_finite() && interval.lo <= value && value <= interval.hi
4300}
4301
4302fn enumerate_and_select_rho_with_controls(
4307 eval: impl Fn(f64) -> ObjectiveEval,
4308 enclose: impl Fn(f64, f64) -> (Interval, Interval),
4309 init_rho: Option<f64>,
4310 controls: ProfileSearchControls,
4311 mut visit: impl FnMut(StationaryRoot, &ObjectiveEval),
4312) -> Result<ProfileSelection, EstimationError> {
4313 const CAP: usize = MAX_DEPTH + 4;
4314 let mut stack = [(0.0f64, 0.0f64, 0usize); CAP];
4315 let mut top = 0usize;
4316 stack[top] = (controls.lower, controls.upper, 0);
4317 top += 1;
4318
4319 let lower_eval = eval(controls.lower);
4320 let upper_eval = eval(controls.upper);
4321 let (mut best_rho, mut best_eval) = if upper_eval.cost < lower_eval.cost {
4322 (controls.upper, upper_eval)
4323 } else {
4324 (controls.lower, lower_eval)
4325 };
4326 let mut last_root: Option<StationaryRoot> = None;
4327
4328 while top > 0 {
4329 top -= 1;
4330 let (a, b, depth) = stack[top];
4331 let ea = eval(a);
4332 let eb = eval(b);
4333 let (dv, dvv) = enclose(a, b);
4334 if !(interval_contains(dv, ea.grad)
4335 && interval_contains(dv, eb.grad)
4336 && interval_contains(dvv, ea.hess)
4337 && interval_contains(dvv, eb.hess))
4338 {
4339 return Err(profile_search_refusal(
4340 &eval,
4341 0.5 * (a + b),
4342 format!(
4343 "analytic derivative enclosure [{}, {}] / curvature enclosure [{}, {}] missed an endpoint jet on [{a}, {b}]",
4344 dv.lo, dv.hi, dvv.lo, dvv.hi
4345 ),
4346 ));
4347 }
4348 if dv.lo > 0.0 || dv.hi < 0.0 {
4349 continue;
4350 }
4351
4352 let monotone = dvv.lo > 0.0 || dvv.hi < 0.0;
4353 let at_floor = depth >= controls.max_depth
4354 || (b - a) <= controls.resolution * (1.0 + a.abs().max(b.abs()));
4355 if !monotone && at_floor {
4356 return Err(profile_search_refusal(
4357 &eval,
4358 0.5 * (a + b),
4359 format!(
4360 "stationary structure remained non-monotone on [{a}, {b}] at rho resolution {}",
4361 controls.resolution
4362 ),
4363 ));
4364 }
4365
4366 if monotone {
4367 let crosses = (ea.grad <= 0.0 && eb.grad >= 0.0) || (ea.grad >= 0.0 && eb.grad <= 0.0);
4368 if crosses {
4369 let hint = init_rho.filter(|rho| rho.is_finite() && *rho >= a && *rho <= b);
4370 let root = refine_stationary_rho_core(&eval, a, b, controls.resolution, hint)?;
4371 let duplicate = last_root.is_some_and(|previous| {
4372 root.rho.to_bits() == previous.rho.to_bits()
4373 || (root.bracket[0] <= previous.bracket[1]
4374 && previous.bracket[0] <= root.bracket[1])
4375 });
4376 if !duplicate {
4377 let e = eval(root.rho);
4378 if e.cost < best_eval.cost {
4379 best_rho = root.rho;
4380 best_eval = e;
4381 }
4382 visit(root, &e);
4383 last_root = Some(root);
4384 }
4385 }
4386 continue;
4387 }
4388
4389 let mid = a + 0.5 * (b - a);
4390 if !(mid > a && mid < b) || top + 2 > CAP {
4391 return Err(profile_search_refusal(
4392 &eval,
4393 mid,
4394 format!("stationary subdivision could not continue on [{a}, {b}]"),
4395 ));
4396 }
4397 stack[top] = (mid, b, depth + 1);
4398 top += 1;
4399 stack[top] = (a, mid, depth + 1);
4400 top += 1;
4401 }
4402
4403 if !(best_eval.cost.is_finite() && best_eval.grad.is_finite()) {
4404 return Err(EstimationError::InvalidInput(
4405 "Gaussian REML profiled search produced no finite candidate".to_string(),
4406 ));
4407 }
4408 let projected_gradient_residual = if best_rho == controls.lower {
4409 (-best_eval.grad).max(0.0)
4410 } else if best_rho == controls.upper {
4411 best_eval.grad.max(0.0)
4412 } else {
4413 best_eval.grad.abs()
4414 };
4415 Ok(ProfileSelection {
4416 rho: best_rho,
4417 projected_gradient_residual,
4418 })
4419}
4420
4421fn enumerate_and_select_rho(
4422 eval: impl Fn(f64) -> ObjectiveEval,
4423 enclose: impl Fn(f64, f64) -> (Interval, Interval),
4424 init_rho: Option<f64>,
4425 visit: impl FnMut(StationaryRoot, &ObjectiveEval),
4426) -> Result<ProfileSelection, EstimationError> {
4427 enumerate_and_select_rho_with_controls(
4428 eval,
4429 enclose,
4430 init_rho,
4431 ProfileSearchControls::PRODUCTION,
4432 visit,
4433 )
4434}
4435
4436fn compactified_limit_costs(
4444 cache: &GaussianRemlEigenCache,
4445 ywy: ArrayView1<'_, f64>,
4446 projected_rhs_squared: ArrayView2<'_, f64>,
4447 n_outputs: usize,
4448 nu: f64,
4449) -> [f64; 2] {
4450 let mut sum_log_delta_pos = 0.0;
4451 for &delta in &cache.penalty_eigenvalues {
4452 if delta > 0.0 {
4453 sum_log_delta_pos += delta.ln();
4454 }
4455 }
4456 let logdet_limit = cache.logdet_xtwx + sum_log_delta_pos - cache.logdet_penalty_positive;
4457 let mut plus_inf = 0.5 * (n_outputs as f64) * logdet_limit;
4458 for j in 0..ywy.len() {
4459 let mut null_mass = 0.0;
4460 for i in 0..cache.penalty_eigenvalues.len() {
4461 if cache.penalty_eigenvalues[i] == 0.0 {
4462 null_mass += projected_rhs_squared[[i, j]];
4463 }
4464 }
4465 let dp_inf = ywy[j] - null_mass;
4466 if !(dp_inf.is_finite() && dp_inf > 0.0) {
4467 plus_inf = f64::INFINITY;
4468 break;
4469 }
4470 plus_inf += 0.5 * nu * (1.0 + (2.0 * std::f64::consts::PI * dp_inf / nu).ln());
4471 }
4472 [f64::INFINITY, plus_inf]
4473}
4474
4475#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4477pub enum RhoLandscape {
4478 NoInteriorOptimum,
4481 UniqueInterior,
4483 MultipleInterior,
4485}
4486
4487#[derive(Clone, Debug)]
4497pub struct RhoLandscapeCertificate {
4498 pub stationary_count: usize,
4499 pub root_brackets: Vec<[f64; 2]>,
4500 pub landscape: RhoLandscape,
4501 pub window_costs: [f64; 2],
4502 pub limit_costs: [f64; 2],
4503 pub selected_rho: f64,
4504 pub boundary_optimum: bool,
4505 pub rho_window: [f64; 2],
4506}
4507
4508fn rho_landscape_certificate_from_parts(
4509 cache: &GaussianRemlEigenCache,
4510 ywy: ArrayView1<'_, f64>,
4511 projected_rhs_squared: ArrayView2<'_, f64>,
4512 n_effective: usize,
4513 n_outputs: usize,
4514 init_rho: Option<f64>,
4515) -> Result<RhoLandscapeCertificate, EstimationError> {
4516 validate_reml_profile_residuals(cache, ywy, projected_rhs_squared, RHO_LOWER)?;
4517 let nu = n_effective as f64 - cache.nullity as f64;
4518 let eval = |rho: f64| {
4519 evaluate_reml_parts(
4520 cache,
4521 ywy,
4522 projected_rhs_squared,
4523 n_effective,
4524 n_outputs,
4525 rho,
4526 )
4527 };
4528 let window_costs = [eval(RHO_LOWER).cost, eval(RHO_UPPER).cost];
4529 let limit_costs = compactified_limit_costs(cache, ywy, projected_rhs_squared, n_outputs, nu);
4530
4531 let mut root_brackets = Vec::new();
4532 let selection = if cache.penalty_rank == 0 {
4533 ProfileSelection {
4534 rho: init_rho.unwrap_or(0.0).clamp(RHO_LOWER, RHO_UPPER),
4535 projected_gradient_residual: 0.0,
4536 }
4537 } else {
4538 let enclose = |a: f64, b: f64| {
4539 reml_deriv_enclosure(
4540 cache,
4541 ywy,
4542 projected_rhs_squared,
4543 n_effective,
4544 n_outputs,
4545 a,
4546 b,
4547 )
4548 };
4549 enumerate_and_select_rho(&eval, &enclose, init_rho, |root, _e| {
4550 root_brackets.push(root.bracket)
4551 })?
4552 };
4553
4554 let stationary_count = root_brackets.len();
4555 let landscape = match stationary_count {
4556 0 => RhoLandscape::NoInteriorOptimum,
4557 1 => RhoLandscape::UniqueInterior,
4558 _ => RhoLandscape::MultipleInterior,
4559 };
4560 let boundary_optimum = matches!(landscape, RhoLandscape::NoInteriorOptimum);
4561
4562 Ok(RhoLandscapeCertificate {
4563 stationary_count,
4564 root_brackets,
4565 landscape,
4566 window_costs,
4567 limit_costs,
4568 selected_rho: selection.rho,
4569 boundary_optimum,
4570 rho_window: [RHO_LOWER, RHO_UPPER],
4571 })
4572}
4573
4574pub fn gaussian_reml_rho_landscape_certificate(
4578 x: ArrayView2<'_, f64>,
4579 y: ArrayView1<'_, f64>,
4580 penalty: ArrayView2<'_, f64>,
4581 nullspace_dim: Option<usize>,
4582 weights: Option<ArrayView1<'_, f64>>,
4583 init_rho: Option<f64>,
4584) -> Result<RhoLandscapeCertificate, EstimationError> {
4585 if init_rho.is_some_and(|rho| !rho.is_finite()) {
4586 crate::bail_invalid_estim!(
4587 "Gaussian REML rho-landscape certificate requires a finite rho hint"
4588 );
4589 }
4590 let y2 = y.insert_axis(Axis(1));
4591 let prepared = prepare_gaussian_reml(x, y2.view(), penalty, nullspace_dim, weights, None)?;
4592 rho_landscape_certificate_from_parts(
4593 &prepared.cache,
4594 prepared.ywy.view(),
4595 prepared.projected_rhs_squared.view(),
4596 prepared.n_effective,
4597 prepared.n_outputs,
4598 init_rho,
4599 )
4600}
4601
4602fn optimize_rho(
4604 prepared: &GaussianRemlPrepared,
4605 init_rho: Option<f64>,
4606) -> Result<f64, EstimationError> {
4607 validate_reml_profile_residuals(
4608 &prepared.cache,
4609 prepared.ywy.view(),
4610 prepared.projected_rhs_squared.view(),
4611 RHO_LOWER,
4612 )?;
4613 if prepared.cache.penalty_rank == 0 {
4614 return Ok(init_rho.unwrap_or(0.0).clamp(RHO_LOWER, RHO_UPPER));
4615 }
4616 let eval = |rho: f64| prepared.evaluate(rho);
4617 let enclose = |a: f64, b: f64| {
4618 reml_deriv_enclosure(
4619 &prepared.cache,
4620 prepared.ywy.view(),
4621 prepared.projected_rhs_squared.view(),
4622 prepared.n_effective,
4623 prepared.n_outputs,
4624 a,
4625 b,
4626 )
4627 };
4628 Ok(enumerate_and_select_rho(eval, enclose, init_rho, |_r, _e| {})?.rho)
4629}
4630
4631fn fill_weighted_rhs_no_alloc(
4632 x: ArrayView2<'_, f64>,
4633 y: ArrayView2<'_, f64>,
4634 weights: Option<ArrayView1<'_, f64>>,
4635 workspace: &mut GaussianRemlNoAllocWorkspace,
4636) -> Result<(), EstimationError> {
4637 let d = y.ncols();
4638
4639 let (xtwy, ywy_full) = match weights {
4645 Some(w) => (fast_xt_diag_y(&x, &w, &y), fast_xt_diag_y(&y, &w, &y)),
4646 None => (fast_atb(&x, &y), fast_atb(&y, &y)),
4647 };
4648 workspace.xtwy.assign(&xtwy);
4649 for output in 0..d {
4650 workspace.ywy[output] = ywy_full[[output, output]];
4651 }
4652
4653 if workspace
4654 .xtwy
4655 .iter()
4656 .chain(workspace.ywy.iter())
4657 .any(|value| !value.is_finite())
4658 {
4659 crate::bail_invalid_estim!("Gaussian REML weighted cross-products must be finite");
4660 }
4661 Ok(())
4662}
4663
4664fn project_rhs_no_alloc(
4665 cache: &GaussianRemlEigenCache,
4666 workspace: &mut GaussianRemlNoAllocWorkspace,
4667) {
4668 let projected = fast_atb(&cache.coefficient_basis, &workspace.xtwy);
4671 workspace.projected_rhs.assign(&projected);
4672 let p = cache.penalty_eigenvalues.len();
4673 let d = workspace.ywy.len();
4674 for eig in 0..p {
4675 for output in 0..d {
4676 let value = workspace.projected_rhs[[eig, output]];
4677 workspace.projected_rhs_squared[[eig, output]] = value * value;
4678 }
4679 }
4680}
4681
4682fn evaluate_reml_parts(
4683 cache: &GaussianRemlEigenCache,
4684 ywy: ArrayView1<'_, f64>,
4685 projected_rhs_squared: ArrayView2<'_, f64>,
4686 n_effective: usize,
4687 n_outputs: usize,
4688 rho: f64,
4689) -> ObjectiveEval {
4690 evaluate_reml_profile(
4691 cache,
4692 ywy,
4693 projected_rhs_squared,
4694 n_outputs,
4695 n_effective as f64 - cache.nullity as f64,
4696 rho,
4697 )
4698}
4699
4700fn evaluate_reml_profile(
4704 cache: &GaussianRemlEigenCache,
4705 ywy: ArrayView1<'_, f64>,
4706 projected_rhs_squared: ArrayView2<'_, f64>,
4707 logdet_output_count: usize,
4708 dispersion_dof: f64,
4709 rho: f64,
4710) -> ObjectiveEval {
4711 let d = logdet_output_count as f64;
4712
4713 let (logdet_term, edf) = gaussian_reml_logdet_term(cache, rho, d);
4716 let mut eval = ObjectiveEval {
4717 cost: 0.0,
4718 grad: 0.0,
4719 hess: 0.0,
4720 edf,
4721 };
4722 eval += logdet_term;
4723 for output in 0..ywy.len() {
4724 eval += gaussian_reml_dispersion_term(
4725 cache,
4726 ywy,
4727 projected_rhs_squared,
4728 output,
4729 dispersion_dof,
4730 rho,
4731 );
4732 }
4733 eval
4734}
4735
4736fn optimize_rho_no_alloc(
4742 cache: &GaussianRemlEigenCache,
4743 ywy: ArrayView1<'_, f64>,
4744 projected_rhs_squared: ArrayView2<'_, f64>,
4745 n_effective: usize,
4746 n_outputs: usize,
4747 init_rho: Option<f64>,
4748) -> Result<f64, EstimationError> {
4749 validate_reml_profile_residuals(cache, ywy.view(), projected_rhs_squared.view(), RHO_LOWER)?;
4750 if cache.penalty_rank == 0 {
4751 return Ok(init_rho.unwrap_or(0.0).clamp(RHO_LOWER, RHO_UPPER));
4752 }
4753 let eval = |rho: f64| {
4754 evaluate_reml_parts(
4755 cache,
4756 ywy,
4757 projected_rhs_squared,
4758 n_effective,
4759 n_outputs,
4760 rho,
4761 )
4762 };
4763 let enclose = |a: f64, b: f64| {
4764 reml_deriv_enclosure(
4765 cache,
4766 ywy,
4767 projected_rhs_squared,
4768 n_effective,
4769 n_outputs,
4770 a,
4771 b,
4772 )
4773 };
4774 Ok(enumerate_and_select_rho(eval, enclose, init_rho, |_r, _e| {})?.rho)
4775}
4776
4777fn fill_coefficients_no_alloc(
4778 cache: &GaussianRemlEigenCache,
4779 workspace: &mut GaussianRemlNoAllocWorkspace,
4780 lambda: f64,
4781 mut coefficients: ArrayViewMut2<'_, f64>,
4782) {
4783 let p = cache.penalty_eigenvalues.len();
4784 let d = workspace.ywy.len();
4785 for eig in 0..p {
4786 let scale = 1.0 / (1.0 + lambda * cache.penalty_eigenvalues[eig]);
4787 for output in 0..d {
4788 workspace.scaled_projected_rhs[[eig, output]] =
4789 workspace.projected_rhs[[eig, output]] * scale;
4790 }
4791 }
4792
4793 for col in 0..p {
4794 for output in 0..d {
4795 let mut value = 0.0;
4796 for eig in 0..p {
4797 value += cache.coefficient_basis[[col, eig]]
4798 * workspace.scaled_projected_rhs[[eig, output]];
4799 }
4800 coefficients[[col, output]] = value;
4801 }
4802 }
4803}
4804
4805fn fill_fitted_no_alloc(
4806 x: ArrayView2<'_, f64>,
4807 coefficients: ArrayView2<'_, f64>,
4808 mut fitted: ArrayViewMut2<'_, f64>,
4809) {
4810 let n = x.nrows();
4811 let p = x.ncols();
4812 let d = coefficients.ncols();
4813 for row in 0..n {
4814 for output in 0..d {
4815 let mut value = 0.0;
4816 for col in 0..p {
4817 value += x[[row, col]] * coefficients[[col, output]];
4818 }
4819 fitted[[row, output]] = value;
4820 }
4821 }
4822}
4823
4824fn fill_sigma2_no_alloc(
4825 cache: &GaussianRemlEigenCache,
4826 ywy: ArrayView1<'_, f64>,
4827 projected_rhs_squared: ArrayView2<'_, f64>,
4828 n_effective: usize,
4829 n_outputs: usize,
4830 lambda: f64,
4831 mut sigma2: ArrayViewMut1<'_, f64>,
4832) {
4833 let nu = n_effective as f64 - cache.nullity as f64;
4834 for output in 0..n_outputs {
4835 let mut fitted_quadratic = 0.0;
4836 for eig in 0..cache.penalty_eigenvalues.len() {
4837 let denom = 1.0 + lambda * cache.penalty_eigenvalues[eig];
4838 fitted_quadratic += projected_rhs_squared[[eig, output]] / denom;
4839 }
4840 sigma2[output] = (ywy[output] - fitted_quadratic) / nu;
4841 }
4842}
4843
4844fn invert_lower_triangular(lower: &Array2<f64>) -> Result<Array2<f64>, EstimationError> {
4845 let n = lower.nrows();
4846 if lower.ncols() != n {
4847 crate::bail_invalid_estim!("lower-triangular solve requires a square matrix");
4848 }
4849 let eye = Array2::eye(n);
4850 solve_lower_triangular_matrix(lower, &eye)
4851}
4852
4853fn solve_lower_triangular_matrix(
4854 lower: &Array2<f64>,
4855 rhs: &Array2<f64>,
4856) -> Result<Array2<f64>, EstimationError> {
4857 let n = lower.nrows();
4858 if lower.ncols() != n || rhs.nrows() != n {
4859 crate::bail_invalid_estim!("lower-triangular solve dimension mismatch");
4860 }
4861 if let Some(out) = gam_gpu::try_solve_lower_triangular_matrix(lower.view(), rhs.view()) {
4862 return Ok(out);
4863 }
4864 let mut out = Array2::<f64>::zeros(rhs.dim());
4865 for col in 0..rhs.ncols() {
4866 for i in 0..n {
4867 let mut value = rhs[[i, col]];
4868 for k in 0..i {
4869 value -= lower[[i, k]] * out[[k, col]];
4870 }
4871 let diag = lower[[i, i]];
4872 if !(diag.is_finite() && diag.abs() > 0.0) {
4873 return Err(EstimationError::ModelIsIllConditioned {
4874 condition_number: f64::INFINITY,
4875 });
4876 }
4877 out[[i, col]] = value / diag;
4878 }
4879 }
4880 Ok(out)
4881}
4882
4883fn solve_spd_from_lower_factor(
4887 lower: &Array2<f64>,
4888 rhs: &Array2<f64>,
4889) -> Result<Array2<f64>, EstimationError> {
4890 let forward = solve_lower_triangular_matrix(lower, rhs)?;
4891 solve_upper_triangular_matrix(&lower.t().to_owned(), &forward)
4892}
4893
4894fn solve_upper_triangular_matrix(
4895 upper: &Array2<f64>,
4896 rhs: &Array2<f64>,
4897) -> Result<Array2<f64>, EstimationError> {
4898 let n = upper.nrows();
4899 if upper.ncols() != n || rhs.nrows() != n {
4900 crate::bail_invalid_estim!("upper-triangular solve dimension mismatch");
4901 }
4902 if let Some(out) = gam_gpu::try_solve_upper_triangular_matrix(upper.view(), rhs.view()) {
4903 return Ok(out);
4904 }
4905 let mut out = Array2::<f64>::zeros(rhs.dim());
4906 for col in 0..rhs.ncols() {
4907 for i_rev in 0..n {
4908 let i = n - 1 - i_rev;
4909 let mut value = rhs[[i, col]];
4910 for k in (i + 1)..n {
4911 value -= upper[[i, k]] * out[[k, col]];
4912 }
4913 let diag = upper[[i, i]];
4914 if !(diag.is_finite() && diag.abs() > 0.0) {
4915 return Err(EstimationError::ModelIsIllConditioned {
4916 condition_number: f64::INFINITY,
4917 });
4918 }
4919 out[[i, col]] = value / diag;
4920 }
4921 }
4922 Ok(out)
4923}
4924
4925#[cfg(test)]
4926mod tests {
4927 use super::*;
4928 use ndarray::array;
4929
4930 #[test]
4931 fn edf_does_not_double_count_penalty_nullspace() {
4932 let x = array![[1.0, 0.0], [1.0, 1.0], [1.0, 2.0], [1.0, 3.0], [1.0, 4.0],];
4933 let y = array![[0.0], [1.0], [1.8], [3.2], [4.1]];
4934 let penalty = array![[0.0, 0.0], [0.0, 1.0]];
4935 let result =
4936 gaussian_reml_multi_closed_form(x.view(), y.view(), penalty.view(), None, Some(0.0))
4937 .expect("small full-rank Gaussian REML fit");
4938
4939 assert!(result.edf >= result.cache.nullity as f64);
4940 assert!(result.edf <= x.ncols() as f64 + 1.0e-10);
4941 }
4942
4943 #[test]
4944 fn shared_dispersion_pools_projection_exact_and_missed_outputs() {
4945 let n = 12usize;
4946 let mut x = Array2::<f64>::zeros((n, 2));
4947 let mut y = Array2::<f64>::zeros((n, 2));
4948 for row in 0..n {
4949 let t = row as f64 - 5.5;
4950 x[[row, 0]] = 1.0;
4951 x[[row, 1]] = t;
4952 y[[row, 0]] = t;
4955 y[[row, 1]] = if row % 2 == 0 { -2.0 } else { 3.0 };
4957 }
4958 let penalty = Array2::<f64>::zeros((2, 2));
4959 let fit = gaussian_reml_multi_shared_dispersion_closed_form(
4960 x.view(),
4961 y.view(),
4962 penalty.view(),
4963 None,
4964 None,
4965 )
4966 .expect("shared-dispersion vector REML fit");
4967
4968 assert_eq!(fit.sigma2[0].to_bits(), fit.sigma2[1].to_bits());
4969 let mut pooled_rss = 0.0_f64;
4970 for row in 0..n {
4971 for output in 0..2 {
4972 let residual = y[[row, output]] - fit.fitted[[row, output]];
4973 pooled_rss += residual * residual;
4974 }
4975 }
4976 let shared_nu = (2 * (n - fit.cache.nullity)) as f64;
4977 let expected_sigma2 = pooled_rss / shared_nu;
4978 assert!(expected_sigma2 > 0.0);
4979 assert!(
4980 (fit.sigma2[0] - expected_sigma2).abs()
4981 <= f64::EPSILON.sqrt() * expected_sigma2.max(1.0),
4982 "shared sigma2 {} must equal pooled vector deviance / shared dof {}",
4983 fit.sigma2[0],
4984 expected_sigma2
4985 );
4986 }
4987
4988 #[test]
4989 fn shared_dispersion_penalty_envelope_gradient_matches_refitted_direction() {
4990 let n = 24usize;
4991 let mut x = Array2::<f64>::zeros((n, 3));
4992 let mut y = Array2::<f64>::zeros((n, 2));
4993 for row in 0..n {
4994 let t = -1.0 + 2.0 * row as f64 / (n - 1) as f64;
4995 x[[row, 0]] = 1.0;
4996 x[[row, 1]] = t;
4997 x[[row, 2]] = t * t;
4998 y[[row, 0]] = 0.3 + 1.2 * t - 0.8 * t * t + 0.04 * (7.0 * t).sin();
4999 y[[row, 1]] = -0.2 + 0.5 * t + 0.4 * t * t + 0.03 * (5.0 * t).cos();
5000 }
5001 let penalty = array![[0.0, 0.0, 0.0], [0.0, 0.7, 0.1], [0.0, 0.1, 1.4]];
5002 let direction = array![[0.0, 0.0, 0.0], [0.0, 0.3, -0.08], [0.0, -0.08, 0.6]];
5003 let fit = gaussian_reml_multi_shared_dispersion_closed_form(
5004 x.view(),
5005 y.view(),
5006 penalty.view(),
5007 None,
5008 None,
5009 )
5010 .unwrap();
5011 let gradient = gaussian_reml_multi_shared_dispersion_penalty_gradient_from_fit(
5012 x.view(),
5013 y.view(),
5014 penalty.view(),
5015 None,
5016 &fit,
5017 )
5018 .unwrap();
5019 let analytic = gradient
5020 .iter()
5021 .zip(direction.iter())
5022 .map(|(gradient, direction)| gradient * direction)
5023 .sum::<f64>();
5024
5025 let step = f64::EPSILON.cbrt();
5026 let plus_penalty = &penalty + &(direction.mapv(|value| step * value));
5027 let minus_penalty = &penalty - &(direction.mapv(|value| step * value));
5028 let plus = gaussian_reml_multi_shared_dispersion_closed_form(
5029 x.view(),
5030 y.view(),
5031 plus_penalty.view(),
5032 None,
5033 Some(fit.rho),
5034 )
5035 .unwrap();
5036 let minus = gaussian_reml_multi_shared_dispersion_closed_form(
5037 x.view(),
5038 y.view(),
5039 minus_penalty.view(),
5040 None,
5041 Some(fit.rho),
5042 )
5043 .unwrap();
5044 let numerical = (plus.reml_score - minus.reml_score) / (2.0 * step);
5045 let scale = analytic.abs().max(numerical.abs()).max(1.0);
5046 assert!(
5047 (analytic - numerical).abs() <= 2.0e-5 * scale,
5048 "shared-dispersion penalty envelope derivative mismatch: analytic={analytic}, refitted={numerical}"
5049 );
5050 }
5051
5052 #[test]
5053 fn block_orthogonal_score_matches_the_objective_derivative() {
5054 let gram = array![[3.0, 0.4], [0.4, 2.0]];
5055 let rhs = array![[1.2, -0.3], [0.6, 0.9]];
5056 let penalty = array![[1.0, 0.2], [0.2, 0.8]];
5057 let scale = array![1.3, 0.8];
5058 let rho = 0.37;
5059 let step = 1.0e-6;
5060 let eval = block_orthogonal_eval(&gram, &rhs, &penalty, rho).unwrap();
5061 let analytic = block_orthogonal_scale_objective(&eval, rho, scale.view(), 2).grad;
5062 let value_at = |candidate_rho: f64| {
5063 let candidate = block_orthogonal_eval(&gram, &rhs, &penalty, candidate_rho).unwrap();
5064 block_orthogonal_scale_objective(&candidate, candidate_rho, scale.view(), 2).value
5065 };
5066 let numerical = (value_at(rho + step) - value_at(rho - step)) / (2.0 * step);
5067 assert!(
5068 (analytic - numerical).abs() <= 1.0e-7 * analytic.abs().max(1.0),
5069 "analytic score {analytic:.12e} != objective derivative {numerical:.12e}"
5070 );
5071 }
5072
5073 #[test]
5074 fn block_orthogonal_profile_hessian_matches_the_profiled_objective() {
5075 let grams = [
5076 array![[3.0, 0.4], [0.4, 2.0]],
5077 array![[2.5, -0.2], [-0.2, 1.8]],
5078 ];
5079 let rhs = [
5080 array![[1.2, -0.3], [0.6, 0.9]],
5081 array![[0.5, 0.8], [-0.4, 0.7]],
5082 ];
5083 let penalties = [
5084 array![[1.0, 0.2], [0.2, 0.8]],
5085 array![[0.9, -0.1], [-0.1, 1.1]],
5086 ];
5087 let ranks = [2_usize, 2_usize];
5088 let ywy = array![8.0, 9.0];
5089 let nu = 7.0;
5090 let rhos = array![0.37, -0.21];
5091 let profile_value = |candidate_rhos: ArrayView1<'_, f64>| {
5092 let evals = (0..2)
5093 .map(|block| {
5094 block_orthogonal_eval(
5095 &grams[block],
5096 &rhs[block],
5097 &penalties[block],
5098 candidate_rhos[block],
5099 )
5100 .unwrap()
5101 })
5102 .collect::<Vec<_>>();
5103 let mut q = ywy.clone();
5104 for eval in &evals {
5105 q -= &eval.fitted_energy;
5106 }
5107 let determinant_term = evals
5108 .iter()
5109 .enumerate()
5110 .map(|(block, eval)| eval.logdet - ranks[block] as f64 * candidate_rhos[block])
5111 .sum::<f64>();
5112 0.5 * 2.0 * determinant_term + 0.5 * nu * q.iter().map(|value| value.ln()).sum::<f64>()
5113 };
5114 let evals = (0..2)
5115 .map(|block| {
5116 block_orthogonal_eval(&grams[block], &rhs[block], &penalties[block], rhos[block])
5117 .unwrap()
5118 })
5119 .collect::<Vec<_>>();
5120 let scale = block_orthogonal_conditional_scale(&evals, ywy.view(), nu).unwrap();
5121 let analytic =
5122 block_orthogonal_profile_hessian(&evals, rhos.view(), scale.view(), &ranks, nu)
5123 .unwrap();
5124 let step = 1.0e-4;
5125 let center = profile_value(rhos.view());
5126 let mut numerical = Array2::<f64>::zeros((2, 2));
5127 for coordinate in 0..2 {
5128 let mut plus = rhos.clone();
5129 let mut minus = rhos.clone();
5130 plus[coordinate] += step;
5131 minus[coordinate] -= step;
5132 numerical[[coordinate, coordinate]] = (profile_value(plus.view()) - 2.0 * center
5133 + profile_value(minus.view()))
5134 / (step * step);
5135 }
5136 let mut plus_plus = rhos.clone();
5137 let mut plus_minus = rhos.clone();
5138 let mut minus_plus = rhos.clone();
5139 let mut minus_minus = rhos.clone();
5140 plus_plus[0] += step;
5141 plus_plus[1] += step;
5142 plus_minus[0] += step;
5143 plus_minus[1] -= step;
5144 minus_plus[0] -= step;
5145 minus_plus[1] += step;
5146 minus_minus[0] -= step;
5147 minus_minus[1] -= step;
5148 let cross = (profile_value(plus_plus.view())
5149 - profile_value(plus_minus.view())
5150 - profile_value(minus_plus.view())
5151 + profile_value(minus_minus.view()))
5152 / (4.0 * step * step);
5153 numerical[[0, 1]] = cross;
5154 numerical[[1, 0]] = cross;
5155 for row in 0..2 {
5156 for col in 0..2 {
5157 assert!(
5158 (analytic[[row, col]] - numerical[[row, col]]).abs()
5159 <= 2.0e-6 * analytic[[row, col]].abs().max(1.0),
5160 "profile Hessian ({row}, {col}) analytic {:.12e} != numerical {:.12e}",
5161 analytic[[row, col]],
5162 numerical[[row, col]]
5163 );
5164 }
5165 }
5166 }
5167
5168 #[test]
5169 fn block_orthogonal_shared_scale_fit_carries_a_score_certificate() {
5170 let c0 = [1.0_f64; 8];
5176 let c1 = [1.0, 1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0];
5177 let c2 = [1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, -1.0];
5178 let c3 = [1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0];
5179 let mut d1 = Array2::<f64>::zeros((8, 2));
5180 let mut d2 = Array2::<f64>::zeros((8, 2));
5181 for i in 0..8 {
5182 d1[[i, 0]] = c0[i];
5183 d1[[i, 1]] = c1[i];
5184 d2[[i, 0]] = c2[i];
5185 d2[[i, 1]] = c3[i];
5186 }
5187 let penalties = vec![Array2::<f64>::eye(2), Array2::<f64>::eye(2)];
5188 let bumps = [0.03, -0.05, 0.02, 0.01, -0.02, 0.04, -0.01, -0.02];
5189 let mut y = Array2::<f64>::zeros((8, 1));
5190 for i in 0..8 {
5191 y[[i, 0]] = c0[i] + 0.5 * c1[i] + 0.25 * c2[i] + bumps[i];
5192 }
5193
5194 let result = gaussian_reml_blocks_orthogonal_shared_scale(
5195 &[d1.clone(), d2.clone()],
5196 &penalties,
5197 y.view(),
5198 None,
5199 None,
5200 )
5201 .expect("well-posed orthogonal-block fit must certify and mint");
5202
5203 let weight = Array1::<f64>::ones(8);
5204 let ywy = (0..8).map(|i| y[[i, 0]] * y[[i, 0]]).sum::<f64>();
5205 let nu = 8.0_f64;
5207 let mut evals = Vec::new();
5208 for (block, design) in [&d1, &d2].into_iter().enumerate() {
5209 let gram = canonicalize_penalty(dense_xt_diag_x(design.view(), weight.view()).view());
5210 let rhs = dense_xt_diag_y(design.view(), weight.view(), y.view());
5211 let pen = canonicalize_penalty(penalties[block].view());
5212 evals.push(
5213 block_orthogonal_eval(&gram, &rhs, &pen, result.log_lambdas[block])
5214 .expect("block eval at the minted rho"),
5215 );
5216 }
5217 let explained: f64 = evals.iter().map(|eval| eval.fitted_energy[0]).sum();
5218 let q = ywy - explained;
5219 assert!(q > 0.0);
5220 let scale = Array1::from_vec(vec![nu / q]);
5221 for (block, eval) in evals.iter().enumerate() {
5222 let derivs =
5223 block_orthogonal_scale_objective(eval, result.log_lambdas[block], scale.view(), 2);
5224 let residual = derivs.grad.abs() / 2.0;
5225 assert!(
5226 residual <= BLOCK_ORTHOGONAL_SCORE_TOL,
5227 "block {block} score residual {residual:.3e} exceeds the certificate tolerance"
5228 );
5229 }
5230 let curvature = block_orthogonal_profile_spectrum(
5231 &block_orthogonal_profile_hessian(
5232 &evals,
5233 result.log_lambdas.view(),
5234 scale.view(),
5235 &[2, 2],
5236 nu,
5237 )
5238 .unwrap(),
5239 )
5240 .unwrap()
5241 .curvature;
5242 assert!(
5243 curvature.min_eigenvalue >= -curvature.roundoff,
5244 "minted fit has negative profiled curvature {:.6e} beyond roundoff {:.3e}",
5245 curvature.min_eigenvalue,
5246 curvature.roundoff
5247 );
5248
5249 let err = gaussian_reml_blocks_orthogonal_shared_scale_with_controls(
5250 &[d1, d2],
5251 &penalties,
5252 y.view(),
5253 None,
5254 None,
5255 BlockOrthogonalControls {
5256 max_outer_passes: 0,
5257 ..BlockOrthogonalControls::default()
5258 },
5259 )
5260 .unwrap_err();
5261 match err {
5262 EstimationError::BlockOrthogonalRemlDidNotConverge {
5263 iterations,
5264 max_score_residual,
5265 rho_checkpoint,
5266 ..
5267 } => {
5268 assert_eq!(iterations, 0);
5269 assert!(max_score_residual.is_infinite());
5270 assert_eq!(rho_checkpoint, vec![0.0, 0.0]);
5271 }
5272 other => panic!("expected typed block-orthogonal exhaustion, got {other}"),
5273 }
5274 }
5275
5276 #[test]
5277 fn block_orthogonal_solver_rejects_cross_block_signal() {
5278 let first = array![[1.0], [1.0], [1.0], [1.0], [1.0], [1.0]];
5279 let second = array![[0.0], [1.0], [2.0], [3.0], [4.0], [5.0]];
5280 let penalties = vec![Array2::<f64>::eye(1), Array2::<f64>::eye(1)];
5281 let y = array![[0.2], [0.8], [1.7], [3.1], [3.9], [5.2]];
5282 let err = gaussian_reml_blocks_orthogonal_shared_scale(
5283 &[first, second],
5284 &penalties,
5285 y.view(),
5286 None,
5287 None,
5288 )
5289 .unwrap_err();
5290 assert!(
5291 matches!(&err, EstimationError::InvalidInput(_)),
5292 "nonorthogonal blocks must fail the decomposed-objective contract: {err}"
5293 );
5294 assert!(err.to_string().contains("weighted cross-product"));
5295 }
5296
5297 #[test]
5298 fn multi_output_duplicate_columns_match_scalar_fit() {
5299 let x = array![
5300 [1.0, -1.0],
5301 [1.0, -0.5],
5302 [1.0, 0.0],
5303 [1.0, 0.5],
5304 [1.0, 1.0],
5305 [1.0, 1.5],
5306 ];
5307 let y1 = array![0.5, 0.2, 0.0, 0.3, 1.1, 2.0];
5308 let y = Array2::from_shape_fn(
5309 (y1.len(), 2),
5310 |(i, j)| if j == 0 { y1[i] } else { 2.0 * y1[i] },
5311 );
5312 let penalty = array![[0.0, 0.0], [0.0, 1.0]];
5313
5314 let scalar =
5315 gaussian_reml_closed_form(x.view(), y1.view(), penalty.view(), None, Some(0.0))
5316 .expect("scalar Gaussian REML fit");
5317 let multi =
5318 gaussian_reml_multi_closed_form(x.view(), y.view(), penalty.view(), None, Some(0.0))
5319 .expect("multi-output Gaussian REML fit");
5320
5321 assert!((multi.rho - scalar.rho).abs() <= 1.0e-8);
5322 for i in 0..x.ncols() {
5323 assert!((multi.coefficients[[i, 0]] - scalar.coefficients[i]).abs() <= 1.0e-8);
5324 assert!((multi.coefficients[[i, 1]] - 2.0 * scalar.coefficients[i]).abs() <= 1.0e-8);
5325 }
5326 }
5327
5328 #[test]
5329 fn warm_start_reuses_cache_and_lambda_seed() {
5330 let x = array![
5331 [1.0, -1.0],
5332 [1.0, -0.25],
5333 [1.0, 0.5],
5334 [1.0, 1.25],
5335 [1.0, 2.0],
5336 ];
5337 let y = array![[0.1], [0.4], [0.7], [1.4], [2.2]];
5338 let penalty = array![[0.0, 0.0], [0.0, 1.0]];
5339
5340 let cold =
5341 gaussian_reml_multi_closed_form(x.view(), y.view(), penalty.view(), None, Some(0.0))
5342 .expect("cold fit");
5343 let warm_start = GaussianRemlWarmStart::from_multi_result(&cold);
5344 let warm = gaussian_reml_multi_closed_form_warm_started(
5345 x.view(),
5346 y.view(),
5347 penalty.view(),
5348 None,
5349 Some(&warm_start),
5350 )
5351 .expect("warm-started fit");
5352
5353 assert!((cold.lambda - warm.lambda).abs() <= 1.0e-10);
5354 assert_eq!(cold.cache.xtwx_fingerprint, warm.cache.xtwx_fingerprint);
5355 for i in 0..x.ncols() {
5356 assert!((cold.coefficients[[i, 0]] - warm.coefficients[[i, 0]]).abs() <= 1.0e-10);
5357 }
5358 }
5359
5360 #[test]
5361 fn warm_start_cache_rejects_different_penalty_geometry() {
5362 let x = array![
5363 [1.0, -1.0],
5364 [1.0, -0.25],
5365 [1.0, 0.5],
5366 [1.0, 1.25],
5367 [1.0, 2.0],
5368 ];
5369 let y = array![[0.1], [0.4], [0.7], [1.4], [2.2]];
5370 let penalty_a = array![[0.0, 0.0], [0.0, 1.0]];
5371 let penalty_b = array![[1.0, -1.0], [-1.0, 1.0]];
5372
5373 let first =
5374 gaussian_reml_multi_closed_form(x.view(), y.view(), penalty_a.view(), None, Some(0.0))
5375 .expect("first fit");
5376 let warm_start = GaussianRemlWarmStart::from_multi_result(&first);
5377 let err = gaussian_reml_multi_closed_form_warm_started(
5378 x.view(),
5379 y.view(),
5380 penalty_b.view(),
5381 None,
5382 Some(&warm_start),
5383 )
5384 .expect_err("penalty-mismatched cache must be rejected");
5385
5386 assert!(err.to_string().contains("penalty mismatch"));
5387 }
5388
5389 #[test]
5390 fn no_alloc_cache_path_matches_allocating_fit() {
5391 let x = array![
5392 [1.0, -1.0, 0.25],
5393 [1.0, -0.5, 0.10],
5394 [1.0, 0.0, -0.20],
5395 [1.0, 0.5, -0.05],
5396 [1.0, 1.0, 0.30],
5397 [1.0, 1.5, 0.60],
5398 ];
5399 let y = array![
5400 [0.0, 0.2],
5401 [0.3, 0.1],
5402 [0.4, -0.1],
5403 [0.9, 0.3],
5404 [1.6, 0.8],
5405 [2.2, 1.2],
5406 ];
5407 let weights = array![1.0, 0.8, 1.2, 1.1, 0.9, 1.3];
5408 let penalty = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 4.0]];
5409
5410 let allocating = gaussian_reml_multi_closed_form_with_cache(
5411 x.view(),
5412 y.view(),
5413 penalty.view(),
5414 Some(weights.view()),
5415 Some(1.0),
5416 None,
5417 )
5418 .expect("allocating fit");
5419 let mut workspace = GaussianRemlNoAllocWorkspace::new(x.ncols(), y.ncols());
5420 let mut coefficients = Array2::zeros((x.ncols(), y.ncols()));
5421 let mut fitted = Array2::zeros(y.dim());
5422 let mut sigma2 = Array1::zeros(y.ncols());
5423
5424 let no_alloc = gaussian_reml_multi_closed_form_with_cache_no_alloc(
5425 x.view(),
5426 y.view(),
5427 penalty.view(),
5428 Some(weights.view()),
5429 Some(allocating.lambda),
5430 &allocating.cache,
5431 &mut workspace,
5432 coefficients.view_mut(),
5433 fitted.view_mut(),
5434 sigma2.view_mut(),
5435 )
5436 .expect("no-alloc cached fit");
5437
5438 assert!((no_alloc.lambda - allocating.lambda).abs() <= 1.0e-10);
5439 assert!((no_alloc.reml_score - allocating.reml_score).abs() <= 1.0e-8);
5440 assert!((no_alloc.reml_grad_rho - allocating.reml_grad_rho).abs() <= 1.0e-8);
5441 assert!((no_alloc.reml_hess_rho - allocating.reml_hess_rho).abs() <= 1.0e-8);
5442 assert!((no_alloc.edf - allocating.edf).abs() <= 1.0e-10);
5443 for i in 0..x.ncols() {
5444 for j in 0..y.ncols() {
5445 assert!((coefficients[[i, j]] - allocating.coefficients[[i, j]]).abs() <= 1.0e-8);
5446 }
5447 }
5448 for i in 0..x.nrows() {
5449 for j in 0..y.ncols() {
5450 assert!((fitted[[i, j]] - allocating.fitted[[i, j]]).abs() <= 1.0e-8);
5451 }
5452 }
5453 for j in 0..y.ncols() {
5454 assert!((sigma2[j] - allocating.sigma2[j]).abs() <= 1.0e-10);
5455 }
5456 }
5457
5458 #[test]
5459 fn no_alloc_cache_path_rejects_bad_shapes_and_penalty_mismatch() {
5460 let x = array![[1.0, -1.0], [1.0, 0.0], [1.0, 1.0], [1.0, 2.0]];
5461 let y = array![[0.0], [0.2], [0.9], [1.8]];
5462 let penalty = array![[0.0, 0.0], [0.0, 1.0]];
5463 let cache = build_gaussian_reml_eigen_cache(x.view(), penalty.view(), None)
5464 .expect("Gaussian REML cache");
5465
5466 let mut bad_workspace = GaussianRemlNoAllocWorkspace::new(x.ncols(), y.ncols() + 1);
5467 let mut coefficients = Array2::zeros((x.ncols(), y.ncols()));
5468 let mut fitted = Array2::zeros(y.dim());
5469 let mut sigma2 = Array1::zeros(y.ncols());
5470 let err = gaussian_reml_multi_closed_form_with_cache_no_alloc(
5471 x.view(),
5472 y.view(),
5473 penalty.view(),
5474 None,
5475 Some(1.0),
5476 &cache,
5477 &mut bad_workspace,
5478 coefficients.view_mut(),
5479 fitted.view_mut(),
5480 sigma2.view_mut(),
5481 )
5482 .expect_err("workspace shape mismatch must be rejected");
5483 assert!(err.to_string().contains("workspace shape mismatch"));
5484
5485 let penalty_mismatch = array![[1.0, -1.0], [-1.0, 1.0]];
5486 let mut workspace = GaussianRemlNoAllocWorkspace::new(x.ncols(), y.ncols());
5487 let err = gaussian_reml_multi_closed_form_with_cache_no_alloc(
5488 x.view(),
5489 y.view(),
5490 penalty_mismatch.view(),
5491 None,
5492 Some(1.0),
5493 &cache,
5494 &mut workspace,
5495 coefficients.view_mut(),
5496 fitted.view_mut(),
5497 sigma2.view_mut(),
5498 )
5499 .expect_err("penalty mismatch must be rejected");
5500 assert!(err.to_string().contains("penalty mismatch"));
5501 }
5502
5503 #[derive(Clone, Copy, Debug)]
5504 enum ForwardScalar {
5505 Lambda,
5506 RemlScore,
5507 Coefficient(usize, usize),
5508 Fitted(usize, usize),
5509 Edf,
5510 }
5511
5512 fn finite_difference_design() -> Array2<f64> {
5513 Array2::from_shape_fn((20, 5), |(row, col)| {
5514 let t = (row as f64 - 9.5) / 10.0;
5515 match col {
5516 0 => 1.0,
5517 1 => t,
5518 2 => 0.5 * (3.0 * t * t - 1.0),
5519 3 => 0.5 * (5.0 * t * t * t - 3.0 * t),
5520 4 => (35.0 * t.powi(4) - 30.0 * t * t + 3.0) / 8.0,
5521 _ => unreachable!(),
5522 }
5523 })
5524 }
5525
5526 fn finite_difference_response(outputs: usize) -> Array2<f64> {
5527 Array2::from_shape_fn((20, outputs), |(row, output)| {
5538 let t = (row as f64 - 9.5) / 10.0;
5539 let phase = output as f64 + 1.0;
5540 0.2 + 0.25 * phase * t - 0.12 * t * t
5541 + (0.08 + 0.03 * phase) * (1.1 * t + 0.3 * phase).sin()
5542 + 0.05 * (7.0 * t + 0.5 * phase).sin()
5543 })
5544 }
5545
5546 fn finite_difference_penalty() -> Array2<f64> {
5547 Array2::from_diag(&array![0.0, 0.8, 1.2, 1.7, 2.3])
5548 }
5549
5550 fn finite_difference_weights() -> Array1<f64> {
5551 Array1::from_shape_fn(20, |row| {
5552 let t = (row as f64 - 9.5) / 10.0;
5553 1.0 + 0.025 * (1.1 * t).sin() + 0.01 * t
5554 })
5555 }
5556
5557 fn one_hot_objective_try(
5564 x: ArrayView2<'_, f64>,
5565 y: ArrayView2<'_, f64>,
5566 penalty: ArrayView2<'_, f64>,
5567 weights: ArrayView1<'_, f64>,
5568 target: ForwardScalar,
5569 ) -> Option<f64> {
5570 let fit = gaussian_reml_multi_closed_form_with_cache(
5571 x,
5572 y,
5573 penalty,
5574 Some(weights),
5575 Some(0.85),
5576 None,
5577 )
5578 .ok()?;
5579 Some(match target {
5580 ForwardScalar::Lambda => fit.lambda,
5581 ForwardScalar::RemlScore => fit.reml_score,
5582 ForwardScalar::Coefficient(row, col) => fit.coefficients[[row, col]],
5583 ForwardScalar::Fitted(row, col) => fit.fitted[[row, col]],
5584 ForwardScalar::Edf => fit.edf,
5585 })
5586 }
5587
5588 fn one_hot_objective(
5589 x: ArrayView2<'_, f64>,
5590 y: ArrayView2<'_, f64>,
5591 penalty: ArrayView2<'_, f64>,
5592 weights: ArrayView1<'_, f64>,
5593 target: ForwardScalar,
5594 ) -> f64 {
5595 one_hot_objective_try(x, y, penalty, weights, target)
5596 .expect("finite-difference forward fit")
5597 }
5598
5599 fn one_hot_backward(
5600 x: ArrayView2<'_, f64>,
5601 y: ArrayView2<'_, f64>,
5602 penalty: ArrayView2<'_, f64>,
5603 weights: ArrayView1<'_, f64>,
5604 target: ForwardScalar,
5605 ) -> GaussianRemlBackwardResult {
5606 let mut grad_coefficients = Array2::<f64>::zeros((x.ncols(), y.ncols()));
5607 let mut grad_fitted = Array2::<f64>::zeros(y.dim());
5608 let (grad_lambda, grad_score, grad_edf, coefficient_upstream, fitted_upstream) =
5609 match target {
5610 ForwardScalar::Lambda => (1.0, 0.0, 0.0, None, None),
5611 ForwardScalar::RemlScore => (0.0, 1.0, 0.0, None, None),
5612 ForwardScalar::Coefficient(row, col) => {
5613 grad_coefficients[[row, col]] = 1.0;
5614 (0.0, 0.0, 0.0, Some(grad_coefficients.view()), None)
5615 }
5616 ForwardScalar::Fitted(row, col) => {
5617 grad_fitted[[row, col]] = 1.0;
5618 (0.0, 0.0, 0.0, None, Some(grad_fitted.view()))
5619 }
5620 ForwardScalar::Edf => (0.0, 0.0, 1.0, None, None),
5621 };
5622 gaussian_reml_multi_closed_form_backward(
5623 x,
5624 y,
5625 penalty,
5626 Some(weights),
5627 Some(0.85),
5628 grad_lambda,
5629 coefficient_upstream,
5630 fitted_upstream,
5631 grad_score,
5632 grad_edf,
5633 )
5634 .expect("analytic backward VJP")
5635 }
5636
5637 fn assert_fd_close(label: &str, analytic: f64, finite_difference: f64) {
5638 let rel_tol = 1.0e-6_f64;
5639 let abs_tol = 1.0e-6_f64;
5640 let tol = abs_tol.max(rel_tol * analytic.abs().max(finite_difference.abs()));
5641 let diff = (analytic - finite_difference).abs();
5642 assert!(
5643 diff <= tol,
5644 "{label}: analytic={analytic:.12e}, finite_difference={finite_difference:.12e}, diff={diff:.3e}, tol={tol:.3e}"
5645 );
5646 }
5647
5648 fn adaptive_central_difference(mut eval: impl FnMut(f64) -> f64) -> f64 {
5649 let steps: [f64; 5] = [1.0e-3, 5.0e-4, 2.5e-4, 1.25e-4, 6.25e-5];
5650 let mut best = f64::NAN;
5651 let mut best_delta = f64::INFINITY;
5652 let mut previous: Option<f64> = None;
5653 for h in steps {
5654 let d1 = (eval(h) - eval(-h)) / (2.0 * h);
5655 let half_h = 0.5 * h;
5656 let d2 = (eval(half_h) - eval(-half_h)) / (2.0 * half_h);
5657 let estimate: f64 = d2 + (d2 - d1) / 3.0;
5658 if let Some(prev) = previous {
5659 let delta = (estimate - prev).abs();
5660 if delta < best_delta {
5661 best_delta = delta;
5662 best = estimate;
5663 }
5664 } else {
5665 best = estimate;
5666 }
5667 previous = Some(estimate);
5668 }
5669 best
5670 }
5671
5672 fn assert_backward_matches_forward_finite_difference(outputs: usize) {
5673 let x = finite_difference_design();
5674 let y = finite_difference_response(outputs);
5675 let penalty = finite_difference_penalty();
5676 let weights = finite_difference_weights();
5677 let targets = [
5678 ForwardScalar::Lambda,
5679 ForwardScalar::RemlScore,
5680 ForwardScalar::Coefficient(3, outputs - 1),
5681 ForwardScalar::Fitted(12, outputs - 1),
5682 ForwardScalar::Edf,
5683 ];
5684 for target in targets {
5685 let backward =
5686 one_hot_backward(x.view(), y.view(), penalty.view(), weights.view(), target);
5687
5688 for row in 0..x.nrows() {
5689 for col in 0..x.ncols() {
5690 let eval = |delta: f64| {
5691 let mut candidate = x.clone();
5692 candidate[[row, col]] += delta;
5693 one_hot_objective(
5694 candidate.view(),
5695 y.view(),
5696 penalty.view(),
5697 weights.view(),
5698 target,
5699 )
5700 };
5701 let fd = adaptive_central_difference(eval);
5702 assert_fd_close(
5703 &format!("target={target:?} x[{row},{col}]"),
5704 backward.grad_x[[row, col]],
5705 fd,
5706 );
5707 }
5708 }
5709
5710 for row in 0..y.nrows() {
5711 for col in 0..y.ncols() {
5712 let eval = |delta: f64| {
5713 let mut candidate = y.clone();
5714 candidate[[row, col]] += delta;
5715 one_hot_objective(
5716 x.view(),
5717 candidate.view(),
5718 penalty.view(),
5719 weights.view(),
5720 target,
5721 )
5722 };
5723 let fd = adaptive_central_difference(eval);
5724 assert_fd_close(
5725 &format!("target={target:?} y[{row},{col}]"),
5726 backward.grad_y[[row, col]],
5727 fd,
5728 );
5729 }
5730 }
5731
5732 for row in 0..weights.len() {
5733 let eval = |delta: f64| {
5734 let mut candidate = weights.clone();
5735 candidate[row] += delta;
5736 one_hot_objective(x.view(), y.view(), penalty.view(), candidate.view(), target)
5737 };
5738 let fd = adaptive_central_difference(eval);
5739 assert_fd_close(
5740 &format!("target={target:?} weights[{row}]"),
5741 backward.grad_weights[row],
5742 fd,
5743 );
5744 }
5745
5746 let null_index = 0usize; let probe_h = 1.0e-3_f64; for r in 0..penalty.nrows() {
5770 for c in 0..penalty.ncols() {
5771 if r == null_index || c == null_index {
5772 continue;
5773 }
5774 let eval = |delta: f64| {
5775 let mut candidate = penalty.clone();
5776 candidate[[r, c]] += delta;
5777 one_hot_objective(
5778 x.view(),
5779 y.view(),
5780 candidate.view(),
5781 weights.view(),
5782 target,
5783 )
5784 };
5785 let cone_safe = {
5786 let mut s_plus = penalty.clone();
5787 let mut s_minus = penalty.clone();
5788 s_plus[[r, c]] += probe_h;
5789 s_minus[[r, c]] -= probe_h;
5790 one_hot_objective_try(
5791 x.view(),
5792 y.view(),
5793 s_plus.view(),
5794 weights.view(),
5795 target,
5796 )
5797 .is_some()
5798 && one_hot_objective_try(
5799 x.view(),
5800 y.view(),
5801 s_minus.view(),
5802 weights.view(),
5803 target,
5804 )
5805 .is_some()
5806 };
5807 if !cone_safe {
5808 continue;
5809 }
5810 let fd = adaptive_central_difference(eval);
5811 assert_fd_close(
5812 &format!("target={target:?} penalty[{r},{c}]"),
5813 backward.grad_penalty[[r, c]],
5814 fd,
5815 );
5816 }
5817 }
5818 }
5819 }
5820
5821 #[test]
5822 fn scalar_backward_matches_forward_finite_difference_for_all_x_y_and_weight_entries() {
5823 assert_backward_matches_forward_finite_difference(1);
5824 }
5825
5826 #[test]
5827 fn multi_output_backward_matches_forward_finite_difference_for_all_x_y_and_weight_entries() {
5828 assert_backward_matches_forward_finite_difference(3);
5829 }
5830
5831 #[test]
5832 fn backward_vjp_matches_finite_difference() {
5833 let x = array![
5834 [1.0, -1.0, 0.2],
5835 [1.0, -0.3, -0.1],
5836 [1.0, 0.2, 0.4],
5837 [1.0, 0.8, 0.1],
5838 [1.0, 1.4, 0.5],
5839 [1.0, 2.0, 0.9],
5840 ];
5841 let y = array![
5842 [0.1, -0.2],
5843 [0.2, 0.1],
5844 [0.7, 0.0],
5845 [1.1, 0.3],
5846 [1.8, 0.9],
5847 [2.4, 1.4],
5848 ];
5849 let weights = array![1.0, 0.9, 1.1, 1.2, 0.8, 1.3];
5850 let penalty = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.2], [0.0, 0.2, 1.7]];
5851 let upstream_coefficients = array![[0.2, -0.1], [0.05, 0.03], [-0.04, 0.07]];
5852 let upstream_fitted = array![
5853 [0.01, -0.02],
5854 [0.03, 0.01],
5855 [-0.01, 0.02],
5856 [0.04, -0.03],
5857 [0.02, 0.05],
5858 [-0.02, 0.01],
5859 ];
5860 let upstream_lambda = 0.17;
5861 let upstream_score = -0.11;
5862
5863 let backward = gaussian_reml_multi_closed_form_backward(
5864 x.view(),
5865 y.view(),
5866 penalty.view(),
5867 Some(weights.view()),
5868 Some(0.8),
5869 upstream_lambda,
5870 Some(upstream_coefficients.view()),
5871 Some(upstream_fitted.view()),
5872 upstream_score,
5873 0.0,
5874 )
5875 .expect("backward VJP");
5876
5877 let objective = |x_eval: &Array2<f64>, y_eval: &Array2<f64>, w_eval: &Array1<f64>| {
5878 let fit = gaussian_reml_multi_closed_form_with_cache(
5879 x_eval.view(),
5880 y_eval.view(),
5881 penalty.view(),
5882 Some(w_eval.view()),
5883 Some(0.8),
5884 None,
5885 )
5886 .expect("fit for objective");
5887 upstream_lambda * fit.lambda
5888 + upstream_score * fit.reml_score
5889 + (&fit.coefficients * &upstream_coefficients).sum()
5890 + (&fit.fitted * &upstream_fitted).sum()
5891 };
5892 let eps = 1.0e-6;
5893 assert!(objective(&x, &y, &weights).is_finite());
5894
5895 let mut x_plus = x.clone();
5896 let mut x_minus = x.clone();
5897 x_plus[[3, 2]] += eps;
5898 x_minus[[3, 2]] -= eps;
5899 let fd_x =
5900 (objective(&x_plus, &y, &weights) - objective(&x_minus, &y, &weights)) / (2.0 * eps);
5901 assert!(
5902 (fd_x - backward.grad_x[[3, 2]]).abs() <= 2.0e-4,
5903 "grad_x mismatch: analytic={} fd={}",
5904 backward.grad_x[[3, 2]],
5905 fd_x
5906 );
5907
5908 let mut y_plus = y.clone();
5909 let mut y_minus = y.clone();
5910 y_plus[[4, 1]] += eps;
5911 y_minus[[4, 1]] -= eps;
5912 let fd_y =
5913 (objective(&x, &y_plus, &weights) - objective(&x, &y_minus, &weights)) / (2.0 * eps);
5914 assert!(
5915 (fd_y - backward.grad_y[[4, 1]]).abs() <= 2.0e-4,
5916 "grad_y mismatch: analytic={} fd={}",
5917 backward.grad_y[[4, 1]],
5918 fd_y
5919 );
5920
5921 let mut w_plus = weights.clone();
5922 let mut w_minus = weights.clone();
5923 w_plus[2] += eps;
5924 w_minus[2] -= eps;
5925 let fd_w = (objective(&x, &y, &w_plus) - objective(&x, &y, &w_minus)) / (2.0 * eps);
5926 assert!(
5927 (fd_w - backward.grad_weights[2]).abs() <= 2.0e-4,
5928 "grad_weight mismatch: analytic={} fd={}",
5929 backward.grad_weights[2],
5930 fd_w
5931 );
5932
5933 let objective_s = |s_eval: &Array2<f64>| {
5945 let fit = gaussian_reml_multi_closed_form_with_cache(
5946 x.view(),
5947 y.view(),
5948 s_eval.view(),
5949 Some(weights.view()),
5950 Some(0.8),
5951 None,
5952 )
5953 .expect("fit for penalty objective");
5954 upstream_lambda * fit.lambda
5955 + upstream_score * fit.reml_score
5956 + (&fit.coefficients * &upstream_coefficients).sum()
5957 + (&fit.fitted * &upstream_fitted).sum()
5958 };
5959 for (r, c) in [(1usize, 1usize), (1, 2), (2, 2)] {
5963 let mut s_plus = penalty.clone();
5964 let mut s_minus = penalty.clone();
5965 s_plus[[r, c]] += eps;
5966 s_minus[[r, c]] -= eps;
5967 let fd_s = (objective_s(&s_plus) - objective_s(&s_minus)) / (2.0 * eps);
5968 assert!(
5969 (fd_s - backward.grad_penalty[[r, c]]).abs() <= 2.0e-4,
5970 "grad_penalty[{r},{c}] mismatch: analytic={} fd={}",
5971 backward.grad_penalty[[r, c]],
5972 fd_s
5973 );
5974 }
5975 }
5976
5977 #[test]
5978 fn batched_eigen_cache_matches_per_fit_build() {
5979 let xtwx_a = array![[4.0, 1.0], [1.0, 3.0]];
5985 let xtwx_b = array![[2.5, -0.5], [-0.5, 1.7]];
5986 let xtwx_c = array![[7.2, 0.3], [0.3, 5.1]];
5987 let penalty = array![[0.0, 0.0], [0.0, 1.0]];
5988
5989 let batched = build_gaussian_reml_eigen_cache_batched(
5990 vec![xtwx_a.clone(), xtwx_b.clone(), xtwx_c.clone()],
5991 penalty.view(),
5992 None,
5993 );
5994 assert_eq!(batched.len(), 3);
5995
5996 for (xtwx, batched_cache) in [&xtwx_a, &xtwx_b, &xtwx_c].into_iter().zip(batched.iter()) {
5997 let single = gaussian_reml_eigen_cache_from_xtwx(xtwx.clone(), penalty.view(), None)
5998 .expect("per-fit cache");
5999 let batched_cache = batched_cache.as_ref().expect("batched cache");
6000 assert_eq!(batched_cache.penalty_rank, single.penalty_rank);
6001 assert_eq!(batched_cache.nullity, single.nullity);
6002 assert_eq!(batched_cache.xtwx_fingerprint, single.xtwx_fingerprint);
6003 assert_eq!(
6004 batched_cache.penalty_fingerprint,
6005 single.penalty_fingerprint
6006 );
6007 assert!((batched_cache.logdet_xtwx - single.logdet_xtwx).abs() <= 1.0e-12);
6008 assert!(
6009 (batched_cache.logdet_penalty_positive - single.logdet_penalty_positive).abs()
6010 <= 1.0e-12
6011 );
6012 for (a, b) in batched_cache
6013 .penalty_eigenvalues
6014 .iter()
6015 .zip(single.penalty_eigenvalues.iter())
6016 {
6017 assert!((a - b).abs() <= 1.0e-12);
6018 }
6019 for ((a, b), _) in batched_cache
6020 .coefficient_basis
6021 .iter()
6022 .zip(single.coefficient_basis.iter())
6023 .zip(0..)
6024 {
6025 assert!((a - b).abs() <= 1.0e-12);
6026 }
6027 }
6028 }
6029
6030 #[test]
6031 fn scalar_rho_optimizer_chooses_lowest_cost_stationary_point() {
6032 let cache = GaussianRemlEigenCache {
6033 penalty_eigenvalues: array![5.2430192311066924e-05, 81734184.18548436],
6034 eigenvectors: Array2::eye(2),
6035 coefficient_basis: Array2::eye(2),
6036 xtwx_fingerprint: 0,
6037 penalty_fingerprint: 0,
6038 logdet_xtwx: 0.0,
6039 logdet_penalty_positive: 0.0,
6040 penalty_rank: 2,
6041 nullity: 0,
6042 };
6043 let prepared = GaussianRemlPrepared {
6044 cache: cache.clone(),
6045 ywy: array![0.5021347226586624],
6046 projected_rhs_squared: array![[0.361060218768292], [0.01014486085547482]],
6047 projected_rhs: array![
6048 [0.361060218768292_f64.sqrt()],
6049 [0.01014486085547482_f64.sqrt()]
6050 ],
6051 n_effective: 100,
6052 n_outputs: 1,
6053 };
6054
6055 let rho = optimize_rho(&prepared, None).expect("allocating rho optimizer");
6056 let no_alloc_rho = optimize_rho_no_alloc(
6057 &cache,
6058 prepared.ywy.view(),
6059 prepared.projected_rhs_squared.view(),
6060 prepared.n_effective,
6061 prepared.n_outputs,
6062 None,
6063 )
6064 .expect("no-alloc rho optimizer");
6065
6066 assert!(
6067 (rho - 4.3251059890).abs() < 1.0e-6,
6068 "rho optimizer selected {rho}, expected the lower-cost later stationary point"
6069 );
6070 assert_eq!(
6074 no_alloc_rho, rho,
6075 "no-alloc optimizer selected {no_alloc_rho}, allocating selected {rho}"
6076 );
6077 assert!(prepared.evaluate(rho).cost < prepared.evaluate(-18.9277503549).cost);
6078 }
6079
6080 struct Lcg(u64);
6083 impl Lcg {
6084 fn new(seed: u64) -> Self {
6085 Lcg(seed)
6086 }
6087 fn next_u64(&mut self) -> u64 {
6088 self.0 = self
6089 .0
6090 .wrapping_mul(6364136223846793005)
6091 .wrapping_add(1442695040888963407);
6092 self.0
6093 }
6094 fn unit(&mut self) -> f64 {
6095 (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
6096 }
6097 fn range(&mut self, lo: f64, hi: f64) -> f64 {
6098 lo + (hi - lo) * self.unit()
6099 }
6100 }
6101
6102 fn synthetic_cache(eigs: &[f64]) -> GaussianRemlEigenCache {
6107 let n = eigs.len();
6108 let rank = eigs.iter().filter(|&&delta| delta > 0.0).count();
6109 GaussianRemlEigenCache {
6110 penalty_eigenvalues: Array1::from(eigs.to_vec()),
6111 eigenvectors: Array2::eye(n),
6112 coefficient_basis: Array2::eye(n),
6113 xtwx_fingerprint: 0,
6114 penalty_fingerprint: 0,
6115 logdet_xtwx: 0.0,
6116 logdet_penalty_positive: 0.0,
6117 penalty_rank: rank,
6118 nullity: n - rank,
6119 }
6120 }
6121
6122 #[test]
6131 fn profiled_one_mode_certificate_matches_analytic_root_and_ignores_seed_as_candidate() {
6132 let delta = 4.0;
6133 let q = 2.0;
6134 let irreducible_residual = 3.0;
6135 let n_effective = 10usize;
6136 let cache = synthetic_cache(&[delta]);
6137 let ywy = array![q + irreducible_residual];
6138 let projected = array![[q]];
6139 let eval = |rho: f64| {
6140 evaluate_reml_parts(&cache, ywy.view(), projected.view(), n_effective, 1, rho)
6141 };
6142 let enclose = |a: f64, b: f64| {
6143 reml_deriv_enclosure(&cache, ywy.view(), projected.view(), n_effective, 1, a, b)
6144 };
6145 let expected_t =
6146 irreducible_residual / (((n_effective - 1) as f64) * q - irreducible_residual);
6147 let expected_rho = (expected_t / delta).ln();
6148 let mut roots = Vec::new();
6149 let selection =
6150 enumerate_and_select_rho(&eval, &enclose, Some(-20.0), |root, _| roots.push(root))
6151 .expect("profile certificate");
6152
6153 assert_eq!(roots.len(), 1, "unexpected stationary set");
6154 assert!(
6155 roots[0].bracket[0] <= expected_rho && expected_rho <= roots[0].bracket[1],
6156 "analytic root {expected_rho} outside certified bracket {:?}",
6157 roots[0].bracket
6158 );
6159 assert!(
6160 (selection.rho - expected_rho).abs()
6161 <= RHO_BRACKET_RESOLUTION * (1.0 + expected_rho.abs()),
6162 "selected rho {} differs from analytic profiled root {expected_rho}",
6163 selection.rho
6164 );
6165 assert_ne!(
6166 selection.rho.to_bits(),
6167 (-20.0_f64).to_bits(),
6168 "a nonstationary warm hint must never enter the objective argmin"
6169 );
6170 }
6171
6172 #[test]
6173 fn unresolved_stationary_structure_is_a_typed_refusal() {
6174 let eval = |rho: f64| ObjectiveEval {
6175 cost: rho * rho,
6176 grad: 2.0 * rho,
6177 hess: 2.0,
6178 edf: 0.0,
6179 };
6180 let enclose = |_a: f64, _b: f64| (Interval::entire(), Interval::entire());
6183 let error = enumerate_and_select_rho_with_controls(
6184 eval,
6185 enclose,
6186 None,
6187 ProfileSearchControls {
6188 lower: -1.0,
6189 upper: 1.0,
6190 resolution: 0.25,
6191 max_depth: 0,
6192 },
6193 |_root, _eval| {},
6194 )
6195 .expect_err("ambiguous stationary structure must refuse");
6196 assert!(matches!(error, EstimationError::RemlDidNotConverge { .. }));
6197 }
6198
6199 #[test]
6200 fn profiled_modal_evaluation_is_finite_beyond_exp_range() {
6201 let cache = synthetic_cache(&[4.0]);
6202 let ywy = array![5.0];
6203 let projected = array![[2.0]];
6204 for rho in [-1_000.0, 1_000.0] {
6205 let mode = modal_kernels(rho, 4.0);
6206 assert!(mode.log_one_plus_t.is_finite());
6207 assert!(mode.u.is_finite());
6208 assert!(mode.v.is_finite());
6209 assert!(mode.w.is_finite());
6210 assert!(mode.k.is_finite());
6211 let value = evaluate_reml_parts(&cache, ywy.view(), projected.view(), 10, 1, rho);
6212 assert!(value.cost.is_finite(), "non-finite cost at rho={rho}");
6213 assert!(value.grad.is_finite(), "non-finite gradient at rho={rho}");
6214 assert!(value.hess.is_finite(), "non-finite Hessian at rho={rho}");
6215 }
6216 }
6217
6218 #[test]
6223 fn landscape_certificate_classifies_small_designs() {
6224 let one_mode: &[(f64, f64, f64)] = &[(4.0, 2.0, 3.0), (0.5, 1.5, 2.0), (9.0, 0.8, 1.2)];
6226 for &(delta, q, resid) in one_mode {
6227 let cache = synthetic_cache(&[delta]);
6228 let ywy = array![q + resid];
6229 let prs = array![[q]];
6230 let n_eff = 12usize;
6231 let cert = rho_landscape_certificate_from_parts(
6232 &cache,
6233 ywy.view(),
6234 prs.view(),
6235 n_eff,
6236 1,
6237 None,
6238 )
6239 .expect("one-mode certificate");
6240 assert_eq!(cert.stationary_count, 1);
6241 assert_eq!(cert.landscape, RhoLandscape::UniqueInterior);
6242 assert_eq!(cert.root_brackets.len(), cert.stationary_count);
6243 }
6244
6245 let cache = synthetic_cache(&[0.5, 3.0]);
6247 let prs = array![[1.0], [0.4]];
6248 let ywy = array![1.0 + 0.4 + 1.5];
6249 let cert =
6250 rho_landscape_certificate_from_parts(&cache, ywy.view(), prs.view(), 30, 1, None)
6251 .expect("two-mode certificate");
6252 assert_eq!(cert.root_brackets.len(), cert.stationary_count);
6253 }
6254
6255 #[test]
6261 fn compactified_limit_cost_selects_boundary_when_no_interior_stationary_point() {
6262 let cache = synthetic_cache(&[1.0, 2.0]);
6263 let prs = array![[0.0], [0.0]];
6264 let ywy = array![1.0];
6265 let cert =
6266 rho_landscape_certificate_from_parts(&cache, ywy.view(), prs.view(), 20, 1, None)
6267 .expect("monotone certificate");
6268 assert_eq!(cert.stationary_count, 0);
6269 assert_eq!(cert.landscape, RhoLandscape::NoInteriorOptimum);
6270 assert!(cert.boundary_optimum);
6271 assert!(
6272 cert.limit_costs[0].is_infinite() && cert.limit_costs[0] > 0.0,
6273 "rho->-inf cost must diverge to +inf, got {}",
6274 cert.limit_costs[0]
6275 );
6276 assert!(
6277 cert.limit_costs[1].is_finite(),
6278 "rho->+inf limit cost must be finite, got {}",
6279 cert.limit_costs[1]
6280 );
6281 assert!(
6282 cert.limit_costs[1] < cert.window_costs[0],
6283 "large-λ boundary cost {} must undercut the small-λ endpoint {}",
6284 cert.limit_costs[1],
6285 cert.window_costs[0]
6286 );
6287 }
6288
6289 #[test]
6292 fn selected_rho_beats_every_certified_profile_candidate() {
6293 let mut rng = Lcg::new(0x9911_7733_5522_0044);
6294 for _case in 0..40 {
6295 let n_eig = 2 + (rng.next_u64() % 4) as usize;
6296 let eigs: Vec<f64> = (0..n_eig).map(|_| rng.range(-5.0, 6.0).exp()).collect();
6297 let cache = synthetic_cache(&eigs);
6298 let c2: Vec<f64> = (0..n_eig)
6299 .map(|_| {
6300 let v = rng.range(0.0, 2.5);
6301 v * v
6302 })
6303 .collect();
6304 let sum_c2: f64 = c2.iter().sum();
6305 let prs = Array2::from_shape_vec((n_eig, 1), c2).unwrap();
6306 let ywy = Array1::from(vec![sum_c2 + rng.range(0.05, 2.0)]);
6307 let n_eff = 80usize;
6308 let n_out = 1usize;
6309
6310 let eval =
6311 |rho: f64| evaluate_reml_parts(&cache, ywy.view(), prs.view(), n_eff, n_out, rho);
6312 let enclose = |a: f64, b: f64| {
6313 reml_deriv_enclosure(&cache, ywy.view(), prs.view(), n_eff, n_out, a, b)
6314 };
6315 let mut roots = Vec::new();
6316 let selection =
6317 enumerate_and_select_rho(&eval, &enclose, None, |root, _| roots.push(root.rho))
6318 .unwrap();
6319 let selected = selection.rho;
6320 let selected_cost = eval(selected).cost;
6321 let tol = 1.0e-8 * (1.0 + selected_cost.abs());
6322
6323 for &r in &roots {
6324 assert!(selected_cost <= eval(r).cost + tol);
6325 }
6326 assert!(selected_cost <= eval(RHO_LOWER).cost + tol);
6327 assert!(selected_cost <= eval(RHO_UPPER).cost + tol);
6328 }
6329 }
6330
6331 #[test]
6332 fn backward_from_fit_matches_backward_with_refit() {
6333 let x = array![[1.0, -0.9], [1.0, -0.4], [1.0, 0.1], [1.0, 0.6], [1.0, 1.1],];
6338 let y = array![[0.2, -0.1], [0.4, 0.1], [0.7, 0.3], [1.0, 0.5], [1.5, 0.8]];
6339 let penalty = array![[0.0, 0.0], [0.0, 1.5]];
6340 let weights = array![1.05, 0.95, 1.01, 0.99, 1.03];
6341
6342 let refit = gaussian_reml_multi_closed_form_backward(
6343 x.view(),
6344 y.view(),
6345 penalty.view(),
6346 Some(weights.view()),
6347 Some(0.85),
6348 0.2,
6349 None,
6350 None,
6351 -0.1,
6352 0.0,
6353 )
6354 .expect("refit backward");
6355
6356 let fit = gaussian_reml_multi_closed_form_with_cache(
6357 x.view(),
6358 y.view(),
6359 penalty.view(),
6360 Some(weights.view()),
6361 Some(0.85),
6362 None,
6363 )
6364 .expect("forward fit");
6365 let from_fit = gaussian_reml_multi_closed_form_backward_from_fit(
6366 x.view(),
6367 y.view(),
6368 penalty.view(),
6369 Some(weights.view()),
6370 &fit,
6371 0.2,
6372 None,
6373 None,
6374 -0.1,
6375 0.0,
6376 )
6377 .expect("from_fit backward");
6378
6379 for (a, b) in refit.grad_x.iter().zip(from_fit.grad_x.iter()) {
6380 assert!((a - b).abs() <= 1.0e-12);
6381 }
6382 for (a, b) in refit.grad_y.iter().zip(from_fit.grad_y.iter()) {
6383 assert!((a - b).abs() <= 1.0e-12);
6384 }
6385 for (a, b) in refit.grad_weights.iter().zip(from_fit.grad_weights.iter()) {
6386 assert!((a - b).abs() <= 1.0e-12);
6387 }
6388 }
6389
6390 #[test]
6400 fn backward_degrades_gracefully_when_k_is_near_singular() {
6401 let x = array![
6405 [1.0, -1.0, 0.5],
6406 [1.0, -0.5, 0.2],
6407 [1.0, 0.0, -0.1],
6408 [1.0, 0.5, 0.3],
6409 [1.0, 1.0, 0.8],
6410 [1.0, 1.5, 1.1],
6411 [1.0, 2.0, 1.5],
6412 [1.0, 2.5, 2.0],
6413 [1.0, 3.0, 2.6],
6414 [1.0, 3.5, 3.1],
6415 ];
6416 let y = array![
6417 [0.1],
6418 [0.3],
6419 [0.4],
6420 [0.7],
6421 [1.0],
6422 [1.5],
6423 [2.0],
6424 [2.7],
6425 [3.3],
6426 [4.0]
6427 ];
6428 let penalty = array![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
6430
6431 let mut fit =
6432 gaussian_reml_multi_closed_form(x.view(), y.view(), penalty.view(), None, Some(0.0))
6433 .expect("forward fit must succeed for well-posed input");
6434 fit.reml_hess_rho = 0.0;
6438
6439 let result = gaussian_reml_multi_closed_form_backward_from_fit(
6440 x.view(),
6441 y.view(),
6442 penalty.view(),
6443 None,
6444 &fit,
6445 1.0,
6448 None,
6449 None,
6450 1.0,
6451 1.0,
6452 )
6453 .expect("backward must NOT error on near-singular K");
6454
6455 assert_eq!(result.grad_x.dim(), (x.nrows(), x.ncols()));
6456 assert_eq!(result.grad_y.dim(), (y.nrows(), y.ncols()));
6457 assert_eq!(result.grad_penalty.dim(), (x.ncols(), x.ncols()));
6458 assert_eq!(result.grad_weights.dim(), x.nrows());
6459 for v in result.grad_x.iter() {
6460 assert!(v.is_finite(), "grad_x must be finite, got {v}");
6461 }
6462 for v in result.grad_y.iter() {
6463 assert!(v.is_finite(), "grad_y must be finite, got {v}");
6464 }
6465 for v in result.grad_penalty.iter() {
6466 assert!(v.is_finite(), "grad_penalty must be finite, got {v}");
6467 }
6468 for v in result.grad_weights.iter() {
6469 assert!(v.is_finite(), "grad_weights must be finite, got {v}");
6470 }
6471 }
6472}
6473
6474pub struct GaussianRemlBlocksBackwardAnalytic {
6478 pub grad_designs: Vec<Array2<f64>>,
6479 pub grad_penalties: Vec<Array2<f64>>,
6480 pub grad_y: Array2<f64>,
6481 pub grad_weights: Array1<f64>,
6482}
6483
6484pub fn gaussian_reml_fit_blocks_backward_analytic(
6493 designs: &[Array2<f64>],
6494 penalties_raw: &[Array2<f64>],
6495 y: ArrayView1<'_, f64>,
6496 weights: ArrayView1<'_, f64>,
6497 rhos: &[f64],
6498 grad_coefficients: Option<ArrayView2<'_, f64>>,
6499 grad_fitted: Option<ArrayView2<'_, f64>>,
6500 grad_lambdas: Option<ArrayView1<'_, f64>>,
6501 grad_log_lambdas: Option<ArrayView1<'_, f64>>,
6502 grad_reml_score: f64,
6503 grad_edf: Option<ArrayView1<'_, f64>>,
6504) -> Result<GaussianRemlBlocksBackwardAnalytic, EstimationError> {
6505 let n = y.len();
6506 let f_blocks = designs.len();
6507 let mut offsets = Vec::with_capacity(f_blocks + 1);
6508 offsets.push(0_usize);
6509 for design in designs {
6510 offsets.push(offsets.last().copied().unwrap() + design.ncols());
6511 }
6512 let p_total = *offsets.last().unwrap();
6513 if n == 0 || p_total == 0 {
6514 return Err(EstimationError::InvalidInput(
6515 "gaussian_reml_fit_blocks_backward requires non-empty rows and at least one coefficient column"
6516 .to_string(),
6517 ));
6518 }
6519
6520 if rhos.len() != f_blocks {
6521 return Err(EstimationError::InvalidInput(format!(
6522 "log_lambdas length mismatch: expected {f_blocks}, got {}",
6523 rhos.len()
6524 )));
6525 }
6526 if let Some(gc) = grad_coefficients {
6527 if gc.dim() != (p_total, 1) {
6528 return Err(EstimationError::InvalidInput(format!(
6529 "grad_coefficients shape mismatch: expected {}x1, got {}x{}",
6530 p_total,
6531 gc.nrows(),
6532 gc.ncols()
6533 )));
6534 }
6535 }
6536 if let Some(gf) = grad_fitted {
6537 if gf.dim() != (n, 1) {
6538 return Err(EstimationError::InvalidInput(format!(
6539 "grad_fitted shape mismatch: expected {}x1, got {}x{}",
6540 n,
6541 gf.nrows(),
6542 gf.ncols()
6543 )));
6544 }
6545 }
6546 if !grad_reml_score.is_finite() {
6547 return Err(EstimationError::InvalidInput(format!(
6548 "grad_reml_score must be finite; got {grad_reml_score}"
6549 )));
6550 }
6551 if let Some(vec) = grad_lambdas {
6552 if vec.len() != f_blocks {
6553 return Err(EstimationError::InvalidInput(format!(
6554 "grad_lambdas length mismatch: expected {f_blocks}, got {}",
6555 vec.len()
6556 )));
6557 }
6558 }
6559 if let Some(vec) = grad_log_lambdas {
6560 if vec.len() != f_blocks {
6561 return Err(EstimationError::InvalidInput(format!(
6562 "grad_log_lambdas length mismatch: expected {f_blocks}, got {}",
6563 vec.len()
6564 )));
6565 }
6566 }
6567 if let Some(vec) = grad_edf {
6568 if vec.len() != f_blocks {
6569 return Err(EstimationError::InvalidInput(format!(
6570 "grad_edf length mismatch: expected {f_blocks}, got {}",
6571 vec.len()
6572 )));
6573 }
6574 }
6575 if let Some(gc) = grad_coefficients {
6576 if let Some(((row, col), value)) = gc.indexed_iter().find(|(_, value)| !value.is_finite()) {
6577 return Err(EstimationError::InvalidInput(format!(
6578 "grad_coefficients[{row},{col}] must be finite; got {value}"
6579 )));
6580 }
6581 }
6582 if let Some(gf) = grad_fitted {
6583 if let Some(((row, col), value)) = gf.indexed_iter().find(|(_, value)| !value.is_finite()) {
6584 return Err(EstimationError::InvalidInput(format!(
6585 "grad_fitted[{row},{col}] must be finite; got {value}"
6586 )));
6587 }
6588 }
6589 if let Some(vec) = grad_lambdas {
6590 if let Some((block, value)) = vec.iter().enumerate().find(|(_, value)| !value.is_finite()) {
6591 return Err(EstimationError::InvalidInput(format!(
6592 "grad_lambdas[{block}] must be finite; got {value}"
6593 )));
6594 }
6595 }
6596 if let Some(vec) = grad_log_lambdas {
6597 if let Some((block, value)) = vec.iter().enumerate().find(|(_, value)| !value.is_finite()) {
6598 return Err(EstimationError::InvalidInput(format!(
6599 "grad_log_lambdas[{block}] must be finite; got {value}"
6600 )));
6601 }
6602 }
6603 if let Some(vec) = grad_edf {
6604 if let Some((block, value)) = vec.iter().enumerate().find(|(_, value)| !value.is_finite()) {
6605 return Err(EstimationError::InvalidInput(format!(
6606 "grad_edf[{block}] must be finite; got {value}"
6607 )));
6608 }
6609 }
6610 for (block, design) in designs.iter().enumerate() {
6611 if let Some(((row, col), value)) =
6612 design.indexed_iter().find(|(_, value)| !value.is_finite())
6613 {
6614 return Err(EstimationError::InvalidInput(format!(
6615 "designs[{block}][{row},{col}] must be finite; got {value}"
6616 )));
6617 }
6618 }
6619 for (block, penalty) in penalties_raw.iter().enumerate() {
6620 if let Some(((row, col), value)) =
6621 penalty.indexed_iter().find(|(_, value)| !value.is_finite())
6622 {
6623 return Err(EstimationError::InvalidInput(format!(
6624 "penalties[{block}][{row},{col}] must be finite; got {value}"
6625 )));
6626 }
6627 }
6628 if let Some((row, value)) = y.iter().enumerate().find(|(_, value)| !value.is_finite()) {
6629 return Err(EstimationError::InvalidInput(format!(
6630 "y[{row}] must be finite; got {value}"
6631 )));
6632 }
6633 if let Some((row, value)) = weights
6634 .iter()
6635 .enumerate()
6636 .find(|(_, value)| !value.is_finite() || **value < 0.0)
6637 {
6638 return Err(EstimationError::InvalidInput(format!(
6639 "weights[{row}] must be finite and non-negative; got {value}"
6640 )));
6641 }
6642
6643 let mut z = Array2::<f64>::zeros((n, p_total));
6644 for k in 0..f_blocks {
6645 z.slice_mut(s![.., offsets[k]..offsets[k + 1]])
6646 .assign(&designs[k]);
6647 }
6648
6649 let penalties: Vec<Array2<f64>> = penalties_raw
6650 .iter()
6651 .map(|p| {
6652 let mut out = p.clone();
6653 gam_linalg::matrix::symmetrize_in_place(&mut out);
6654 out
6655 })
6656 .collect();
6657 let mut ranks = Vec::with_capacity(f_blocks);
6658 let mut pinvs = Vec::with_capacity(f_blocks);
6659 for penalty in &penalties {
6660 let geometry = gam_linalg::utils::rank_certified_psd_pseudoinverse(penalty, 1.0e-10)?;
6661 ranks.push(geometry.rank());
6662 pinvs.push(geometry.into_pseudoinverse());
6663 }
6664
6665 let lambdas = Array1::from_iter(rhos.iter().map(|rho| rho.exp()));
6666 if let Some((block, lambda)) = lambdas
6667 .iter()
6668 .enumerate()
6669 .find(|(_, lambda)| !lambda.is_finite() || **lambda <= 0.0)
6670 {
6671 return Err(EstimationError::InvalidInput(format!(
6672 "exp(log_lambdas[{block}]) must be finite and positive; got {lambda}"
6673 )));
6674 }
6675 let mut k_matrix = fast_xt_diag_x(&z.view(), &weights);
6676 for block in 0..f_blocks {
6677 let lambda = lambdas[block];
6678 for local_i in 0..penalties[block].nrows() {
6679 let global_i = offsets[block] + local_i;
6680 for local_j in 0..penalties[block].ncols() {
6681 let global_j = offsets[block] + local_j;
6682 k_matrix[[global_i, global_j]] += lambda * penalties[block][[local_i, local_j]];
6683 }
6684 }
6685 }
6686 let r = gam_linalg::utils::certified_spd_inverse(
6687 &k_matrix,
6688 "block Gaussian REML penalized normal matrix",
6689 )
6690 .map(gam_linalg::utils::CertifiedSpdInverse::into_inverse)
6691 .map_err(|error| {
6692 EstimationError::InvalidInput(format!(
6693 "block Gaussian REML requires an exact SPD penalized normal matrix: {error}"
6694 ))
6695 })?;
6696
6697 let mut xtwy = Array1::<f64>::zeros(p_total);
6698 for row in 0..n {
6699 let wy = weights[row] * y[row];
6700 for col in 0..p_total {
6701 xtwy[col] += z[[row, col]] * wy;
6702 }
6703 }
6704 let beta = r.dot(&xtwy);
6705 let fitted = z.dot(&beta);
6706 if let Some((col, value)) = beta
6707 .iter()
6708 .enumerate()
6709 .find(|(_, value)| !value.is_finite())
6710 {
6711 return Err(EstimationError::InvalidInput(format!(
6712 "solved coefficient {col} is non-finite: {value}"
6713 )));
6714 }
6715 let residual = &y.to_owned() - &fitted;
6716 let weighted_residual = &residual * &weights.to_owned();
6717 let ywy = y
6718 .iter()
6719 .zip(weights.iter())
6720 .map(|(&yi, &wi)| wi * yi * yi)
6721 .sum::<f64>();
6722 let q_raw = ywy - xtwy.dot(&beta);
6723 if !q_raw.is_finite() {
6724 return Err(EstimationError::InvalidInput(format!(
6725 "Gaussian REML residual quadratic form must be finite; got {q_raw}"
6726 )));
6727 }
6728 let q = q_raw.max(1.0e-300);
6729 let nullity = penalties
6730 .iter()
6731 .zip(ranks.iter())
6732 .map(|(penalty, rank)| penalty.nrows().saturating_sub(*rank))
6733 .sum::<usize>();
6734 let nu = effective_observation_count(weights) as f64 - nullity as f64;
6737 if !(nu.is_finite() && nu > 0.0) {
6738 return Err(EstimationError::InvalidInput(format!(
6739 "Gaussian REML residual degrees of freedom must be positive; got {nu}"
6740 )));
6741 }
6742 let tau = nu / q;
6743 let tau_q = -nu / (q * q);
6744 if !(tau.is_finite() && tau_q.is_finite()) {
6745 return Err(EstimationError::InvalidInput(format!(
6746 "Gaussian REML scale derivatives are non-finite: tau={tau}, tau_q={tau_q}"
6747 )));
6748 }
6749
6750 let mut grad_z = Array2::<f64>::zeros((n, p_total));
6751 let mut g_kernel = Array2::<f64>::zeros((p_total, p_total));
6752 let mut h_kernel = Array1::<f64>::zeros(p_total);
6753 let mut q_kernel = 0.0_f64;
6754 let mut j_blocks: Vec<Array2<f64>> = penalties
6755 .iter()
6756 .map(|p| Array2::<f64>::zeros(p.dim()))
6757 .collect();
6758
6759 let mut beta_tilde = Array1::<f64>::zeros(p_total);
6760 if let Some(gc) = grad_coefficients {
6761 beta_tilde += &gc.column(0).to_owned();
6762 }
6763 if let Some(gf) = grad_fitted {
6764 let gf_col = gf.column(0).to_owned();
6765 beta_tilde += &z.t().dot(&gf_col);
6766 for row in 0..n {
6767 for col in 0..p_total {
6768 grad_z[[row, col]] += gf_col[row] * beta[col];
6769 }
6770 }
6771 }
6772
6773 let u = r.dot(&beta_tilde);
6778 h_kernel += &u;
6779 for i in 0..p_total {
6780 for j in 0..p_total {
6781 g_kernel[[i, j]] -= 0.5 * (beta[i] * u[j] + u[i] * beta[j]);
6782 }
6783 }
6784
6785 let mut alpha = Array1::<f64>::zeros(f_blocks);
6786 if let Some(gl) = grad_lambdas {
6787 for block in 0..f_blocks {
6788 alpha[block] += gl[block] * lambdas[block];
6789 }
6790 }
6791 if let Some(grho) = grad_log_lambdas {
6792 alpha += &grho.to_owned();
6793 }
6794
6795 let mut p_betas = Vec::with_capacity(f_blocks);
6796 let mut m_vectors = Vec::with_capacity(f_blocks);
6797 let mut rp_matrices = Vec::with_capacity(f_blocks);
6798 let mut rpr_matrices = Vec::with_capacity(f_blocks);
6799 let mut b_values = Array1::<f64>::zeros(f_blocks);
6800 let mut t_values = Array1::<f64>::zeros(f_blocks);
6801
6802 for block in 0..f_blocks {
6803 let start = offsets[block];
6804 let end = offsets[block + 1];
6805 let beta_k = beta.slice(s![start..end]).to_owned();
6806 let s_beta = penalties[block].dot(&beta_k);
6807 let lambda = lambdas[block];
6808 let lambda_s_beta = s_beta.mapv(|value| lambda * value);
6809 let mut p_beta = Array1::<f64>::zeros(p_total);
6810 for local_i in 0..(end - start) {
6811 p_beta[start + local_i] = lambda_s_beta[local_i];
6812 }
6813 let weighted_penalty = penalties[block].mapv(|value| lambda * value);
6814 let rp_block = r.slice(s![.., start..end]).dot(&weighted_penalty);
6815 let mut rp = Array2::<f64>::zeros((p_total, p_total));
6816 rp.slice_mut(s![.., start..end]).assign(&rp_block);
6817 let rpr = rp_block.dot(&r.slice(s![start..end, ..]));
6818 let m = r.slice(s![.., start..end]).dot(&lambda_s_beta);
6819 b_values[block] = beta.dot(&p_beta);
6820 t_values[block] = (0..(end - start))
6821 .map(|local_i| rp_block[[start + local_i, local_i]])
6822 .sum::<f64>();
6823 alpha[block] -= u.dot(&p_beta);
6824 p_betas.push(p_beta);
6825 m_vectors.push(m);
6826 rp_matrices.push(rp);
6827 rpr_matrices.push(rpr);
6828 }
6829
6830 if grad_reml_score != 0.0 {
6831 q_kernel += 0.5 * grad_reml_score * tau;
6832 g_kernel += &(r.clone() * (0.5 * grad_reml_score));
6833 for block in 0..f_blocks {
6834 j_blocks[block] -= &(pinvs[block].clone() * (0.5 * grad_reml_score / lambdas[block]));
6835 }
6836 }
6837
6838 let mut trace_pairs = Array2::<f64>::zeros((f_blocks, f_blocks));
6839 for i in 0..f_blocks {
6840 for j in 0..f_blocks {
6841 trace_pairs[[i, j]] =
6842 gam_linalg::utils::trace_of_product(rp_matrices[i].view(), rp_matrices[j].view());
6843 }
6844 }
6845
6846 if let Some(ge) = grad_edf {
6847 for edf_block in 0..f_blocks {
6848 let scale = ge[edf_block];
6849 if scale == 0.0 {
6850 continue;
6851 }
6852 let start = offsets[edf_block];
6853 let end = offsets[edf_block + 1];
6854 g_kernel += &(rpr_matrices[edf_block].clone() * scale);
6855 j_blocks[edf_block] -= &(r.slice(s![start..end, start..end]).to_owned() * scale);
6856 for rho_block in 0..f_blocks {
6857 alpha[rho_block] += scale * trace_pairs[[edf_block, rho_block]];
6858 if rho_block == edf_block {
6859 alpha[rho_block] -= scale * t_values[edf_block];
6860 }
6861 }
6862 }
6863 }
6864
6865 if let Some((block, value)) = alpha
6866 .iter()
6867 .enumerate()
6868 .find(|(_, value)| !value.is_finite())
6869 {
6870 return Err(EstimationError::InvalidInput(format!(
6871 "rho adjoint seed for block {block} is non-finite: {value}"
6872 )));
6873 }
6874
6875 if alpha.iter().any(|value| *value != 0.0) {
6876 let mut outer_h = Array2::<f64>::zeros((f_blocks, f_blocks));
6877 for k in 0..f_blocks {
6878 for j in 0..f_blocks {
6879 let beta_pk_r_pj_beta = p_betas[k].dot(&m_vectors[j]);
6880 outer_h[[k, j]] = 0.5 * trace_pairs[[k, j]] + tau * beta_pk_r_pj_beta
6881 - if k == j {
6882 0.5 * (t_values[k] + tau * b_values[k])
6883 } else {
6884 0.0
6885 }
6886 - 0.5 * tau_q * b_values[k] * b_values[j];
6887 }
6888 }
6889 gam_linalg::matrix::symmetrize_in_place(&mut outer_h);
6894 if let Some(((row, col), value)) =
6895 outer_h.indexed_iter().find(|(_, value)| !value.is_finite())
6896 {
6897 return Err(EstimationError::InvalidInput(format!(
6898 "outer rho curvature entry ({row},{col}) is non-finite: {value}"
6899 )));
6900 }
6901 let rho_adj = gam_linalg::utils::certified_symmetric_solve(
6902 &outer_h,
6903 &alpha,
6904 "block Gaussian REML outer-rho adjoint",
6905 )
6906 .map(gam_linalg::utils::CertifiedSymmetricSolution::into_solution)
6907 .map_err(|error| {
6908 EstimationError::InvalidInput(format!(
6909 "block Gaussian REML outer-rho adjoint is not exactly solvable: {error}"
6910 ))
6911 })?;
6912 if let Some((block, value)) = rho_adj
6913 .iter()
6914 .enumerate()
6915 .find(|(_, value)| !value.is_finite())
6916 {
6917 return Err(EstimationError::InvalidInput(format!(
6918 "outer rho adjoint for block {block} is non-finite: {value}"
6919 )));
6920 }
6921 let weighted_b_sum = rho_adj
6922 .iter()
6923 .zip(b_values.iter())
6924 .map(|(&zk, &bk)| zk * bk)
6925 .sum::<f64>();
6926 q_kernel += 0.5 * tau_q * weighted_b_sum;
6927 for block in 0..f_blocks {
6928 let zk = rho_adj[block];
6929 if zk == 0.0 {
6930 continue;
6931 }
6932 g_kernel -= &(rpr_matrices[block].clone() * (0.5 * zk));
6933 let m = &m_vectors[block];
6934 for i in 0..p_total {
6935 h_kernel[i] += tau * zk * m[i];
6936 for j in 0..p_total {
6937 g_kernel[[i, j]] -= 0.5 * tau * zk * (beta[i] * m[j] + m[i] * beta[j]);
6938 }
6939 }
6940 let start = offsets[block];
6941 let end = offsets[block + 1];
6942 j_blocks[block] += &(r.slice(s![start..end, start..end]).to_owned() * (0.5 * zk));
6943 for i in 0..(end - start) {
6944 for j in 0..(end - start) {
6945 j_blocks[block][[i, j]] += 0.5 * tau * zk * beta[start + i] * beta[start + j];
6946 }
6947 }
6948 }
6949 }
6950
6951 for row in 0..n {
6952 for col in 0..p_total {
6953 grad_z[[row, col]] += -2.0 * q_kernel * weighted_residual[row] * beta[col];
6954 }
6955 }
6956 let zg = z.dot(&g_kernel);
6957 for row in 0..n {
6958 for col in 0..p_total {
6959 grad_z[[row, col]] += 2.0 * weights[row] * zg[[row, col]];
6960 }
6961 }
6962 let wy = y.to_owned() * &weights.to_owned();
6963 for row in 0..n {
6964 for col in 0..p_total {
6965 grad_z[[row, col]] += wy[row] * h_kernel[col];
6966 }
6967 }
6968
6969 let mut grad_y = Array2::<f64>::zeros((n, 1));
6970 let zh = z.dot(&h_kernel);
6971 for row in 0..n {
6972 grad_y[[row, 0]] = 2.0 * q_kernel * weighted_residual[row] + weights[row] * zh[row];
6973 }
6974
6975 let mut grad_weights = Array1::<f64>::zeros(n);
6976 for row in 0..n {
6977 let diag_zgz = (0..p_total)
6978 .map(|col| z[[row, col]] * zg[[row, col]])
6979 .sum::<f64>();
6980 grad_weights[row] = q_kernel * residual[row] * residual[row] + diag_zgz + y[row] * zh[row];
6981 }
6982
6983 if grad_reml_score != 0.0 {
7005 let q_kernel_score = 0.5 * grad_reml_score * tau;
7006 let zr = z.dot(&r);
7007 let n_pos = (0..n).filter(|&i| weights[i] > 0.0).count();
7008 if n_pos > 0 {
7009 let mut weighted_score_partial_sum = 0.0_f64;
7010 for row in 0..n {
7011 if weights[row] <= 0.0 {
7012 continue;
7013 }
7014 let z_r_z = (0..p_total)
7015 .map(|col| z[[row, col]] * zr[[row, col]])
7016 .sum::<f64>();
7017 let a_score =
7018 q_kernel_score * residual[row] * residual[row] + 0.5 * grad_reml_score * z_r_z;
7019 weighted_score_partial_sum += weights[row] * a_score;
7020 }
7021 let projection = weighted_score_partial_sum / n_pos as f64;
7022 for row in 0..n {
7023 if weights[row] > 0.0 {
7024 grad_weights[row] -= projection / weights[row];
7025 }
7026 }
7027 }
7028 }
7029
7030 let mut grad_penalties = Vec::with_capacity(f_blocks);
7031 for block in 0..f_blocks {
7032 let start = offsets[block];
7033 let end = offsets[block + 1];
7034 let mut local = g_kernel.slice(s![start..end, start..end]).to_owned();
7035 for i in 0..(end - start) {
7036 for j in 0..(end - start) {
7037 local[[i, j]] += q_kernel * beta[start + i] * beta[start + j];
7038 }
7039 }
7040 local += &j_blocks[block];
7041 local *= lambdas[block];
7042 gam_linalg::matrix::symmetrize_in_place(&mut local);
7043 grad_penalties.push(local);
7044 }
7045
7046 let mut grad_designs = Vec::with_capacity(f_blocks);
7047 for block in 0..f_blocks {
7048 grad_designs.push(
7049 grad_z
7050 .slice(s![.., offsets[block]..offsets[block + 1]])
7051 .to_owned(),
7052 );
7053 }
7054
7055 Ok(GaussianRemlBlocksBackwardAnalytic {
7056 grad_designs,
7057 grad_penalties,
7058 grad_y,
7059 grad_weights,
7060 })
7061}
7062
7063pub struct DenseFisherGaussianFit {
7067 pub coefficients: Array2<f64>,
7068 pub fitted: Array2<f64>,
7069 pub sigma2: Array1<f64>,
7070 pub objective: f64,
7071}
7072
7073pub fn add_block_diagonal_penalty(
7076 hessian: &mut Array2<f64>,
7077 penalty: ArrayView2<'_, f64>,
7078 lambda: f64,
7079 n_outputs: usize,
7080) -> Result<(), EstimationError> {
7081 let k = penalty.ncols();
7082 if penalty.nrows() != k {
7083 return Err(EstimationError::InvalidInput(format!(
7084 "penalty must be square for dense Fisher fit; got {}x{}",
7085 penalty.nrows(),
7086 penalty.ncols()
7087 )));
7088 }
7089 if hessian.dim() != (k * n_outputs, k * n_outputs) {
7090 return Err(EstimationError::InvalidInput(
7091 "dense Fisher Hessian shape mismatch while adding penalty".to_string(),
7092 ));
7093 }
7094 for output in 0..n_outputs {
7095 let offset = output * k;
7096 for row in 0..k {
7097 for col in 0..k {
7098 let s_sym = 0.5 * (penalty[[row, col]] + penalty[[col, row]]);
7099 hessian[[offset + row, offset + col]] += lambda * s_sym;
7100 }
7101 }
7102 }
7103 Ok(())
7104}
7105
7106pub fn dense_fisher_gaussian_fit(
7113 design: ArrayView2<'_, f64>,
7114 y: ArrayView2<'_, f64>,
7115 penalty: ArrayView2<'_, f64>,
7116 row_weights: ArrayView1<'_, f64>,
7117 fisher_w: ArrayView3<'_, f64>,
7118 lambda: f64,
7119 latent_prior_score: f64,
7120) -> Result<DenseFisherGaussianFit, EstimationError> {
7121 let n_obs = design.nrows();
7122 let k = design.ncols();
7123 let n_outputs = y.ncols();
7124 let mut hessian = crate::pirls::dense_block_xtwx(design, fisher_w, Some(row_weights))?;
7125 add_block_diagonal_penalty(&mut hessian, penalty, lambda, n_outputs)?;
7126 let rhs = crate::pirls::dense_block_xtwy(design, fisher_w, y, Some(row_weights))?;
7127 let beta_vec =
7128 gam_linalg::utils::solve_dense_block_system(&hessian, &rhs, "dense Fisher Gaussian")
7129 .map_err(EstimationError::InvalidInput)?;
7130 let mut coefficients = Array2::<f64>::zeros((k, n_outputs));
7131 for output in 0..n_outputs {
7132 for col in 0..k {
7133 coefficients[[col, output]] = beta_vec[output * k + col];
7134 }
7135 }
7136 let fitted = design.dot(&coefficients);
7137 let mut sigma2 = Array1::<f64>::zeros(n_outputs);
7138 let mut objective = latent_prior_score;
7139 for row in 0..n_obs {
7140 for a in 0..n_outputs {
7141 let ra = y[[row, a]] - fitted[[row, a]];
7142 sigma2[a] += row_weights[row] * ra * ra;
7143 for b in 0..n_outputs {
7144 objective += 0.5
7145 * row_weights[row]
7146 * ra
7147 * fisher_w[[row, a, b]]
7148 * (y[[row, b]] - fitted[[row, b]]);
7149 }
7150 }
7151 }
7152 for output in 0..n_outputs {
7153 sigma2[output] /= (n_obs.saturating_sub(k).max(1)) as f64;
7154 let beta_col = coefficients.column(output);
7155 let s_beta = penalty.dot(&beta_col);
7156 objective += 0.5 * lambda * beta_col.dot(&s_beta);
7157 }
7158 Ok(DenseFisherGaussianFit {
7159 coefficients,
7160 fitted,
7161 sigma2,
7162 objective,
7163 })
7164}