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 grad: f64,
1044 hess: f64,
1045}
1046
1047fn block_orthogonal_scale_objective(
1048 eval: &BlockOrthogonalEval,
1049 rho: f64,
1050 scale_precision: ArrayView1<'_, f64>,
1051 rank: usize,
1052) -> BlockOrthogonalScaleDerivs {
1053 let d = scale_precision.len() as f64;
1054 let fit_term = scale_precision
1055 .iter()
1056 .zip(eval.fitted_energy.iter())
1057 .map(|(scale, energy)| scale * energy)
1058 .sum::<f64>();
1059 let value = 0.5 * d * eval.logdet - 0.5 * fit_term - 0.5 * d * (rank as f64) * rho;
1061 let grad = 0.5 * d * (eval.trace - rank as f64)
1065 + 0.5
1066 * scale_precision
1067 .iter()
1068 .zip(eval.penalty_energy.iter())
1069 .map(|(scale, energy)| scale * energy)
1070 .sum::<f64>();
1071 let hess = 0.5 * d * (eval.trace - eval.trace_pair)
1074 + 0.5
1075 * scale_precision
1076 .iter()
1077 .zip(eval.penalty_energy.iter().zip(eval.curvature_energy.iter()))
1078 .map(|(scale, (energy, curvature))| scale * (energy - 2.0 * curvature))
1079 .sum::<f64>();
1080 BlockOrthogonalScaleDerivs { value, grad, hess }
1081}
1082
1083fn solve_block_orthogonal_rho(
1090 gram: &Array2<f64>,
1091 rhs: &Array2<f64>,
1092 penalty: &Array2<f64>,
1093 rho0: f64,
1094 scale_precision: ArrayView1<'_, f64>,
1095 rank: usize,
1096 max_iter: usize,
1097) -> Result<(f64, BlockOrthogonalEval), EstimationError> {
1098 let mut rho = rho0;
1099 let mut current = block_orthogonal_eval(gram, rhs, penalty, rho)?;
1100 for _ in 0..max_iter {
1101 let derivs = block_orthogonal_scale_objective(¤t, rho, scale_precision, rank);
1104 let grad = derivs.grad;
1105 let hess = derivs.hess;
1106 if !(grad.is_finite() && hess.is_finite()) {
1107 return Err(EstimationError::ModelIsIllConditioned {
1108 condition_number: f64::INFINITY,
1109 });
1110 }
1111 if grad == 0.0 {
1112 break;
1113 }
1114 let direction = if hess > 0.0 { -grad / hess } else { -grad };
1120 if !direction.is_finite() || grad * direction >= 0.0 {
1121 return Err(EstimationError::ModelIsIllConditioned {
1122 condition_number: f64::INFINITY,
1123 });
1124 }
1125 let current_value = derivs.value;
1126 let mut step_scale = 1.0_f64;
1127 let accepted = loop {
1128 let candidate_rho = rho + step_scale * direction;
1129 if candidate_rho == rho {
1130 break None;
1131 }
1132 if let Ok(candidate_eval) = block_orthogonal_eval(gram, rhs, penalty, candidate_rho) {
1133 let candidate_value = block_orthogonal_scale_objective(
1134 &candidate_eval,
1135 candidate_rho,
1136 scale_precision,
1137 rank,
1138 )
1139 .value;
1140 if candidate_value.is_finite() && candidate_value < current_value {
1141 break Some((candidate_rho, candidate_eval));
1142 }
1143 }
1144 step_scale *= 0.5;
1148 };
1149 let Some((next_rho, next_eval)) = accepted else {
1150 break;
1151 };
1152 rho = next_rho;
1153 current = next_eval;
1154 }
1155 Ok((rho, current))
1156}
1157
1158fn block_orthogonal_conditional_scale(
1159 evals: &[BlockOrthogonalEval],
1160 ywy: ArrayView1<'_, f64>,
1161 nu: f64,
1162) -> Result<Array1<f64>, EstimationError> {
1163 let mut explained = Array1::<f64>::zeros(ywy.len());
1164 for eval in evals {
1165 explained += &eval.fitted_energy;
1166 }
1167 let q = &ywy - &explained;
1168 if q.iter().any(|value| !value.is_finite() || *value <= 0.0) {
1169 return Err(EstimationError::ModelIsIllConditioned {
1170 condition_number: f64::INFINITY,
1171 });
1172 }
1173 let scale = q.mapv(|value| nu / value);
1174 if scale
1175 .iter()
1176 .any(|value| !value.is_finite() || *value <= 0.0)
1177 {
1178 return Err(EstimationError::ModelIsIllConditioned {
1179 condition_number: f64::INFINITY,
1180 });
1181 }
1182 Ok(scale)
1183}
1184
1185fn validate_weighted_block_orthogonality(
1191 designs: &[Array2<f64>],
1192 weight: ArrayView1<'_, f64>,
1193) -> Result<(), EstimationError> {
1194 let unit_roundoff = 0.5 * f64::EPSILON;
1195 let operation_count = weight.len().saturating_mul(4);
1196 let accumulated = operation_count as f64 * unit_roundoff;
1197 if accumulated >= 1.0 {
1198 crate::bail_invalid_estim!(
1199 "block-orthogonality verification has no finite floating-point error bound for {} rows",
1200 weight.len()
1201 );
1202 }
1203 let gamma = accumulated / (1.0 - accumulated);
1204 for left_block in 0..designs.len() {
1205 for right_block in (left_block + 1)..designs.len() {
1206 let left = &designs[left_block];
1207 let right = &designs[right_block];
1208 for left_col in 0..left.ncols() {
1209 for right_col in 0..right.ncols() {
1210 let mut cross_product = 0.0_f64;
1211 let mut magnitude_sum = 0.0_f64;
1212 for row in 0..weight.len() {
1213 let term = weight[row] * left[[row, left_col]] * right[[row, right_col]];
1214 cross_product += term;
1215 magnitude_sum += term.abs();
1216 }
1217 let roundoff = gamma * magnitude_sum;
1218 if !cross_product.is_finite()
1219 || !roundoff.is_finite()
1220 || cross_product.abs() > roundoff
1221 {
1222 crate::bail_invalid_estim!(
1223 "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}"
1224 );
1225 }
1226 }
1227 }
1228 }
1229 }
1230 Ok(())
1231}
1232
1233#[derive(Clone, Copy, Debug)]
1234struct BlockOrthogonalProfileCurvature {
1235 min_eigenvalue: f64,
1236 roundoff: f64,
1237}
1238
1239fn block_orthogonal_profile_hessian(
1249 evals: &[BlockOrthogonalEval],
1250 rhos: ArrayView1<'_, f64>,
1251 scale_precision: ArrayView1<'_, f64>,
1252 ranks: &[usize],
1253 nu: f64,
1254) -> Result<Array2<f64>, EstimationError> {
1255 let blocks = evals.len();
1256 let mut hessian = Array2::<f64>::zeros((blocks, blocks));
1257 for block in 0..blocks {
1258 hessian[[block, block]] = block_orthogonal_scale_objective(
1259 &evals[block],
1260 rhos[block],
1261 scale_precision.view(),
1262 ranks[block],
1263 )
1264 .hess;
1265 }
1266 for left in 0..blocks {
1267 for right in 0..=left {
1268 let correction = evals[left]
1269 .penalty_energy
1270 .iter()
1271 .zip(evals[right].penalty_energy.iter())
1272 .zip(scale_precision.iter())
1273 .map(|((&left_energy, &right_energy), &scale)| {
1274 0.5 * scale * scale * left_energy * right_energy / nu
1275 })
1276 .sum::<f64>();
1277 hessian[[left, right]] -= correction;
1278 if left != right {
1279 hessian[[right, left]] -= correction;
1280 }
1281 }
1282 }
1283 if hessian.iter().any(|value| !value.is_finite()) {
1284 return Err(EstimationError::ModelIsIllConditioned {
1285 condition_number: f64::INFINITY,
1286 });
1287 }
1288 Ok(hessian)
1289}
1290
1291fn block_orthogonal_profile_curvature(
1296 evals: &[BlockOrthogonalEval],
1297 rhos: ArrayView1<'_, f64>,
1298 scale_precision: ArrayView1<'_, f64>,
1299 ranks: &[usize],
1300 nu: f64,
1301) -> Result<BlockOrthogonalProfileCurvature, EstimationError> {
1302 let hessian = block_orthogonal_profile_hessian(evals, rhos, scale_precision, ranks, nu)?;
1303 let blocks = hessian.nrows();
1304 let eigenvalues = hessian
1305 .eigh(Side::Lower)
1306 .map_err(|_| EstimationError::ModelIsIllConditioned {
1307 condition_number: f64::INFINITY,
1308 })?
1309 .0;
1310 let min_eigenvalue = eigenvalues.iter().copied().fold(f64::INFINITY, f64::min);
1311 let spectral_scale = eigenvalues
1312 .iter()
1313 .copied()
1314 .map(f64::abs)
1315 .fold(0.0_f64, f64::max);
1316 let roundoff = f64::EPSILON * blocks.max(1) as f64 * spectral_scale.max(f64::MIN_POSITIVE);
1317 Ok(BlockOrthogonalProfileCurvature {
1318 min_eigenvalue,
1319 roundoff,
1320 })
1321}
1322
1323pub fn gaussian_reml_blocks_orthogonal_shared_scale(
1324 designs: &[Array2<f64>],
1325 penalties: &[Array2<f64>],
1326 y: ArrayView2<'_, f64>,
1327 weights: Option<ArrayView1<'_, f64>>,
1328 init_rhos: Option<&[f64]>,
1329) -> Result<GaussianRemlBlockOrthogonalResult, EstimationError> {
1330 gaussian_reml_blocks_orthogonal_shared_scale_with_controls(
1331 designs,
1332 penalties,
1333 y,
1334 weights,
1335 init_rhos,
1336 BlockOrthogonalControls::default(),
1337 )
1338}
1339
1340fn gaussian_reml_blocks_orthogonal_shared_scale_with_controls(
1341 designs: &[Array2<f64>],
1342 penalties: &[Array2<f64>],
1343 y: ArrayView2<'_, f64>,
1344 weights: Option<ArrayView1<'_, f64>>,
1345 init_rhos: Option<&[f64]>,
1346 controls: BlockOrthogonalControls,
1347) -> Result<GaussianRemlBlockOrthogonalResult, EstimationError> {
1348 if designs.is_empty() {
1349 crate::bail_invalid_estim!("block-orthogonal Gaussian REML requires at least one block");
1350 }
1351 if designs.len() != penalties.len() {
1352 crate::bail_invalid_estim!(
1353 "block-orthogonal Gaussian REML block mismatch: {} designs, {} penalties",
1354 designs.len(),
1355 penalties.len()
1356 );
1357 }
1358 let n = y.nrows();
1359 let d = y.ncols();
1360 if d == 0 {
1361 crate::bail_invalid_estim!("block-orthogonal Gaussian REML requires at least one output");
1362 }
1363 if y.iter().any(|value| !value.is_finite()) {
1364 crate::bail_invalid_estim!("block-orthogonal Gaussian REML response must be finite");
1365 }
1366 let weight = gaussian_reml_weights(n, weights)?;
1367 if let Some(rhos) = init_rhos {
1368 if rhos.len() != designs.len() {
1369 crate::bail_invalid_estim!(
1370 "block-orthogonal Gaussian REML init_rhos length mismatch: expected {}, got {}",
1371 designs.len(),
1372 rhos.len()
1373 );
1374 }
1375 if rhos.iter().any(|value| !value.is_finite()) {
1376 crate::bail_invalid_estim!("block-orthogonal Gaussian REML init_rhos must be finite");
1377 }
1378 }
1379
1380 let mut ywy = Array1::<f64>::zeros(d);
1381 for row in 0..n {
1382 for output in 0..d {
1383 ywy[output] += weight[row] * y[[row, output]] * y[[row, output]];
1384 }
1385 }
1386 let mut grams = Vec::with_capacity(designs.len());
1387 let mut rhs_blocks = Vec::with_capacity(designs.len());
1388 let mut penalties_owned = Vec::with_capacity(penalties.len());
1389 let mut ranks = Vec::with_capacity(penalties.len());
1390 let mut penalty_logdets = Vec::with_capacity(penalties.len());
1391 let mut nullity_total = 0_usize;
1392 for (block, (design, penalty)) in designs.iter().zip(penalties.iter()).enumerate() {
1393 let penalty_owned = canonicalize_penalty(penalty.view());
1394 validate_gaussian_reml_design(design.view(), penalty_owned.view(), Some(weight.view()))?;
1395 if design.nrows() != n {
1396 crate::bail_invalid_estim!(
1397 "block-orthogonal Gaussian REML designs[{block}] has {} rows, expected {n}",
1398 design.nrows()
1399 );
1400 }
1401 let gram = dense_xt_diag_x(design.view(), weight.view());
1402 let rhs = dense_xt_diag_y(design.view(), weight.view(), y);
1403 let (rank, logdet) = block_penalty_rank_logdet(penalty_owned.view())?;
1404 nullity_total += penalty_owned.nrows().saturating_sub(rank);
1405 grams.push(canonicalize_penalty(gram.view()));
1406 rhs_blocks.push(rhs);
1407 penalties_owned.push(penalty_owned);
1408 ranks.push(rank);
1409 penalty_logdets.push(logdet);
1410 }
1411 validate_weighted_block_orthogonality(designs, weight.view())?;
1412 let n_effective = effective_observation_count(weight.view());
1413 if n_effective <= nullity_total {
1414 crate::bail_invalid_estim!(
1415 "block-orthogonal Gaussian REML requires more positive-weight rows than the total penalty nullity; got n_effective={n_effective}, nullity={nullity_total}"
1416 );
1417 }
1418 let nu = (n_effective - nullity_total) as f64;
1419 let mut rhos = match init_rhos {
1420 Some(values) => Array1::from_vec(values.to_vec()),
1421 None => Array1::zeros(designs.len()),
1422 };
1423 let mut evals = (0..designs.len())
1428 .map(|block| {
1429 block_orthogonal_eval(
1430 &grams[block],
1431 &rhs_blocks[block],
1432 &penalties_owned[block],
1433 rhos[block],
1434 )
1435 })
1436 .collect::<Result<Vec<_>, _>>()?;
1437 let mut scale_precision = block_orthogonal_conditional_scale(&evals, ywy.view(), nu)?;
1438 let mut converged = false;
1457 let mut cycle_detected = false;
1458 let mut outer_passes = 0usize;
1459 let mut last_score_residual = f64::INFINITY;
1460 let mut last_min_profile_curvature = f64::NEG_INFINITY;
1461 let mut last_profile_curvature_roundoff = 0.0_f64;
1462 let mut last_scale_step = f64::INFINITY;
1463 let mut recent_states: [Option<(Array1<f64>, Array1<f64>)>; 2] = [None, None];
1464 while outer_passes < controls.max_outer_passes {
1465 outer_passes += 1;
1466 evals.clear();
1467 for block in 0..designs.len() {
1468 let (rho, eval) = solve_block_orthogonal_rho(
1469 &grams[block],
1470 &rhs_blocks[block],
1471 &penalties_owned[block],
1472 rhos[block],
1473 scale_precision.view(),
1474 ranks[block],
1475 controls.block_updates_per_pass,
1476 )?;
1477 rhos[block] = rho;
1478 evals.push(eval);
1479 }
1480 let next_scale = block_orthogonal_conditional_scale(&evals, ywy.view(), nu)?;
1481 last_scale_step = next_scale
1482 .iter()
1483 .zip(scale_precision.iter())
1484 .map(|(next, old)| (next.ln() - old.ln()).abs())
1485 .fold(0.0_f64, f64::max);
1486 scale_precision = next_scale;
1487 last_score_residual = 0.0;
1488 for (block, eval) in evals.iter().enumerate() {
1489 let derivs = block_orthogonal_scale_objective(
1490 eval,
1491 rhos[block],
1492 scale_precision.view(),
1493 ranks[block],
1494 );
1495 let residual = derivs.grad.abs() / ((d as f64) * (ranks[block].max(1) as f64));
1496 if !residual.is_finite() {
1497 return Err(EstimationError::ModelIsIllConditioned {
1498 condition_number: f64::INFINITY,
1499 });
1500 }
1501 last_score_residual = last_score_residual.max(residual);
1502 }
1503 let curvature = block_orthogonal_profile_curvature(
1504 &evals,
1505 rhos.view(),
1506 scale_precision.view(),
1507 &ranks,
1508 nu,
1509 )?;
1510 last_min_profile_curvature = curvature.min_eigenvalue;
1511 last_profile_curvature_roundoff = curvature.roundoff;
1512 if last_score_residual <= controls.score_tol
1513 && last_min_profile_curvature >= -last_profile_curvature_roundoff
1514 {
1515 converged = true;
1516 break;
1517 }
1518 let state = (rhos.clone(), scale_precision.clone());
1524 if recent_states
1525 .iter()
1526 .flatten()
1527 .any(|prev| prev.0 == state.0 && prev.1 == state.1)
1528 {
1529 cycle_detected = true;
1530 break;
1531 }
1532 recent_states[1] = recent_states[0].take();
1533 recent_states[0] = Some(state);
1534 }
1535 if !converged {
1536 return Err(EstimationError::BlockOrthogonalRemlDidNotConverge {
1537 iterations: outer_passes,
1538 max_score_residual: last_score_residual,
1539 score_tol: controls.score_tol,
1540 min_profile_curvature: last_min_profile_curvature,
1541 profile_curvature_roundoff: last_profile_curvature_roundoff,
1542 last_scale_step,
1543 cycle_detected,
1544 rho_checkpoint: rhos.to_vec(),
1545 });
1546 }
1547
1548 let coefficients = evals
1549 .iter()
1550 .map(|eval| eval.beta.clone())
1551 .collect::<Vec<_>>();
1552 let mut fitted = Array2::<f64>::zeros((n, d));
1553 for (design, coef) in designs.iter().zip(coefficients.iter()) {
1554 fitted += &fast_ab(&design.view(), &coef.view());
1555 }
1556 let mut explained = Array1::<f64>::zeros(d);
1557 for eval in evals.iter() {
1558 explained += &eval.fitted_energy;
1559 }
1560 let q = &ywy - &explained;
1561 if q.iter().any(|value| !value.is_finite() || *value <= 0.0) {
1562 return Err(EstimationError::ModelIsIllConditioned {
1563 condition_number: f64::INFINITY,
1564 });
1565 }
1566 let lambdas = Array1::from_vec(gam_problem::checked_exp_log_strengths(
1567 rhos.iter().copied(),
1568 )?);
1569 let edf = Array1::from_iter(evals.iter().map(|eval| eval.edf));
1570 let logdet_term = evals
1571 .iter()
1572 .enumerate()
1573 .map(|(block, eval)| {
1574 eval.logdet - penalty_logdets[block] - (ranks[block] as f64) * rhos[block]
1575 })
1576 .sum::<f64>();
1577 let scale_term = q
1578 .iter()
1579 .map(|value| nu * (1.0 + (2.0 * std::f64::consts::PI * value / nu).ln()))
1580 .sum::<f64>();
1581 Ok(GaussianRemlBlockOrthogonalResult {
1582 coefficients,
1583 fitted,
1584 lambdas,
1585 log_lambdas: rhos,
1586 reml_score: 0.5 * (d as f64) * logdet_term + 0.5 * scale_term,
1587 edf,
1588 })
1589}
1590
1591pub fn gaussian_reml_multi_shared_dispersion_penalty_gradient_from_fit(
1602 x: ArrayView2<'_, f64>,
1603 y: ArrayView2<'_, f64>,
1604 penalty: ArrayView2<'_, f64>,
1605 weights: Option<ArrayView1<'_, f64>>,
1606 fit: &GaussianRemlMultiResult,
1607) -> Result<Array2<f64>, EstimationError> {
1608 validate_gaussian_reml_forward_fit(x, y, penalty, weights, fit)?;
1609 let n = x.nrows();
1610 let p = x.ncols();
1611 let d = y.ncols();
1612 if d == 0 {
1613 crate::bail_invalid_estim!(
1614 "shared-dispersion REML penalty gradient requires at least one response column"
1615 );
1616 }
1617 let weight = gaussian_reml_weights(n, weights)?;
1618 let n_effective = effective_observation_count(weight.view());
1619 let per_output_nu = n_effective.checked_sub(fit.cache.nullity).ok_or_else(|| {
1620 EstimationError::InvalidInput(
1621 "shared-dispersion REML penalty gradient has non-positive residual degrees of freedom"
1622 .to_string(),
1623 )
1624 })?;
1625 if per_output_nu == 0 {
1626 crate::bail_invalid_estim!(
1627 "shared-dispersion REML penalty gradient requires positive residual degrees of freedom"
1628 );
1629 }
1630 let shared_nu = (d as f64) * (per_output_nu as f64);
1631 let shared_sigma2 = fit.sigma2[0];
1642 if fit
1643 .sigma2
1644 .iter()
1645 .any(|sigma2| sigma2.to_bits() != shared_sigma2.to_bits())
1646 {
1647 crate::bail_invalid_estim!(
1648 "shared-dispersion REML penalty gradient requires one shared forward dispersion"
1649 );
1650 }
1651 let pooled_deviance = shared_sigma2 * shared_nu;
1652 if !(pooled_deviance.is_finite() && pooled_deviance > 0.0) {
1653 crate::bail_invalid_estim!(
1654 "shared-dispersion REML penalty gradient requires positive forward deviance"
1655 );
1656 }
1657
1658 let inverse_hessian = gaussian_reml_inverse_hessian_from_cache(&fit.cache, fit.lambda)?;
1659 let penalty_pseudoinverse = gaussian_reml_penalty_pseudoinverse_from_cache(&fit.cache);
1660 let mut gradient = Array2::<f64>::zeros((p, p));
1661 for row in 0..p {
1662 for col in 0..p {
1663 gradient[[row, col]] = 0.5
1664 * (d as f64)
1665 * (fit.lambda * inverse_hessian[[col, row]] - penalty_pseudoinverse[[col, row]]);
1666 }
1667 }
1668 let deviance_scale = 0.5 * shared_nu * fit.lambda / pooled_deviance;
1669 for output in 0..d {
1670 add_rank_one_penalty_vjp(
1671 deviance_scale,
1672 fit.coefficients.column(output),
1673 &mut gradient,
1674 );
1675 }
1676 for row in 0..p {
1677 for col in (row + 1)..p {
1678 let mean = 0.5 * (gradient[[row, col]] + gradient[[col, row]]);
1679 gradient[[row, col]] = mean;
1680 gradient[[col, row]] = mean;
1681 }
1682 }
1683 if gradient.iter().any(|value| !value.is_finite()) {
1684 crate::bail_invalid_estim!(
1685 "shared-dispersion REML penalty gradient produced a non-finite value"
1686 );
1687 }
1688 Ok(gradient)
1689}
1690
1691fn gaussian_reml_multi_closed_form_from_parts(
1692 x: ArrayView2<'_, f64>,
1693 y: ArrayView2<'_, f64>,
1694 penalty: ArrayView2<'_, f64>,
1695 nullspace_dim: Option<usize>,
1696 weights: Option<ArrayView1<'_, f64>>,
1697 init_lambda: Option<f64>,
1698 eigen_cache: Option<&GaussianRemlEigenCache>,
1699) -> Result<GaussianRemlMultiResult, EstimationError> {
1700 let prepared = prepare_gaussian_reml(x, y, penalty, nullspace_dim, weights, eigen_cache)?;
1701 let init_rho = init_lambda
1702 .map(validate_initial_lambda)
1703 .transpose()?
1704 .map(f64::ln);
1705 let rho = optimize_rho(&prepared, init_rho)?;
1706 let eval = prepared.evaluate(rho);
1707 let lambda = gam_problem::checked_exp_log_strength(rho)
1708 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
1709 let coefficients = prepared.coefficients(lambda);
1710 let fitted = dense_ab(x, coefficients.view());
1711 let sigma2 = prepared.sigma2(lambda);
1712 let (reml_grad_lambda, reml_hess_lambda) =
1713 rho_derivatives_to_lambda(lambda, eval.grad, eval.hess);
1714 Ok(GaussianRemlMultiResult {
1715 lambda,
1716 rho,
1717 coefficients,
1718 fitted,
1719 reml_score: eval.cost,
1720 reml_grad_lambda,
1721 reml_hess_lambda,
1722 reml_grad_rho: eval.grad,
1723 reml_hess_rho: eval.hess,
1724 edf: eval.edf,
1725 sigma2,
1726 cache: prepared.cache,
1727 })
1728}
1729
1730pub fn gaussian_reml_free_b_score(
1731 x: ArrayView2<'_, f64>,
1732 y: ArrayView2<'_, f64>,
1733 coefficients: ArrayView2<'_, f64>,
1734 log_lambda: f64,
1735 penalty: ArrayView2<'_, f64>,
1736 weights: Option<ArrayView1<'_, f64>>,
1737) -> Result<GaussianRemlFreeBScore, EstimationError> {
1738 let lambda = gam_problem::checked_exp_log_strength(log_lambda)
1739 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
1740 let penalty_owned = canonicalize_penalty(penalty);
1741 let penalty = penalty_owned.view();
1742 let n = x.nrows();
1743 let p = x.ncols();
1744 let d = y.ncols();
1745 validate_gaussian_reml_design(x, penalty, weights)?;
1746 if y.nrows() != n {
1747 crate::bail_invalid_estim!(
1748 "Gaussian REML row mismatch: X has {n} rows but Y has {}",
1749 y.nrows()
1750 );
1751 }
1752 if coefficients.dim() != (p, d) {
1753 crate::bail_invalid_estim!(
1754 "Gaussian REML coefficient shape mismatch: expected {p}x{d}, got {}x{}",
1755 coefficients.nrows(),
1756 coefficients.ncols()
1757 );
1758 }
1759 if y.iter().chain(coefficients.iter()).any(|v| !v.is_finite()) {
1760 crate::bail_invalid_estim!("Gaussian REML inputs must be finite");
1761 }
1762
1763 let weight = gaussian_reml_weights(n, weights)?;
1764 let n_effective = effective_observation_count(weight.view());
1765 let cache =
1766 build_gaussian_reml_eigen_cache_with_nullspace_dim(x, penalty, None, Some(weight.view()))?;
1767 if n_effective <= cache.nullity {
1768 crate::bail_invalid_estim!(
1769 "Gaussian REML requires more positive-weight rows than the nullspace dimension; got n_effective={n_effective}, nullity={}",
1770 cache.nullity
1771 );
1772 }
1773 let nu = n_effective as f64 - cache.nullity as f64;
1774 let fitted = dense_ab(x, coefficients);
1775 let residual = y.to_owned() - &fitted;
1776 let xtw_residual = dense_xt_diag_y(x, weight.view(), residual.view());
1777 let s_beta = dense_ab(penalty, coefficients);
1778
1779 let mut logdet_h = cache.logdet_xtwx;
1780 let mut trace_h = 0.0;
1781 let mut edf = 0.0;
1782 for &delta in &cache.penalty_eigenvalues {
1783 let t = lambda * delta;
1784 logdet_h += (1.0 + t).ln();
1785 if delta > 0.0 {
1786 trace_h += t / (1.0 + t);
1787 }
1788 edf += 1.0 / (1.0 + t);
1789 }
1790 let logdet_s = cache.logdet_penalty_positive + (cache.penalty_rank as f64) * log_lambda;
1791 let mut reml_score = 0.5 * (d as f64) * (logdet_h - logdet_s);
1792 let mut grad_log_lambda = 0.5 * (d as f64) * (trace_h - cache.penalty_rank as f64);
1793 let mut grad_coefficients = Array2::<f64>::zeros((p, d));
1794 let inverse_hessian = {
1795 let xtwx = dense_xt_diag_x(x, weight.view());
1796 let mut hessian = xtwx;
1797 hessian += &(penalty.to_owned() * lambda);
1798 hessian
1799 .cholesky(Side::Lower)
1800 .map_err(EstimationError::LinearSystemSolveFailed)?
1801 .solve_mat(&Array2::<f64>::eye(p))
1802 };
1803 let penalty_pinv = gaussian_reml_penalty_pseudoinverse_from_cache(&cache);
1804 let mut grad_penalty = Array2::<f64>::zeros((p, p));
1805 for row in 0..p {
1806 for col in 0..p {
1807 grad_penalty[[row, col]] += 0.5
1808 * (d as f64)
1809 * (lambda * inverse_hessian[[col, row]] - penalty_pinv[[col, row]]);
1810 }
1811 }
1812 let mut sigma2 = Array1::<f64>::zeros(d);
1813
1814 for output in 0..d {
1815 let mut weighted_rss = 0.0;
1816 for row in 0..n {
1817 let r = residual[[row, output]];
1818 weighted_rss += weight[row] * r * r;
1819 }
1820 let beta_col = coefficients.column(output);
1821 let s_beta_col = s_beta.column(output);
1822 let penalty_quadratic = beta_col.dot(&s_beta_col);
1823 let dp = (weighted_rss + lambda * penalty_quadratic).max(MIN_DEVIANCE);
1824 sigma2[output] = dp / nu;
1825 reml_score += 0.5 * nu * (1.0 + (2.0 * std::f64::consts::PI * dp / nu).ln());
1826 grad_log_lambda += 0.5 * nu * lambda * penalty_quadratic / dp;
1827 let scale = nu / dp;
1828 for coeff in 0..p {
1829 grad_coefficients[[coeff, output]] =
1830 scale * (-xtw_residual[[coeff, output]] + lambda * s_beta[[coeff, output]]);
1831 }
1832 add_rank_one_penalty_vjp(0.5 * scale * lambda, beta_col, &mut grad_penalty);
1833 }
1834 for i in 0..p {
1835 for j in (i + 1)..p {
1836 let avg = 0.5 * (grad_penalty[[i, j]] + grad_penalty[[j, i]]);
1837 grad_penalty[[i, j]] = avg;
1838 grad_penalty[[j, i]] = avg;
1839 }
1840 }
1841
1842 Ok(GaussianRemlFreeBScore {
1843 reml_score,
1844 grad_coefficients,
1845 grad_penalty,
1846 grad_log_lambda,
1847 fitted,
1848 sigma2,
1849 edf,
1850 })
1851}
1852
1853pub fn gaussian_reml_multi_closed_form_backward(
1854 x: ArrayView2<'_, f64>,
1855 y: ArrayView2<'_, f64>,
1856 penalty: ArrayView2<'_, f64>,
1857 weights: Option<ArrayView1<'_, f64>>,
1858 init_lambda: Option<f64>,
1859 upstream_lambda: f64,
1860 upstream_coefficients: Option<ArrayView2<'_, f64>>,
1861 upstream_fitted: Option<ArrayView2<'_, f64>>,
1862 upstream_reml_score: f64,
1863 upstream_edf: f64,
1864) -> Result<GaussianRemlBackwardResult, EstimationError> {
1865 let fit =
1866 gaussian_reml_multi_closed_form_with_cache(x, y, penalty, weights, init_lambda, None)?;
1867 gaussian_reml_multi_closed_form_backward_from_fit(
1868 x,
1869 y,
1870 penalty,
1871 weights,
1872 &fit,
1873 upstream_lambda,
1874 upstream_coefficients,
1875 upstream_fitted,
1876 upstream_reml_score,
1877 upstream_edf,
1878 )
1879}
1880
1881pub fn gaussian_reml_multi_closed_form_backward_from_fit(
1882 x: ArrayView2<'_, f64>,
1883 y: ArrayView2<'_, f64>,
1884 penalty: ArrayView2<'_, f64>,
1885 weights: Option<ArrayView1<'_, f64>>,
1886 fit: &GaussianRemlMultiResult,
1887 upstream_lambda: f64,
1888 upstream_coefficients: Option<ArrayView2<'_, f64>>,
1889 upstream_fitted: Option<ArrayView2<'_, f64>>,
1890 upstream_reml_score: f64,
1891 upstream_edf: f64,
1892) -> Result<GaussianRemlBackwardResult, EstimationError> {
1893 validate_gaussian_reml_backward_upstreams(
1894 x,
1895 y,
1896 penalty,
1897 upstream_lambda,
1898 upstream_coefficients,
1899 upstream_fitted,
1900 upstream_reml_score,
1901 upstream_edf,
1902 )?;
1903 validate_gaussian_reml_forward_fit(x, y, penalty, weights, fit)?;
1904 let lambda = fit.lambda;
1905 let n = x.nrows();
1906 let p = x.ncols();
1907 let d = y.ncols();
1908 let rho_hat = lambda.ln();
1919 let rho_at_bound =
1920 (rho_hat - RHO_UPPER).abs() <= 1.0e-9 || (rho_hat - RHO_LOWER).abs() <= 1.0e-9;
1921 let implicit_rho_usable =
1922 fit.reml_hess_rho.is_finite() && fit.reml_hess_rho.abs() > 1.0e-14 && !rho_at_bound;
1923 let weight = gaussian_reml_weights(n, weights)?;
1924 let inverse_hessian = match gaussian_reml_inverse_hessian_from_cache(&fit.cache, lambda) {
1925 Ok(inv) => inv,
1926 Err(EstimationError::ModelIsIllConditioned { condition_number }) => {
1927 warn_ill_conditioned_backward_once(p, d, condition_number);
1928 return Ok(zero_backward_result(n, p, d));
1929 }
1930 Err(err) => return Err(err),
1931 };
1932 gaussian_reml_multi_closed_form_backward_from_fit_with_inverse_hessian_impl(
1933 x,
1934 y,
1935 penalty,
1936 weight,
1937 fit,
1938 inverse_hessian,
1939 upstream_lambda,
1940 upstream_coefficients,
1941 upstream_fitted,
1942 upstream_reml_score,
1943 upstream_edf,
1944 implicit_rho_usable,
1945 n,
1946 p,
1947 d,
1948 )
1949}
1950
1951fn gaussian_reml_multi_closed_form_backward_from_fit_with_inverse_hessian_impl(
1952 x: ArrayView2<'_, f64>,
1953 y: ArrayView2<'_, f64>,
1954 penalty: ArrayView2<'_, f64>,
1955 weight: Array1<f64>,
1956 fit: &GaussianRemlMultiResult,
1957 inverse_hessian: Array2<f64>,
1958 upstream_lambda: f64,
1959 upstream_coefficients: Option<ArrayView2<'_, f64>>,
1960 upstream_fitted: Option<ArrayView2<'_, f64>>,
1961 upstream_reml_score: f64,
1962 upstream_edf: f64,
1963 implicit_rho_usable: bool,
1964 n: usize,
1965 p: usize,
1966 d: usize,
1967) -> Result<GaussianRemlBackwardResult, EstimationError> {
1968 let penalty_owned = canonicalize_penalty(penalty);
1972 let penalty = penalty_owned.view();
1973 let lambda = fit.lambda;
1974 let beta = &fit.coefficients;
1975 let residual = y.to_owned() - &fit.fitted;
1976 let nu = effective_observation_count(weight.view()) as f64 - fit.cache.nullity as f64;
1980
1981 let mut grad_x = Array2::<f64>::zeros((n, p));
1982 let mut grad_y = Array2::<f64>::zeros((n, d));
1983 let mut grad_penalty = Array2::<f64>::zeros((p, p));
1984 let mut grad_weights = Array1::<f64>::zeros(n);
1985
1986 let mut upstream_beta = Array2::<f64>::zeros((p, d));
1987 if let Some(upstream_coefficients) = upstream_coefficients {
1988 upstream_beta += &upstream_coefficients;
1989 }
1990 if let Some(upstream_fitted) = upstream_fitted {
1991 upstream_beta += &dense_atb(x, upstream_fitted);
1992 grad_x += &dense_ab(upstream_fitted, beta.t());
1993 }
1994
1995 let mut lambda_adjoint = upstream_lambda;
1996 if upstream_beta.iter().any(|value| *value != 0.0) {
1997 add_ridge_profile_vjp_with_lambda_grad(
2002 1.0,
2003 x,
2004 y,
2005 penalty,
2006 &weight,
2007 lambda,
2008 &inverse_hessian,
2009 beta,
2010 upstream_beta.view(),
2011 &mut grad_x,
2012 &mut grad_y,
2013 &mut grad_penalty,
2014 &mut grad_weights,
2015 &mut lambda_adjoint,
2016 );
2017 }
2018
2019 if upstream_reml_score != 0.0 {
2020 add_reml_score_vjp(
2021 upstream_reml_score,
2022 x,
2023 &weight,
2024 &inverse_hessian,
2025 beta,
2026 &residual,
2027 &fit.sigma2,
2028 nu,
2029 lambda,
2030 &fit.cache,
2031 &mut grad_x,
2032 &mut grad_y,
2033 &mut grad_penalty,
2034 &mut grad_weights,
2035 );
2036 lambda_adjoint += upstream_reml_score * fit.reml_grad_lambda;
2037 }
2038
2039 if upstream_edf != 0.0 {
2040 lambda_adjoint += add_edf_vjp(
2041 upstream_edf,
2042 x,
2043 penalty,
2044 &weight,
2045 lambda,
2046 &inverse_hessian,
2047 &mut grad_x,
2048 &mut grad_penalty,
2049 &mut grad_weights,
2050 );
2051 }
2052
2053 if lambda_adjoint != 0.0 && implicit_rho_usable {
2054 let root_scale = -lambda_adjoint * lambda / fit.reml_hess_rho;
2055 add_reml_rho_gradient_vjp(
2056 root_scale,
2057 x,
2058 y,
2059 penalty,
2060 &weight,
2061 lambda,
2062 &inverse_hessian,
2063 beta,
2064 &residual,
2065 &fit.sigma2,
2066 nu,
2067 &mut grad_x,
2068 &mut grad_y,
2069 &mut grad_penalty,
2070 &mut grad_weights,
2071 );
2072 }
2073
2074 let p = grad_penalty.nrows();
2083 for i in 0..p {
2084 for j in (i + 1)..p {
2085 let avg = 0.5 * (grad_penalty[[i, j]] + grad_penalty[[j, i]]);
2086 grad_penalty[[i, j]] = avg;
2087 grad_penalty[[j, i]] = avg;
2088 }
2089 }
2090 Ok(GaussianRemlBackwardResult {
2091 grad_x,
2092 grad_y,
2093 grad_penalty,
2094 grad_weights,
2095 })
2096}
2097
2098pub fn gaussian_reml_multi_closed_form_backward_batch<'a>(
2099 problems: &[GaussianRemlMultiBackwardProblem<'a>],
2100 penalty: ArrayView2<'a, f64>,
2101) -> Vec<Result<GaussianRemlBackwardResult, EstimationError>> {
2102 let inverse_hessians = batched_inverse_hessians_from_caches(problems);
2103 let results: Vec<Result<GaussianRemlBackwardResult, EstimationError>> = problems
2104 .par_iter()
2105 .zip(inverse_hessians.into_par_iter())
2106 .map(|(problem, inverse_hessian_result)| {
2107 validate_gaussian_reml_backward_upstreams(
2108 problem.x.view(),
2109 problem.y.view(),
2110 penalty,
2111 problem.grad_lambda,
2112 problem.grad_coefficients.as_ref().map(|g| g.view()),
2113 problem.grad_fitted.as_ref().map(|g| g.view()),
2114 problem.grad_reml_score,
2115 problem.grad_edf,
2116 )?;
2117 validate_gaussian_reml_forward_fit(
2118 problem.x.view(),
2119 problem.y.view(),
2120 penalty,
2121 problem.weights.as_ref().map(|w| w.view()),
2122 problem.fit,
2123 )?;
2124 let n = problem.x.nrows();
2125 let p = problem.x.ncols();
2126 let d = problem.y.ncols();
2127 if !(problem.fit.reml_hess_rho.is_finite() && problem.fit.reml_hess_rho.abs() > 1.0e-14)
2128 {
2129 warn_ill_conditioned_backward_once(p, d, f64::INFINITY);
2131 return Ok(zero_backward_result(n, p, d));
2132 }
2133 let weight = gaussian_reml_weights(n, problem.weights.as_ref().map(|w| w.view()))?;
2134 let inverse_hessian = match inverse_hessian_result {
2135 Ok(inv) => inv,
2136 Err(EstimationError::ModelIsIllConditioned { condition_number }) => {
2137 warn_ill_conditioned_backward_once(p, d, condition_number);
2138 return Ok(zero_backward_result(n, p, d));
2139 }
2140 Err(err) => return Err(err),
2141 };
2142 let rho_hat = problem.fit.lambda.ln();
2147 let rho_at_bound =
2148 (rho_hat - RHO_UPPER).abs() <= 1.0e-9 || (rho_hat - RHO_LOWER).abs() <= 1.0e-9;
2149 let implicit_rho_usable = problem.fit.reml_hess_rho.is_finite()
2150 && problem.fit.reml_hess_rho.abs() > 1.0e-14
2151 && !rho_at_bound;
2152 gaussian_reml_multi_closed_form_backward_from_fit_with_inverse_hessian_impl(
2153 problem.x.view(),
2154 problem.y.view(),
2155 penalty,
2156 weight,
2157 problem.fit,
2158 inverse_hessian,
2159 problem.grad_lambda,
2160 problem.grad_coefficients.as_ref().map(|g| g.view()),
2161 problem.grad_fitted.as_ref().map(|g| g.view()),
2162 problem.grad_reml_score,
2163 problem.grad_edf,
2164 implicit_rho_usable,
2165 n,
2166 p,
2167 d,
2168 )
2169 })
2170 .collect();
2171 results
2172}
2173
2174fn rho_derivatives_to_lambda(lambda: f64, grad_rho: f64, hess_rho: f64) -> (f64, f64) {
2175 (grad_rho / lambda, (hess_rho - grad_rho) / (lambda * lambda))
2176}
2177
2178fn validate_gaussian_reml_backward_upstreams(
2179 x: ArrayView2<'_, f64>,
2180 y: ArrayView2<'_, f64>,
2181 penalty: ArrayView2<'_, f64>,
2182 upstream_lambda: f64,
2183 upstream_coefficients: Option<ArrayView2<'_, f64>>,
2184 upstream_fitted: Option<ArrayView2<'_, f64>>,
2185 upstream_reml_score: f64,
2186 upstream_edf: f64,
2187) -> Result<(), EstimationError> {
2188 if !(upstream_lambda.is_finite() && upstream_reml_score.is_finite() && upstream_edf.is_finite())
2189 {
2190 crate::bail_invalid_estim!("Gaussian REML backward upstream scalars must be finite");
2191 }
2192 if let Some(upstream_coefficients) = upstream_coefficients {
2193 if upstream_coefficients.dim() != (x.ncols(), y.ncols()) {
2194 crate::bail_invalid_estim!(
2195 "Gaussian REML backward coefficient upstream shape mismatch: expected {}x{}, got {}x{}",
2196 x.ncols(),
2197 y.ncols(),
2198 upstream_coefficients.nrows(),
2199 upstream_coefficients.ncols()
2200 );
2201 }
2202 if upstream_coefficients.iter().any(|value| !value.is_finite()) {
2203 crate::bail_invalid_estim!(
2204 "Gaussian REML backward coefficient upstream must be finite"
2205 );
2206 }
2207 }
2208 if let Some(upstream_fitted) = upstream_fitted {
2209 if upstream_fitted.dim() != y.dim() {
2210 crate::bail_invalid_estim!(
2211 "Gaussian REML backward fitted upstream shape mismatch: expected {}x{}, got {}x{}",
2212 y.nrows(),
2213 y.ncols(),
2214 upstream_fitted.nrows(),
2215 upstream_fitted.ncols()
2216 );
2217 }
2218 if upstream_fitted.iter().any(|value| !value.is_finite()) {
2219 crate::bail_invalid_estim!("Gaussian REML backward fitted upstream must be finite");
2220 }
2221 }
2222 validate_gaussian_reml_design(x, penalty, None)?;
2223 Ok(())
2224}
2225
2226fn validate_gaussian_reml_forward_fit(
2227 x: ArrayView2<'_, f64>,
2228 y: ArrayView2<'_, f64>,
2229 penalty: ArrayView2<'_, f64>,
2230 weights: Option<ArrayView1<'_, f64>>,
2231 fit: &GaussianRemlMultiResult,
2232) -> Result<(), EstimationError> {
2233 let penalty_owned = canonicalize_penalty(penalty);
2237 let penalty = penalty_owned.view();
2238 let n = x.nrows();
2239 let p = x.ncols();
2240 let d = y.ncols();
2241 validate_gaussian_reml_design(x, penalty, weights)?;
2242 validate_gaussian_reml_eigen_cache(&fit.cache, p)?;
2243 if y.nrows() != n
2244 || fit.coefficients.dim() != (p, d)
2245 || fit.fitted.dim() != (n, d)
2246 || fit.sigma2.len() != d
2247 {
2248 crate::bail_invalid_estim!(
2249 "Gaussian REML backward forward-state shape mismatch: expected coefficients=({p},{d}), fitted=({n},{d}), sigma2={d}"
2250 );
2251 }
2252 if !(fit.lambda.is_finite()
2253 && fit.lambda > 0.0
2254 && fit.rho.is_finite()
2255 && fit.reml_score.is_finite()
2256 && fit.reml_hess_rho.is_finite()
2257 && fit.edf.is_finite())
2258 || fit.coefficients.iter().any(|value| !value.is_finite())
2259 || fit.fitted.iter().any(|value| !value.is_finite())
2260 || fit.sigma2.iter().any(|value| !value.is_finite())
2261 {
2262 crate::bail_invalid_estim!("Gaussian REML backward forward state must be finite");
2263 }
2264 let penalty_fingerprint = matrix_fingerprint(penalty);
2265 if fit.cache.penalty_fingerprint != penalty_fingerprint {
2266 crate::bail_invalid_estim!("Gaussian REML backward forward-state penalty mismatch");
2267 }
2268 let weight = gaussian_reml_weights(n, weights)?;
2269 let xtwx = dense_xt_diag_x(x, weight.view());
2270 if fit.cache.xtwx_fingerprint != matrix_fingerprint(xtwx.view()) {
2271 crate::bail_invalid_estim!("Gaussian REML backward forward-state X'WX mismatch");
2272 }
2273 Ok(())
2274}
2275
2276fn gaussian_reml_inverse_hessian_from_cache(
2277 cache: &GaussianRemlEigenCache,
2278 lambda: f64,
2279) -> Result<Array2<f64>, EstimationError> {
2280 if !(lambda.is_finite() && lambda > 0.0) {
2281 crate::bail_invalid_estim!(
2282 "Gaussian REML lambda must be finite and positive; got {lambda}"
2283 );
2284 }
2285 let p = cache.penalty_eigenvalues.len();
2286 let mut scaled_basis = cache.coefficient_basis.clone();
2287 for eig in 0..p {
2288 let scale = 1.0 / (1.0 + lambda * cache.penalty_eigenvalues[eig]);
2289 for row in 0..p {
2290 scaled_basis[[row, eig]] *= scale;
2291 }
2292 }
2293 let inverse = dense_ab(scaled_basis.view(), cache.coefficient_basis.t());
2294 if inverse.iter().any(|value| !value.is_finite()) {
2295 return Err(EstimationError::ModelIsIllConditioned {
2296 condition_number: f64::INFINITY,
2297 });
2298 }
2299 Ok(inverse)
2300}
2301
2302fn batched_inverse_hessians_from_caches(
2303 problems: &[GaussianRemlMultiBackwardProblem<'_>],
2304) -> Vec<Result<Array2<f64>, EstimationError>> {
2305 if problems.is_empty() {
2306 return Vec::new();
2307 }
2308 let p = problems[0].fit.cache.coefficient_basis.nrows();
2309 let uniform = p > 0
2310 && problems.iter().all(|problem| {
2311 let cache = &problem.fit.cache;
2312 cache.coefficient_basis.dim() == (p, p) && cache.penalty_eigenvalues.len() == p
2313 });
2314 if uniform && problems.len() > 1 {
2315 let mut scaled_basis = Array3::<f64>::zeros((problems.len(), p, p));
2316 let mut basis = Array3::<f64>::zeros((problems.len(), p, p));
2317 let mut valid = true;
2318 for (idx, problem) in problems.iter().enumerate() {
2319 let lambda = problem.fit.lambda;
2320 if !(lambda.is_finite() && lambda > 0.0) {
2321 valid = false;
2322 break;
2323 }
2324 let cache = &problem.fit.cache;
2325 basis
2326 .slice_mut(s![idx, .., ..])
2327 .assign(&cache.coefficient_basis);
2328 for eig in 0..p {
2329 let scale = 1.0 / (1.0 + lambda * cache.penalty_eigenvalues[eig]);
2330 for row in 0..p {
2331 scaled_basis[[idx, row, eig]] = cache.coefficient_basis[[row, eig]] * scale;
2332 }
2333 }
2334 }
2335 if valid
2336 && let Some(inverses) =
2337 gam_gpu::try_fast_abt_strided_batched(scaled_basis.view(), basis.view())
2338 {
2339 return inverses
2340 .axis_iter(Axis(0))
2341 .map(|inverse| Ok(inverse.to_owned()))
2342 .collect();
2343 }
2344 }
2345 problems
2346 .iter()
2347 .map(|problem| {
2348 gaussian_reml_inverse_hessian_from_cache(&problem.fit.cache, problem.fit.lambda)
2349 })
2350 .collect()
2351}
2352
2353fn ridge_profile_vjp_data_partials(
2360 scale: f64,
2361 x: ArrayView2<'_, f64>,
2362 y: ArrayView2<'_, f64>,
2363 penalty: ArrayView2<'_, f64>,
2364 weights: &Array1<f64>,
2365 lambda: f64,
2366 inverse_hessian: &Array2<f64>,
2367 beta: &Array2<f64>,
2368 upstream_beta: ArrayView2<'_, f64>,
2369 grad_x: &mut Array2<f64>,
2370 grad_y: &mut Array2<f64>,
2371 grad_penalty: &mut Array2<f64>,
2372 grad_weights: &mut Array1<f64>,
2373) -> Array2<f64> {
2374 let m = dense_ab(inverse_hessian.view(), upstream_beta);
2375 let c = dense_ab(m.view(), beta.t());
2376 let c_sym = &c + &c.t();
2377 let ymt = dense_ab(y, m.t());
2378 let xcs = dense_ab(x, c_sym.view());
2379 for i in 0..x.nrows() {
2380 let wi = weights[i] * scale;
2381 for k in 0..x.ncols() {
2382 grad_x[[i, k]] += wi * (ymt[[i, k]] - xcs[[i, k]]);
2383 }
2384 }
2385
2386 let xm = dense_ab(x, m.view());
2387 for i in 0..x.nrows() {
2388 let wi = weights[i] * scale;
2389 for j in 0..y.ncols() {
2390 grad_y[[i, j]] += wi * xm[[i, j]];
2391 }
2392 }
2393
2394 let xc = dense_ab(x, c.view());
2395 for i in 0..x.nrows() {
2396 let mut from_b = 0.0;
2397 for j in 0..y.ncols() {
2398 from_b += y[[i, j]] * xm[[i, j]];
2399 }
2400 let mut from_a = 0.0;
2401 for k in 0..x.ncols() {
2402 from_a += x[[i, k]] * xc[[i, k]];
2403 }
2404 grad_weights[i] += scale * (from_b - from_a);
2405 }
2406
2407 for row in 0..penalty.nrows() {
2408 for col in 0..penalty.ncols() {
2409 let mut value = 0.0;
2410 for output in 0..beta.ncols() {
2411 value += m[[row, output]] * beta[[col, output]];
2412 }
2413 grad_penalty[[row, col]] -= scale * lambda * value;
2414 }
2415 }
2416 m
2417}
2418
2419fn add_ridge_profile_vjp_with_lambda_grad(
2424 scale: f64,
2425 x: ArrayView2<'_, f64>,
2426 y: ArrayView2<'_, f64>,
2427 penalty: ArrayView2<'_, f64>,
2428 weights: &Array1<f64>,
2429 lambda: f64,
2430 inverse_hessian: &Array2<f64>,
2431 beta: &Array2<f64>,
2432 upstream_beta: ArrayView2<'_, f64>,
2433 grad_x: &mut Array2<f64>,
2434 grad_y: &mut Array2<f64>,
2435 grad_penalty: &mut Array2<f64>,
2436 grad_weights: &mut Array1<f64>,
2437 lambda_adjoint_out: &mut f64,
2438) {
2439 let m = ridge_profile_vjp_data_partials(
2440 scale,
2441 x,
2442 y,
2443 penalty,
2444 weights,
2445 lambda,
2446 inverse_hessian,
2447 beta,
2448 upstream_beta,
2449 grad_x,
2450 grad_y,
2451 grad_penalty,
2452 grad_weights,
2453 );
2454 let penalty_beta = dense_ab(penalty, beta.view());
2455 let dot = m
2456 .iter()
2457 .zip(penalty_beta.iter())
2458 .map(|(left, right)| left * right)
2459 .sum::<f64>();
2460 *lambda_adjoint_out += -scale * dot;
2461}
2462
2463fn add_ridge_profile_vjp_fixed_lambda(
2467 scale: f64,
2468 x: ArrayView2<'_, f64>,
2469 y: ArrayView2<'_, f64>,
2470 penalty: ArrayView2<'_, f64>,
2471 weights: &Array1<f64>,
2472 lambda: f64,
2473 inverse_hessian: &Array2<f64>,
2474 beta: &Array2<f64>,
2475 upstream_beta: ArrayView2<'_, f64>,
2476 grad_x: &mut Array2<f64>,
2477 grad_y: &mut Array2<f64>,
2478 grad_penalty: &mut Array2<f64>,
2479 grad_weights: &mut Array1<f64>,
2480) {
2481 ridge_profile_vjp_data_partials(
2482 scale,
2483 x,
2484 y,
2485 penalty,
2486 weights,
2487 lambda,
2488 inverse_hessian,
2489 beta,
2490 upstream_beta,
2491 grad_x,
2492 grad_y,
2493 grad_penalty,
2494 grad_weights,
2495 );
2496}
2497
2498fn add_reml_score_vjp(
2499 scale: f64,
2500 x: ArrayView2<'_, f64>,
2501 weights: &Array1<f64>,
2502 inverse_hessian: &Array2<f64>,
2503 beta: &Array2<f64>,
2504 residual: &Array2<f64>,
2505 sigma2: &Array1<f64>,
2506 nu: f64,
2507 lambda: f64,
2508 cache: &GaussianRemlEigenCache,
2509 grad_x: &mut Array2<f64>,
2510 grad_y: &mut Array2<f64>,
2511 grad_penalty: &mut Array2<f64>,
2512 grad_weights: &mut Array1<f64>,
2513) {
2514 let d = beta.ncols() as f64;
2515 let xp = dense_ab(x, inverse_hessian.view());
2516 let penalty_pinv = gaussian_reml_penalty_pseudoinverse_from_cache(cache);
2517 for row in 0..grad_penalty.nrows() {
2518 for col in 0..grad_penalty.ncols() {
2519 grad_penalty[[row, col]] +=
2520 scale * 0.5 * d * (lambda * inverse_hessian[[col, row]] - penalty_pinv[[col, row]]);
2521 }
2522 }
2523 for i in 0..x.nrows() {
2524 let wi = weights[i] * scale * d;
2525 for k in 0..x.ncols() {
2526 grad_x[[i, k]] += wi * xp[[i, k]];
2527 }
2528 let mut leverage = 0.0;
2529 for k in 0..x.ncols() {
2530 leverage += x[[i, k]] * xp[[i, k]];
2531 }
2532 grad_weights[i] += scale * 0.5 * d * leverage;
2533 }
2534
2535 for j in 0..beta.ncols() {
2536 let dp = (sigma2[j] * nu).max(MIN_DEVIANCE);
2537 let coef = scale * 0.5 * nu / dp;
2538 add_deviance_profile_vjp(
2539 coef,
2540 j,
2541 x,
2542 weights,
2543 beta,
2544 residual,
2545 grad_x,
2546 grad_y,
2547 grad_weights,
2548 );
2549 add_rank_one_penalty_vjp(coef * lambda, beta.column(j), grad_penalty);
2550 }
2551}
2552
2553fn add_edf_vjp(
2564 scale: f64,
2565 x: ArrayView2<'_, f64>,
2566 penalty: ArrayView2<'_, f64>,
2567 weights: &Array1<f64>,
2568 lambda: f64,
2569 inverse_hessian: &Array2<f64>,
2570 grad_x: &mut Array2<f64>,
2571 grad_penalty: &mut Array2<f64>,
2572 grad_weights: &mut Array1<f64>,
2573) -> f64 {
2574 let m_inv_s = dense_ab(inverse_hessian.view(), penalty);
2576 let mut g_a = dense_ab(m_inv_s.view(), inverse_hessian.view());
2577 g_a.mapv_inplace(|v| v * lambda);
2578
2579 let xg = dense_ab(x, g_a.view());
2583 let leading_scale = 2.0 * scale;
2587 for i in 0..xg.nrows() {
2588 let row_scale = leading_scale * weights[i];
2589 for k in 0..xg.ncols() {
2590 grad_x[[i, k]] += row_scale * xg[[i, k]];
2591 }
2592 }
2593 for i in 0..x.nrows() {
2594 let mut quad = 0.0;
2595 for k in 0..x.ncols() {
2596 quad += x[[i, k]] * xg[[i, k]];
2597 }
2598 grad_weights[i] += scale * quad;
2599 }
2600
2601 for row in 0..grad_penalty.nrows() {
2604 for col in 0..grad_penalty.ncols() {
2605 grad_penalty[[row, col]] +=
2606 scale * (-lambda * inverse_hessian[[row, col]] + lambda * g_a[[row, col]]);
2607 }
2608 }
2609
2610 let p_dim = m_inv_s.nrows();
2612 let mut tr_m_inv_s = 0.0;
2613 for i in 0..p_dim {
2614 tr_m_inv_s += m_inv_s[[i, i]];
2615 }
2616 let mut tr_squared = 0.0;
2617 for i in 0..p_dim {
2618 for j in 0..p_dim {
2619 tr_squared += m_inv_s[[i, j]] * m_inv_s[[j, i]];
2620 }
2621 }
2622 scale * (-tr_m_inv_s + lambda * tr_squared)
2623}
2624
2625fn add_reml_rho_gradient_vjp(
2626 scale: f64,
2627 x: ArrayView2<'_, f64>,
2628 y: ArrayView2<'_, f64>,
2629 penalty: ArrayView2<'_, f64>,
2630 weights: &Array1<f64>,
2631 lambda: f64,
2632 inverse_hessian: &Array2<f64>,
2633 beta: &Array2<f64>,
2634 residual: &Array2<f64>,
2635 sigma2: &Array1<f64>,
2636 nu: f64,
2637 grad_x: &mut Array2<f64>,
2638 grad_y: &mut Array2<f64>,
2639 grad_penalty: &mut Array2<f64>,
2640 grad_weights: &mut Array1<f64>,
2641) {
2642 let d = beta.ncols() as f64;
2643 let inverse_s = dense_ab(inverse_hessian.view(), penalty);
2644 let trace_kernel = dense_ab(inverse_s.view(), inverse_hessian.view());
2645 for row in 0..grad_penalty.nrows() {
2646 for col in 0..grad_penalty.ncols() {
2647 grad_penalty[[row, col]] += scale
2648 * 0.5
2649 * d
2650 * lambda
2651 * (inverse_hessian[[col, row]] - lambda * trace_kernel[[col, row]]);
2652 }
2653 }
2654 let xt = dense_ab(x, trace_kernel.view());
2655 for i in 0..x.nrows() {
2656 let wi = -scale * d * lambda * weights[i];
2657 for k in 0..x.ncols() {
2658 grad_x[[i, k]] += wi * xt[[i, k]];
2659 }
2660 let mut quad = 0.0;
2661 for k in 0..x.ncols() {
2662 quad += x[[i, k]] * xt[[i, k]];
2663 }
2664 grad_weights[i] -= scale * 0.5 * d * lambda * quad;
2665 }
2666
2667 let s_beta = dense_ab(penalty, beta.view());
2668 let mut upstream_beta = Array2::<f64>::zeros(beta.dim());
2669 for j in 0..beta.ncols() {
2670 let dp = (sigma2[j] * nu).max(MIN_DEVIANCE);
2671 let q = lambda * beta.column(j).dot(&s_beta.column(j));
2672 let q_coef = scale * nu / dp;
2673 for row in 0..beta.nrows() {
2674 upstream_beta[[row, j]] = q_coef * lambda * s_beta[[row, j]];
2675 }
2676 let dp_coef = -scale * 0.5 * nu * q / (dp * dp);
2677 add_rank_one_penalty_vjp(
2678 (0.5 * q_coef + dp_coef) * lambda,
2679 beta.column(j),
2680 grad_penalty,
2681 );
2682 add_deviance_profile_vjp(
2683 dp_coef,
2684 j,
2685 x,
2686 weights,
2687 beta,
2688 residual,
2689 grad_x,
2690 grad_y,
2691 grad_weights,
2692 );
2693 }
2694 add_ridge_profile_vjp_fixed_lambda(
2697 1.0,
2698 x,
2699 y,
2700 penalty,
2701 weights,
2702 lambda,
2703 inverse_hessian,
2704 beta,
2705 upstream_beta.view(),
2706 grad_x,
2707 grad_y,
2708 grad_penalty,
2709 grad_weights,
2710 );
2711}
2712
2713fn add_rank_one_penalty_vjp(
2714 scale: f64,
2715 beta_col: ArrayView1<'_, f64>,
2716 grad_penalty: &mut Array2<f64>,
2717) {
2718 for row in 0..beta_col.len() {
2719 for col in 0..beta_col.len() {
2720 grad_penalty[[row, col]] += scale * beta_col[row] * beta_col[col];
2721 }
2722 }
2723}
2724
2725fn gaussian_reml_penalty_pseudoinverse_from_cache(cache: &GaussianRemlEigenCache) -> Array2<f64> {
2726 let p = cache.penalty_eigenvalues.len();
2727 let mut scaled_basis = Array2::<f64>::zeros((p, p));
2728 for eig in 0..p {
2729 let delta = cache.penalty_eigenvalues[eig];
2730 if delta > 0.0 {
2731 for row in 0..p {
2732 scaled_basis[[row, eig]] = cache.coefficient_basis[[row, eig]] / delta;
2733 }
2734 }
2735 }
2736 dense_ab(scaled_basis.view(), cache.coefficient_basis.t())
2737}
2738
2739fn add_deviance_profile_vjp(
2740 scale: f64,
2741 output: usize,
2742 x: ArrayView2<'_, f64>,
2743 weights: &Array1<f64>,
2744 beta: &Array2<f64>,
2745 residual: &Array2<f64>,
2746 grad_x: &mut Array2<f64>,
2747 grad_y: &mut Array2<f64>,
2748 grad_weights: &mut Array1<f64>,
2749) {
2750 for i in 0..x.nrows() {
2751 let r = residual[[i, output]];
2752 let wr_scale = scale * weights[i] * r;
2753 grad_y[[i, output]] += 2.0 * wr_scale;
2754 for k in 0..x.ncols() {
2755 grad_x[[i, k]] -= 2.0 * wr_scale * beta[[k, output]];
2756 }
2757 grad_weights[i] += scale * r * r;
2758 }
2759}
2760
2761fn validate_initial_lambda(lambda: f64) -> Result<f64, EstimationError> {
2762 if lambda.is_finite() && lambda > 0.0 {
2763 Ok(lambda)
2764 } else {
2765 Err(EstimationError::InvalidInput(format!(
2766 "Gaussian REML initial lambda must be finite and positive; got {lambda}"
2767 )))
2768 }
2769}
2770
2771fn dense_ab(a: ArrayView2<'_, f64>, b: ArrayView2<'_, f64>) -> Array2<f64> {
2772 fast_ab(&a, &b)
2773}
2774
2775fn dense_atb(a: ArrayView2<'_, f64>, b: ArrayView2<'_, f64>) -> Array2<f64> {
2776 fast_atb(&a, &b)
2777}
2778
2779fn dense_xt_diag_x(x: ArrayView2<'_, f64>, w: ArrayView1<'_, f64>) -> Array2<f64> {
2780 fast_xt_diag_x(&x, &w)
2781}
2782
2783fn dense_xt_diag_y(
2784 x: ArrayView2<'_, f64>,
2785 w: ArrayView1<'_, f64>,
2786 y: ArrayView2<'_, f64>,
2787) -> Array2<f64> {
2788 fast_xt_diag_y(&x, &w, &y)
2789}
2790
2791fn matrix_fingerprint(matrix: ArrayView2<'_, f64>) -> u64 {
2792 let mut hash = 0xcbf29ce484222325_u64;
2793 hash = fnv1a_mix(hash, matrix.nrows() as u64);
2794 hash = fnv1a_mix(hash, matrix.ncols() as u64);
2795 for &value in matrix {
2796 hash = fnv1a_mix(hash, value.to_bits());
2797 }
2798 hash
2799}
2800
2801fn fnv1a_mix(hash: u64, value: u64) -> u64 {
2802 (hash ^ value).wrapping_mul(0x100000001b3)
2803}
2804
2805pub fn build_gaussian_reml_eigen_cache_batched(
2810 xtwx_matrices: Vec<Array2<f64>>,
2811 penalty: ArrayView2<'_, f64>,
2812 nullspace_dim: Option<usize>,
2813) -> Vec<Result<GaussianRemlEigenCache, EstimationError>> {
2814 let penalty_owned = canonicalize_penalty(penalty);
2815 let penalty = penalty_owned.view();
2816 let k = xtwx_matrices.len();
2817 if k == 0 {
2818 return Vec::new();
2819 }
2820 let fingerprints: Vec<u64> = xtwx_matrices
2821 .iter()
2822 .map(|m| matrix_fingerprint(m.view()))
2823 .collect();
2824
2825 let p = xtwx_matrices[0].nrows();
2826 let uniform_square = p > 0 && xtwx_matrices.iter().all(|matrix| matrix.dim() == (p, p));
2827 if uniform_square && k > 1 {
2828 let mut lower_matrices = xtwx_matrices.clone();
2829 if gam_gpu::try_cholesky_batched_lower_inplace(&mut lower_matrices).is_some() {
2830 let transforms = batched_whitened_penalty_transforms(&lower_matrices, penalty);
2838 return lower_matrices
2839 .into_iter()
2840 .enumerate()
2841 .map(|(b, lower)| {
2842 let precomputed_transform = transforms.as_ref().map(|t| t[b].clone());
2843 gaussian_reml_eigen_cache_from_lower_with_transform(
2844 lower,
2845 penalty,
2846 nullspace_dim,
2847 fingerprints[b],
2848 precomputed_transform,
2849 )
2850 })
2851 .collect();
2852 }
2853 }
2854
2855 let mut results = Vec::with_capacity(k);
2856 for (b, xtwx) in xtwx_matrices.into_iter().enumerate() {
2857 let lower = match gaussian_reml_cholesky_lower(xtwx) {
2858 Ok(l) => l,
2859 Err(err) => {
2860 results.push(Err(err));
2861 continue;
2862 }
2863 };
2864 results.push(gaussian_reml_eigen_cache_from_lower_with_transform(
2865 lower,
2866 penalty,
2867 nullspace_dim,
2868 fingerprints[b],
2869 None,
2870 ));
2871 }
2872 results
2873}
2874
2875fn batched_whitened_penalty_transforms(
2876 lowers: &[Array2<f64>],
2877 penalty: ArrayView2<'_, f64>,
2878) -> Option<Vec<Array2<f64>>> {
2879 let first = lowers.first()?;
2880 let p = first.nrows();
2881 if p == 0 || first.ncols() != p || lowers.iter().any(|lower| lower.dim() != (p, p)) {
2882 return None;
2883 }
2884 let mut linv_stack = Array3::<f64>::zeros((lowers.len(), p, p));
2885 for (idx, lower) in lowers.iter().enumerate() {
2886 let l_inv = invert_lower_triangular(lower).ok()?;
2887 linv_stack.slice_mut(s![idx, .., ..]).assign(&l_inv);
2888 }
2889 let penalty_in_metric = gam_gpu::try_fast_ab_broadcast_b_batched(linv_stack.view(), penalty)?;
2890 let transformed =
2891 gam_gpu::try_fast_abt_strided_batched(penalty_in_metric.view(), linv_stack.view())?;
2892 Some(
2893 transformed
2894 .axis_iter(Axis(0))
2895 .map(|matrix| matrix.to_owned())
2896 .collect(),
2897 )
2898}
2899
2900pub fn build_gaussian_reml_eigen_cache(
2901 x: ArrayView2<'_, f64>,
2902 penalty: ArrayView2<'_, f64>,
2903 weights: Option<ArrayView1<'_, f64>>,
2904) -> Result<GaussianRemlEigenCache, EstimationError> {
2905 build_gaussian_reml_eigen_cache_with_nullspace_dim(x, penalty, None, weights)
2906}
2907
2908pub fn build_gaussian_reml_eigen_cache_with_nullspace_dim(
2909 x: ArrayView2<'_, f64>,
2910 penalty: ArrayView2<'_, f64>,
2911 nullspace_dim: Option<usize>,
2912 weights: Option<ArrayView1<'_, f64>>,
2913) -> Result<GaussianRemlEigenCache, EstimationError> {
2914 let penalty_owned = canonicalize_penalty(penalty);
2915 let penalty = penalty_owned.view();
2916 let n = x.nrows();
2917 validate_gaussian_reml_design(x, penalty, weights)?;
2918 let weight = gaussian_reml_weights(n, weights)?;
2919
2920 let xtwx = dense_xt_diag_x(x, weight.view());
2921 gaussian_reml_eigen_cache_from_xtwx(xtwx, penalty, nullspace_dim)
2922}
2923
2924fn validate_gaussian_reml_design(
2925 x: ArrayView2<'_, f64>,
2926 penalty: ArrayView2<'_, f64>,
2927 weights: Option<ArrayView1<'_, f64>>,
2928) -> Result<(), EstimationError> {
2929 let n = x.nrows();
2930 let p = x.ncols();
2931 if penalty.nrows() != p || penalty.ncols() != p {
2932 crate::bail_invalid_estim!(
2933 "Gaussian REML penalty shape mismatch: expected {p}x{p}, got {}x{}",
2934 penalty.nrows(),
2935 penalty.ncols()
2936 );
2937 }
2938 if x.iter().chain(penalty.iter()).any(|v| !v.is_finite()) {
2939 crate::bail_invalid_estim!("Gaussian REML inputs must be finite");
2940 }
2941 if let Some(w) = weights {
2942 if w.len() != n {
2943 crate::bail_invalid_estim!(
2944 "Gaussian REML weights length mismatch: expected {n}, got {}",
2945 w.len()
2946 );
2947 }
2948 if w.iter().any(|value| !value.is_finite() || *value < 0.0) {
2949 crate::bail_invalid_estim!("Gaussian REML weights must be finite and non-negative");
2950 }
2951 }
2952 Ok(())
2953}
2954
2955fn effective_observation_count(weight: ArrayView1<'_, f64>) -> usize {
2969 weight.iter().filter(|&&w| w > 0.0).count()
2970}
2971
2972fn gaussian_reml_weights(
2973 n: usize,
2974 weights: Option<ArrayView1<'_, f64>>,
2975) -> Result<Array1<f64>, EstimationError> {
2976 match weights {
2977 Some(w) => {
2978 if w.len() != n {
2979 crate::bail_invalid_estim!(
2980 "Gaussian REML weights length mismatch: expected {n}, got {}",
2981 w.len()
2982 );
2983 }
2984 if w.iter().any(|value| !value.is_finite() || *value < 0.0) {
2985 crate::bail_invalid_estim!("Gaussian REML weights must be finite and non-negative");
2986 }
2987 Ok(w.to_owned())
2988 }
2989 None => Ok(Array1::ones(n)),
2990 }
2991}
2992
2993fn gaussian_reml_eigen_cache_from_xtwx(
2994 xtwx: Array2<f64>,
2995 penalty: ArrayView2<'_, f64>,
2996 nullspace_dim: Option<usize>,
2997) -> Result<GaussianRemlEigenCache, EstimationError> {
2998 let xtwx_fingerprint = matrix_fingerprint(xtwx.view());
2999 let lower = gaussian_reml_cholesky_lower(xtwx)?;
3000 gaussian_reml_eigen_cache_from_lower(lower, penalty, nullspace_dim, xtwx_fingerprint)
3001}
3002
3003fn gaussian_reml_eigen_cache_from_lower(
3008 lower: Array2<f64>,
3009 penalty: ArrayView2<'_, f64>,
3010 nullspace_dim: Option<usize>,
3011 xtwx_fingerprint: u64,
3012) -> Result<GaussianRemlEigenCache, EstimationError> {
3013 gaussian_reml_eigen_cache_from_lower_with_transform(
3014 lower,
3015 penalty,
3016 nullspace_dim,
3017 xtwx_fingerprint,
3018 None,
3019 )
3020}
3021
3022fn gaussian_reml_eigen_cache_from_lower_with_transform(
3025 lower: Array2<f64>,
3026 penalty: ArrayView2<'_, f64>,
3027 nullspace_dim: Option<usize>,
3028 xtwx_fingerprint: u64,
3029 precomputed_transform: Option<Array2<f64>>,
3030) -> Result<GaussianRemlEigenCache, EstimationError> {
3031 let p = lower.nrows();
3032 if lower.ncols() != p {
3033 crate::bail_invalid_estim!("Gaussian REML Cholesky factor must be square");
3034 }
3035 let penalty_fingerprint = matrix_fingerprint(penalty);
3036 let logdet_xtwx = 2.0 * lower.diag().iter().map(|v| v.ln()).sum::<f64>();
3037 let transformed_penalty = match precomputed_transform {
3038 Some(transformed) => transformed,
3039 None => {
3040 let l_inv = invert_lower_triangular(&lower)?;
3041 let penalty_in_metric = dense_ab(l_inv.view(), penalty);
3042 dense_ab(penalty_in_metric.view(), l_inv.t())
3043 }
3044 };
3045 let (mut penalty_eigenvalues, eigenvectors) =
3046 transformed_penalty.eigh(Side::Lower).map_err(|_| {
3047 EstimationError::ModelIsIllConditioned {
3048 condition_number: f64::INFINITY,
3049 }
3050 })?;
3051 let max_abs_eig = penalty_eigenvalues
3061 .iter()
3062 .fold(0.0_f64, |acc, &value| acc.max(value.abs()));
3063 let eig_tol = max_abs_eig * EIGEN_REL_TOL;
3064 for value in &mut penalty_eigenvalues {
3065 if *value < 0.0 && value.abs() <= eig_tol {
3066 *value = 0.0;
3067 }
3068 if *value < 0.0 {
3069 crate::bail_invalid_estim!(
3070 "Gaussian REML penalty is not positive semidefinite; eigenvalue={value:.3e}"
3071 );
3072 }
3073 }
3074 let penalty_rank = penalty_eigenvalues
3075 .iter()
3076 .filter(|&&value| value > eig_tol)
3077 .count();
3078 let nullity = p - penalty_rank;
3079 if let Some(expected_nullity) = nullspace_dim
3080 && expected_nullity != nullity
3081 {
3082 crate::bail_invalid_estim!(
3083 "Gaussian REML penalty nullspace mismatch: expected {expected_nullity}, inferred {nullity}"
3084 );
3085 }
3086 let logdet_penalty_positive = gaussian_penalty_positive_logdet(penalty, penalty_rank)?;
3087 let coefficient_basis = solve_upper_triangular_matrix(&lower.t().to_owned(), &eigenvectors)?;
3088
3089 Ok(GaussianRemlEigenCache {
3090 penalty_eigenvalues,
3091 eigenvectors,
3092 coefficient_basis,
3093 xtwx_fingerprint,
3094 penalty_fingerprint,
3095 logdet_xtwx,
3096 logdet_penalty_positive,
3097 penalty_rank,
3098 nullity,
3099 })
3100}
3101
3102fn gaussian_reml_cholesky_lower(xtwx: Array2<f64>) -> Result<Array2<f64>, EstimationError> {
3103 let mut gpu_candidate = xtwx.clone();
3113 if gam_gpu::try_cholesky_lower_inplace(&mut gpu_candidate).is_some() {
3114 return Ok(gpu_candidate);
3115 }
3116 if let Ok(chol) = xtwx.cholesky(Side::Lower) {
3117 return Ok(chol.lower_triangular());
3118 }
3119 let p = xtwx.nrows();
3120 let trace: f64 = (0..p).map(|i| xtwx[[i, i]]).sum();
3121 if !trace.is_finite() || trace <= 0.0 {
3122 return Err(EstimationError::ModelIsIllConditioned {
3123 condition_number: f64::INFINITY,
3124 });
3125 }
3126 escalate_ridge(
3127 RidgeSchedule::geometric(1e-12 * trace / (p as f64), 6),
3128 |jitter| {
3129 let mut jittered = xtwx.clone();
3130 for i in 0..p {
3131 jittered[[i, i]] += jitter;
3132 }
3133 let mut gpu_candidate = jittered.clone();
3134 if gam_gpu::try_cholesky_lower_inplace(&mut gpu_candidate).is_some() {
3135 return Some(gpu_candidate);
3136 }
3137 jittered
3138 .cholesky(Side::Lower)
3139 .ok()
3140 .map(|chol| chol.lower_triangular())
3141 },
3142 )
3143 .map(|success| success.value)
3144 .map_err(|_exhausted| EstimationError::ModelIsIllConditioned {
3145 condition_number: f64::INFINITY,
3146 })
3147}
3148
3149fn gaussian_penalty_positive_logdet(
3150 penalty: ArrayView2<'_, f64>,
3151 penalty_rank: usize,
3152) -> Result<f64, EstimationError> {
3153 if penalty_rank == 0 {
3154 return Ok(0.0);
3155 }
3156 let (pen_eigs, _) = penalty.to_owned().eigh(Side::Lower).map_err(|_| {
3157 EstimationError::ModelIsIllConditioned {
3158 condition_number: f64::INFINITY,
3159 }
3160 })?;
3161 let pen_scale = pen_eigs
3165 .iter()
3166 .fold(0.0_f64, |acc, &value| acc.max(value.abs()));
3167 let pen_tol = pen_scale * EIGEN_REL_TOL;
3168 let mut positive_eigs: Vec<f64> = pen_eigs
3169 .iter()
3170 .copied()
3171 .filter(|&value| value > pen_tol)
3172 .collect();
3173 if positive_eigs.len() != penalty_rank {
3174 positive_eigs = pen_eigs
3175 .iter()
3176 .copied()
3177 .filter(|&value| value > 0.0)
3178 .collect();
3179 positive_eigs.sort_by(|a, b| b.total_cmp(a));
3180 if positive_eigs.len() < penalty_rank {
3181 return Err(EstimationError::ModelIsIllConditioned {
3182 condition_number: f64::INFINITY,
3183 });
3184 }
3185 positive_eigs.truncate(penalty_rank);
3186 }
3187 Ok(positive_eigs.iter().map(|value| value.ln()).sum())
3188}
3189
3190fn validate_gaussian_reml_eigen_cache(
3191 cache: &GaussianRemlEigenCache,
3192 p: usize,
3193) -> Result<(), EstimationError> {
3194 if cache.penalty_eigenvalues.len() != p
3195 || cache.eigenvectors.dim() != (p, p)
3196 || cache.coefficient_basis.dim() != (p, p)
3197 {
3198 crate::bail_invalid_estim!(
3199 "Gaussian REML eigen cache dimension mismatch: expected {p} coefficients"
3200 );
3201 }
3202 if cache.penalty_rank > p || cache.nullity > p || cache.penalty_rank + cache.nullity != p {
3203 crate::bail_invalid_estim!(
3204 "Gaussian REML eigen cache rank/nullity mismatch: rank={}, nullity={}, p={p}",
3205 cache.penalty_rank,
3206 cache.nullity
3207 );
3208 }
3209 if !(cache.logdet_xtwx.is_finite() && cache.logdet_penalty_positive.is_finite()) {
3210 crate::bail_invalid_estim!("Gaussian REML eigen cache log-determinants must be finite");
3211 }
3212 if cache
3213 .penalty_eigenvalues
3214 .iter()
3215 .any(|value| !value.is_finite() || *value < 0.0)
3216 || cache.eigenvectors.iter().any(|value| !value.is_finite())
3217 || cache
3218 .coefficient_basis
3219 .iter()
3220 .any(|value| !value.is_finite())
3221 {
3222 crate::bail_invalid_estim!(
3223 "Gaussian REML eigen cache entries must be finite with non-negative eigenvalues"
3224 .to_string(),
3225 );
3226 }
3227 Ok::<(), _>(())
3228}
3229
3230fn prepare_gaussian_reml(
3231 x: ArrayView2<'_, f64>,
3232 y: ArrayView2<'_, f64>,
3233 penalty: ArrayView2<'_, f64>,
3234 nullspace_dim: Option<usize>,
3235 weights: Option<ArrayView1<'_, f64>>,
3236 eigen_cache: Option<&GaussianRemlEigenCache>,
3237) -> Result<GaussianRemlPrepared, EstimationError> {
3238 let penalty_owned = canonicalize_penalty(penalty);
3241 let penalty = penalty_owned.view();
3242 let n = x.nrows();
3243 let p = x.ncols();
3244 let d = y.ncols();
3245 validate_gaussian_reml_design(x, penalty, weights)?;
3246 if y.nrows() != n {
3247 crate::bail_invalid_estim!(
3248 "Gaussian REML row mismatch: X has {n} rows but Y has {}",
3249 y.nrows()
3250 );
3251 }
3252 if y.iter().any(|v| !v.is_finite()) {
3253 crate::bail_invalid_estim!("Gaussian REML inputs must be finite");
3254 }
3255 let weight = gaussian_reml_weights(n, weights)?;
3256 let n_effective = effective_observation_count(weight.view());
3257
3258 let xtwy = dense_xt_diag_y(x, weight.view(), y);
3259 let ywy = Array1::from_iter((0..d).map(|j| {
3260 let mut value = 0.0;
3261 for row in 0..n {
3262 value += weight[row] * y[[row, j]] * y[[row, j]];
3263 }
3264 value
3265 }));
3266 let xtwx = dense_xt_diag_x(x, weight.view());
3267
3268 if let Some(cache) = eigen_cache {
3269 validate_gaussian_reml_eigen_cache(cache, p)?;
3270 let xtwx_fingerprint = matrix_fingerprint(xtwx.view());
3271 if cache.xtwx_fingerprint != xtwx_fingerprint {
3272 crate::bail_invalid_estim!("Gaussian REML eigen cache X'WX mismatch");
3273 }
3274 let penalty_fingerprint = matrix_fingerprint(penalty);
3275 if cache.penalty_fingerprint != penalty_fingerprint {
3276 crate::bail_invalid_estim!("Gaussian REML eigen cache penalty mismatch");
3277 }
3278 if let Some(expected_nullity) = nullspace_dim
3279 && expected_nullity != cache.nullity
3280 {
3281 crate::bail_invalid_estim!(
3282 "Gaussian REML eigen cache nullspace mismatch: expected {expected_nullity}, got {}",
3283 cache.nullity
3284 );
3285 }
3286 if n_effective <= cache.nullity {
3287 crate::bail_invalid_estim!(
3288 "Gaussian REML requires more positive-weight rows than the nullspace dimension; got n_effective={n_effective}, nullity={}",
3289 cache.nullity
3290 );
3291 }
3292 let projected_rhs = dense_atb(cache.coefficient_basis.view(), xtwy.view());
3293 let projected_rhs_squared = projected_rhs.mapv(|value| value * value);
3294 return Ok(GaussianRemlPrepared {
3295 cache: cache.clone(),
3296 ywy,
3297 projected_rhs_squared,
3298 projected_rhs,
3299 n_effective,
3300 n_outputs: d,
3301 });
3302 }
3303
3304 let cache = gaussian_reml_eigen_cache_from_xtwx(xtwx, penalty, nullspace_dim)?;
3305 if n_effective <= cache.nullity {
3306 crate::bail_invalid_estim!(
3307 "Gaussian REML requires more positive-weight rows than the nullspace dimension; got n_effective={n_effective}, nullity={}",
3308 cache.nullity
3309 );
3310 }
3311 let projected_rhs = dense_atb(cache.coefficient_basis.view(), xtwy.view());
3312 let projected_rhs_squared = projected_rhs.mapv(|value| value * value);
3313
3314 Ok(GaussianRemlPrepared {
3315 cache,
3316 ywy,
3317 projected_rhs_squared,
3318 projected_rhs,
3319 n_effective,
3320 n_outputs: d,
3321 })
3322}
3323
3324impl GaussianRemlPrepared {
3325 fn nu(&self) -> f64 {
3326 self.n_effective as f64 - self.cache.nullity as f64
3327 }
3328
3329 fn evaluate(&self, rho: f64) -> ObjectiveEval {
3330 evaluate_reml_parts(
3331 &self.cache,
3332 self.ywy.view(),
3333 self.projected_rhs_squared.view(),
3334 self.n_effective,
3335 self.n_outputs,
3336 rho,
3337 )
3338 }
3339
3340 fn coefficients(&self, lambda: f64) -> Array2<f64> {
3341 let mut scaled = self.projected_rhs.clone();
3342 for i in 0..self.cache.penalty_eigenvalues.len() {
3343 let scale = 1.0 / (1.0 + lambda * self.cache.penalty_eigenvalues[i]);
3344 for value in scaled.row_mut(i) {
3345 *value *= scale;
3346 }
3347 }
3348 dense_ab(self.cache.coefficient_basis.view(), scaled.view())
3349 }
3350
3351 fn sigma2(&self, lambda: f64) -> Array1<f64> {
3352 let nu = self.nu();
3353 Array1::from_iter((0..self.n_outputs).map(|j| {
3354 let mut fitted_quadratic = 0.0;
3355 for i in 0..self.cache.penalty_eigenvalues.len() {
3356 let denom = 1.0 + lambda * self.cache.penalty_eigenvalues[i];
3357 fitted_quadratic += self.projected_rhs_squared[[i, j]] / denom;
3358 }
3359 (self.ywy[j] - fitted_quadratic) / nu
3360 }))
3361 }
3362}
3363
3364fn validate_reml_profile_residuals(
3371 cache: &GaussianRemlEigenCache,
3372 ywy: ArrayView1<'_, f64>,
3373 projected_rhs_squared: ArrayView2<'_, f64>,
3374 rho: f64,
3375) -> Result<(), EstimationError> {
3376 for output in 0..ywy.len() {
3377 let mut fitted_quadratic = 0.0;
3378 for eig in 0..cache.penalty_eigenvalues.len() {
3379 fitted_quadratic += projected_rhs_squared[[eig, output]]
3380 * modal_kernels(rho, cache.penalty_eigenvalues[eig]).v;
3381 }
3382 let residual = ywy[output] - fitted_quadratic;
3383 if !(residual.is_finite() && residual > 0.0) {
3384 return Err(EstimationError::InvalidInput(format!(
3385 "Gaussian REML profiled residual {output} is not strictly positive at rho={rho}: {residual}; the profiled dispersion has no finite value"
3386 )));
3387 }
3388 }
3389 Ok(())
3390}
3391
3392const RHO_BRACKET_RESOLUTION: f64 = 1.0e-12;
3480
3481const fn dfs_max_depth(range: f64, resolution: f64) -> usize {
3486 let mut width = range;
3487 let mut depth = 0usize;
3488 while width > resolution {
3489 width *= 0.5;
3490 depth += 1;
3491 }
3492 depth
3493}
3494
3495const MAX_DEPTH: usize = dfs_max_depth(RHO_UPPER - RHO_LOWER, RHO_BRACKET_RESOLUTION);
3497
3498#[derive(Clone, Copy)]
3500struct Interval {
3501 lo: f64,
3502 hi: f64,
3503}
3504
3505impl Interval {
3506 fn entire() -> Self {
3507 Self {
3508 lo: f64::NEG_INFINITY,
3509 hi: f64::INFINITY,
3510 }
3511 }
3512}
3513
3514fn round_down(x: f64) -> f64 {
3517 if x.is_nan() || x == f64::NEG_INFINITY {
3518 return x;
3519 }
3520 if x == 0.0 {
3521 return -f64::from_bits(1);
3522 }
3523 let bits = x.to_bits();
3524 let next = if x > 0.0 { bits - 1 } else { bits + 1 };
3525 f64::from_bits(next)
3526}
3527
3528fn round_up(x: f64) -> f64 {
3531 if x.is_nan() || x == f64::INFINITY {
3532 return x;
3533 }
3534 if x == 0.0 {
3535 return f64::from_bits(1);
3536 }
3537 let bits = x.to_bits();
3538 let next = if x > 0.0 { bits + 1 } else { bits - 1 };
3539 f64::from_bits(next)
3540}
3541
3542fn add_down(lhs: f64, rhs: f64) -> f64 {
3543 round_down(lhs + rhs)
3544}
3545
3546fn add_up(lhs: f64, rhs: f64) -> f64 {
3547 round_up(lhs + rhs)
3548}
3549
3550fn nonnegative_product_interval(lhs: f64, rhs: Interval) -> Option<Interval> {
3554 if !(lhs.is_finite()
3555 && lhs >= 0.0
3556 && rhs.lo.is_finite()
3557 && rhs.hi.is_finite()
3558 && rhs.lo >= 0.0
3559 && rhs.hi >= rhs.lo)
3560 {
3561 return None;
3562 }
3563 Some(Interval {
3564 lo: round_down(lhs * rhs.lo).max(0.0),
3565 hi: round_up(lhs * rhs.hi),
3566 })
3567}
3568
3569fn nonnegative_square_interval(bounds: Interval) -> Option<Interval> {
3571 if !(bounds.lo.is_finite()
3572 && bounds.hi.is_finite()
3573 && bounds.lo >= 0.0
3574 && bounds.hi >= bounds.lo)
3575 {
3576 return None;
3577 }
3578 Some(Interval {
3579 lo: round_down(bounds.lo * bounds.lo).max(0.0),
3580 hi: round_up(bounds.hi * bounds.hi),
3581 })
3582}
3583
3584fn conservative_interval(lo: f64, hi: f64, magnitude: f64, operations: usize) -> Interval {
3590 if !(lo.is_finite() && hi.is_finite() && magnitude.is_finite() && lo <= hi) {
3591 return Interval::entire();
3592 }
3593 let n_eps = (operations as f64) * f64::EPSILON;
3594 if n_eps >= 1.0 {
3595 return Interval::entire();
3596 }
3597 let pad =
3598 (n_eps / (1.0 - n_eps)) * magnitude.max(lo.abs()).max(hi.abs()).max(f64::MIN_POSITIVE);
3599 Interval {
3600 lo: round_down(lo - pad),
3601 hi: round_up(hi + pad),
3602 }
3603}
3604
3605#[derive(Clone, Copy)]
3610struct KernelRange {
3611 u_lo: f64,
3612 u_hi: f64,
3613 v_lo: f64,
3614 v_hi: f64,
3615 w_lo: f64,
3616 w_hi: f64,
3617 k_lo: f64,
3618 k_hi: f64,
3619}
3620
3621fn kernel_ranges(log_t_lo: f64, log_t_hi: f64) -> KernelRange {
3622 let kernels = |log_t: f64| modal_kernels(log_t, 1.0);
3623 let left = kernels(log_t_lo);
3624 let right = kernels(log_t_hi);
3625
3626 let u_lo = left.u;
3629 let u_hi = right.u;
3630 let v_lo = right.v;
3631 let v_hi = left.v;
3632
3633 let w_a = left.w;
3635 let w_b = right.w;
3636 let w_lo = w_a.min(w_b);
3637 let w_hi = if log_t_lo <= 0.0 && 0.0 <= log_t_hi {
3638 0.25
3639 } else {
3640 w_a.max(w_b)
3641 };
3642
3643 let sqrt3 = 3.0_f64.sqrt();
3646 let cp_lo = (2.0 - sqrt3).ln();
3647 let cp_hi = (2.0 + sqrt3).ln();
3648 let mut k_lo = left.k.min(right.k);
3649 let mut k_hi = left.k.max(right.k);
3650 if log_t_lo < cp_lo && cp_lo < log_t_hi {
3651 let kc = kernels(cp_lo).k;
3652 k_lo = k_lo.min(kc);
3653 k_hi = k_hi.max(kc);
3654 }
3655 if log_t_lo < cp_hi && cp_hi < log_t_hi {
3656 let kc = kernels(cp_hi).k;
3657 k_lo = k_lo.min(kc);
3658 k_hi = k_hi.max(kc);
3659 }
3660
3661 KernelRange {
3662 u_lo: round_down(u_lo).max(0.0),
3663 u_hi: round_up(u_hi),
3664 v_lo: round_down(v_lo).max(0.0),
3665 v_hi: round_up(v_hi),
3666 w_lo: round_down(w_lo).max(0.0),
3667 w_hi: round_up(w_hi),
3668 k_lo: round_down(k_lo),
3669 k_hi: round_up(k_hi),
3670 }
3671}
3672
3673fn reml_deriv_enclosure(
3681 cache: &GaussianRemlEigenCache,
3682 ywy: ArrayView1<'_, f64>,
3683 projected_rhs_squared: ArrayView2<'_, f64>,
3684 n_effective: usize,
3685 n_outputs: usize,
3686 a: f64,
3687 b: f64,
3688) -> (Interval, Interval) {
3689 reml_deriv_enclosure_profile(
3690 cache,
3691 ywy,
3692 projected_rhs_squared,
3693 n_outputs,
3694 n_effective as f64 - cache.nullity as f64,
3695 a,
3696 b,
3697 )
3698}
3699
3700fn reml_deriv_enclosure_profile(
3707 cache: &GaussianRemlEigenCache,
3708 ywy: ArrayView1<'_, f64>,
3709 projected_rhs_squared: ArrayView2<'_, f64>,
3710 logdet_output_count: usize,
3711 dispersion_dof: f64,
3712 a: f64,
3713 b: f64,
3714) -> (Interval, Interval) {
3715 let d = logdet_output_count as f64;
3716 let rank = cache.penalty_rank as f64;
3717 let half_d = 0.5 * d;
3718 let half_nu = 0.5 * dispersion_dof;
3719 let mut sum_u_lo = 0.0;
3722 let mut sum_u_hi = 0.0;
3723 let mut sum_w_lo = 0.0;
3724 let mut sum_w_hi = 0.0;
3725 for &delta in &cache.penalty_eigenvalues {
3726 if delta > 0.0 {
3727 let log_delta = delta.ln();
3728 let kr = kernel_ranges(a + log_delta, b + log_delta);
3729 sum_u_lo = add_down(sum_u_lo, kr.u_lo);
3730 sum_u_hi = add_up(sum_u_hi, kr.u_hi);
3731 sum_w_lo = add_down(sum_w_lo, kr.w_lo);
3732 sum_w_hi = add_up(sum_w_hi, kr.w_hi);
3733 }
3734 }
3735 let g1_lo = round_down(half_d * round_down(sum_u_lo - rank));
3736 let g1_hi = round_up(half_d * round_up(sum_u_hi - rank));
3737
3738 let mut g2_lo = 0.0;
3741 let mut g2_hi = 0.0;
3742 let mut vpp_disp_lo = 0.0;
3743 let mut vpp_disp_hi = 0.0;
3744 for j in 0..ywy.len() {
3745 let mut num_lo = 0.0; let mut num_hi = 0.0;
3747 let mut sv_lo = 0.0; let mut sv_hi = 0.0;
3749 let mut dph_lo = 0.0; let mut dph_hi = 0.0;
3751 for eig in 0..cache.penalty_eigenvalues.len() {
3752 let delta = cache.penalty_eigenvalues[eig];
3753 let c2 = projected_rhs_squared[[eig, j]];
3754 let log_delta = if delta == 0.0 {
3755 f64::NEG_INFINITY
3756 } else {
3757 delta.ln()
3758 };
3759 let kr = kernel_ranges(a + log_delta, b + log_delta);
3760 let Some(w_product) = nonnegative_product_interval(
3761 c2,
3762 Interval {
3763 lo: kr.w_lo,
3764 hi: kr.w_hi,
3765 },
3766 ) else {
3767 return (Interval::entire(), Interval::entire());
3768 };
3769 let Some(v_product) = nonnegative_product_interval(
3770 c2,
3771 Interval {
3772 lo: kr.v_lo,
3773 hi: kr.v_hi,
3774 },
3775 ) else {
3776 return (Interval::entire(), Interval::entire());
3777 };
3778 num_lo = add_down(num_lo, w_product.lo);
3779 num_hi = add_up(num_hi, w_product.hi);
3780 sv_lo = add_down(sv_lo, v_product.lo);
3781 sv_hi = add_up(sv_hi, v_product.hi);
3782 dph_lo = add_down(dph_lo, round_down(c2 * kr.k_lo));
3783 dph_hi = add_up(dph_hi, round_up(c2 * kr.k_hi));
3784 }
3785 let dp_lo = round_down(ywy[j] - sv_hi);
3789 let dp_hi = round_up(ywy[j] - sv_lo);
3790 if !(dp_lo.is_finite() && dp_hi.is_finite() && dp_lo > 0.0 && dp_hi >= dp_lo) {
3791 return (Interval::entire(), Interval::entire());
3792 }
3793
3794 let ratio_lo = round_down(num_lo / dp_hi).max(0.0);
3796 let ratio_hi = round_up(num_hi / dp_lo);
3797 g2_lo = add_down(g2_lo, ratio_lo);
3798 g2_hi = add_up(g2_hi, ratio_hi);
3799
3800 let quotients = [
3803 dph_lo / dp_lo,
3804 dph_lo / dp_hi,
3805 dph_hi / dp_lo,
3806 dph_hi / dp_hi,
3807 ];
3808 let adp_lo = round_down(quotients.iter().copied().fold(f64::INFINITY, f64::min));
3809 let adp_hi = round_up(quotients.iter().copied().fold(f64::NEG_INFINITY, f64::max));
3810
3811 let bl = round_down(num_lo / dp_hi).max(0.0);
3813 let bh = round_up(num_hi / dp_lo);
3814 let Some(squared_ratio) = nonnegative_square_interval(Interval { lo: bl, hi: bh }) else {
3815 return (Interval::entire(), Interval::entire());
3816 };
3817
3818 vpp_disp_lo = add_down(vpp_disp_lo, round_down(adp_lo - squared_ratio.hi));
3820 vpp_disp_hi = add_up(vpp_disp_hi, round_up(adp_hi - squared_ratio.lo));
3821 }
3822
3823 let vp_lo = add_down(g1_lo, round_down(half_nu * g2_lo));
3824 let vp_hi = add_up(g1_hi, round_up(half_nu * g2_hi));
3825 let vpp_lo = add_down(
3826 round_down(half_d * sum_w_lo),
3827 round_down(half_nu * vpp_disp_lo),
3828 );
3829 let vpp_hi = add_up(round_up(half_d * sum_w_hi), round_up(half_nu * vpp_disp_hi));
3830
3831 let operations = 64usize.saturating_add(
3832 32usize.saturating_mul(
3833 cache
3834 .penalty_eigenvalues
3835 .len()
3836 .saturating_mul(ywy.len().max(1)),
3837 ),
3838 );
3839 let vp_magnitude = g1_lo.abs() + g1_hi.abs() + half_nu.abs() * (g2_lo.abs() + g2_hi.abs());
3840 let vpp_magnitude = half_d.abs() * (sum_w_lo.abs() + sum_w_hi.abs())
3841 + half_nu.abs() * (vpp_disp_lo.abs() + vpp_disp_hi.abs());
3842 (
3843 conservative_interval(vp_lo, vp_hi, vp_magnitude, operations),
3844 conservative_interval(vpp_lo, vpp_hi, vpp_magnitude, operations),
3845 )
3846}
3847
3848#[derive(Clone, Copy, Debug)]
3849struct StationaryRoot {
3850 rho: f64,
3851 bracket: [f64; 2],
3852}
3853
3854#[derive(Clone, Copy, Debug)]
3855struct ProfileSelection {
3856 rho: f64,
3857 projected_gradient_residual: f64,
3858}
3859
3860#[derive(Clone, Copy)]
3861struct ProfileSearchControls {
3862 lower: f64,
3863 upper: f64,
3864 resolution: f64,
3865 max_depth: usize,
3866}
3867
3868impl ProfileSearchControls {
3869 const PRODUCTION: Self = Self {
3870 lower: RHO_LOWER,
3871 upper: RHO_UPPER,
3872 resolution: RHO_BRACKET_RESOLUTION,
3873 max_depth: MAX_DEPTH,
3874 };
3875}
3876
3877fn profile_search_refusal(
3878 eval: &impl Fn(f64) -> ObjectiveEval,
3879 checkpoint: f64,
3880 reason: String,
3881) -> EstimationError {
3882 let e = eval(checkpoint);
3883 EstimationError::RemlDidNotConverge {
3884 context: "closed-form Gaussian profiled REML stationary search".to_string(),
3885 reason,
3886 iterations: 0,
3887 final_value: e.cost,
3888 projected_grad_norm: e.grad.is_finite().then_some(e.grad.abs()),
3889 stationarity_bound: GRAD_TOL * (1.0 + e.cost.abs()),
3890 rho_checkpoint: vec![checkpoint],
3891 }
3892}
3893
3894fn refine_stationary_rho_core(
3900 eval: &impl Fn(f64) -> ObjectiveEval,
3901 mut lo: f64,
3902 mut hi: f64,
3903 resolution: f64,
3904 mut hint: Option<f64>,
3905) -> Result<StationaryRoot, EstimationError> {
3906 let mut left = eval(lo);
3907 let mut right = eval(hi);
3908 if left.grad == 0.0 {
3909 return Ok(StationaryRoot {
3910 rho: lo,
3911 bracket: [lo, lo],
3912 });
3913 }
3914 if right.grad == 0.0 {
3915 return Ok(StationaryRoot {
3916 rho: hi,
3917 bracket: [hi, hi],
3918 });
3919 }
3920 if left.grad.is_sign_positive() == right.grad.is_sign_positive() {
3921 return Err(profile_search_refusal(
3922 eval,
3923 0.5 * (lo + hi),
3924 format!("stationary refinement received a non-bracketing cell [{lo}, {hi}]"),
3925 ));
3926 }
3927
3928 loop {
3929 let width = hi - lo;
3930 let scale = 1.0 + lo.abs().max(hi.abs());
3931 if width <= resolution * scale {
3932 let midpoint = lo + 0.5 * width;
3933 let middle = if midpoint > lo && midpoint < hi {
3934 Some((midpoint, eval(midpoint)))
3935 } else {
3936 None
3937 };
3938 let mut representative = (lo, left);
3939 if right.grad.abs() < representative.1.grad.abs() {
3940 representative = (hi, right);
3941 }
3942 if let Some(candidate) = middle
3943 && candidate.1.grad.abs() < representative.1.grad.abs()
3944 {
3945 representative = candidate;
3946 }
3947 return Ok(StationaryRoot {
3948 rho: representative.0,
3949 bracket: [lo, hi],
3950 });
3951 }
3952
3953 let midpoint = lo + 0.5 * width;
3954 if !(midpoint > lo && midpoint < hi) {
3955 return Err(profile_search_refusal(
3956 eval,
3957 midpoint,
3958 format!(
3959 "stationary root on [{lo}, {hi}] reached floating-point spacing before rho resolution {resolution}"
3960 ),
3961 ));
3962 }
3963 let guard = 0.25 * width;
3964 let base = if left.grad.abs() <= right.grad.abs() {
3965 (lo, left)
3966 } else {
3967 (hi, right)
3968 };
3969 let newton = if base.1.hess != 0.0 {
3970 base.0 - base.1.grad / base.1.hess
3971 } else {
3972 f64::NAN
3973 };
3974 let candidate = hint
3975 .take()
3976 .filter(|&rho| rho >= lo + guard && rho <= hi - guard)
3977 .or_else(|| {
3978 (newton.is_finite() && newton >= lo + guard && newton <= hi - guard)
3979 .then_some(newton)
3980 })
3981 .unwrap_or(midpoint);
3982 if !(candidate > lo && candidate < hi) {
3983 return Err(profile_search_refusal(
3984 eval,
3985 midpoint,
3986 format!(
3987 "stationary refinement could not represent an interior point on [{lo}, {hi}]"
3988 ),
3989 ));
3990 }
3991 let current = eval(candidate);
3992 if current.grad == 0.0 {
3993 return Ok(StationaryRoot {
3994 rho: candidate,
3995 bracket: [candidate, candidate],
3996 });
3997 }
3998 if current.grad.is_sign_positive() == left.grad.is_sign_positive() {
3999 lo = candidate;
4000 left = current;
4001 } else {
4002 hi = candidate;
4003 right = current;
4004 }
4005 }
4006}
4007
4008fn interval_contains(interval: Interval, value: f64) -> bool {
4009 value.is_finite() && interval.lo <= value && value <= interval.hi
4010}
4011
4012fn enumerate_and_select_rho_with_controls(
4017 eval: impl Fn(f64) -> ObjectiveEval,
4018 enclose: impl Fn(f64, f64) -> (Interval, Interval),
4019 init_rho: Option<f64>,
4020 controls: ProfileSearchControls,
4021 mut visit: impl FnMut(StationaryRoot, &ObjectiveEval),
4022) -> Result<ProfileSelection, EstimationError> {
4023 const CAP: usize = MAX_DEPTH + 4;
4024 let mut stack = [(0.0f64, 0.0f64, 0usize); CAP];
4025 let mut top = 0usize;
4026 stack[top] = (controls.lower, controls.upper, 0);
4027 top += 1;
4028
4029 let lower_eval = eval(controls.lower);
4030 let upper_eval = eval(controls.upper);
4031 let (mut best_rho, mut best_eval) = if upper_eval.cost < lower_eval.cost {
4032 (controls.upper, upper_eval)
4033 } else {
4034 (controls.lower, lower_eval)
4035 };
4036 let mut last_root: Option<StationaryRoot> = None;
4037
4038 while top > 0 {
4039 top -= 1;
4040 let (a, b, depth) = stack[top];
4041 let ea = eval(a);
4042 let eb = eval(b);
4043 let (dv, dvv) = enclose(a, b);
4044 if !(interval_contains(dv, ea.grad)
4045 && interval_contains(dv, eb.grad)
4046 && interval_contains(dvv, ea.hess)
4047 && interval_contains(dvv, eb.hess))
4048 {
4049 return Err(profile_search_refusal(
4050 &eval,
4051 0.5 * (a + b),
4052 format!(
4053 "analytic derivative enclosure [{}, {}] / curvature enclosure [{}, {}] missed an endpoint jet on [{a}, {b}]",
4054 dv.lo, dv.hi, dvv.lo, dvv.hi
4055 ),
4056 ));
4057 }
4058 if dv.lo > 0.0 || dv.hi < 0.0 {
4059 continue;
4060 }
4061
4062 let monotone = dvv.lo > 0.0 || dvv.hi < 0.0;
4063 let at_floor = depth >= controls.max_depth
4064 || (b - a) <= controls.resolution * (1.0 + a.abs().max(b.abs()));
4065 if !monotone && at_floor {
4066 return Err(profile_search_refusal(
4067 &eval,
4068 0.5 * (a + b),
4069 format!(
4070 "stationary structure remained non-monotone on [{a}, {b}] at rho resolution {}",
4071 controls.resolution
4072 ),
4073 ));
4074 }
4075
4076 if monotone {
4077 let crosses = (ea.grad <= 0.0 && eb.grad >= 0.0) || (ea.grad >= 0.0 && eb.grad <= 0.0);
4078 if crosses {
4079 let hint = init_rho.filter(|rho| rho.is_finite() && *rho >= a && *rho <= b);
4080 let root = refine_stationary_rho_core(&eval, a, b, controls.resolution, hint)?;
4081 let duplicate = last_root.is_some_and(|previous| {
4082 root.rho.to_bits() == previous.rho.to_bits()
4083 || (root.bracket[0] <= previous.bracket[1]
4084 && previous.bracket[0] <= root.bracket[1])
4085 });
4086 if !duplicate {
4087 let e = eval(root.rho);
4088 if e.cost < best_eval.cost {
4089 best_rho = root.rho;
4090 best_eval = e;
4091 }
4092 visit(root, &e);
4093 last_root = Some(root);
4094 }
4095 }
4096 continue;
4097 }
4098
4099 let mid = a + 0.5 * (b - a);
4100 if !(mid > a && mid < b) || top + 2 > CAP {
4101 return Err(profile_search_refusal(
4102 &eval,
4103 mid,
4104 format!("stationary subdivision could not continue on [{a}, {b}]"),
4105 ));
4106 }
4107 stack[top] = (mid, b, depth + 1);
4108 top += 1;
4109 stack[top] = (a, mid, depth + 1);
4110 top += 1;
4111 }
4112
4113 if !(best_eval.cost.is_finite() && best_eval.grad.is_finite()) {
4114 return Err(EstimationError::InvalidInput(
4115 "Gaussian REML profiled search produced no finite candidate".to_string(),
4116 ));
4117 }
4118 let projected_gradient_residual = if best_rho == controls.lower {
4119 (-best_eval.grad).max(0.0)
4120 } else if best_rho == controls.upper {
4121 best_eval.grad.max(0.0)
4122 } else {
4123 best_eval.grad.abs()
4124 };
4125 Ok(ProfileSelection {
4126 rho: best_rho,
4127 projected_gradient_residual,
4128 })
4129}
4130
4131fn enumerate_and_select_rho(
4132 eval: impl Fn(f64) -> ObjectiveEval,
4133 enclose: impl Fn(f64, f64) -> (Interval, Interval),
4134 init_rho: Option<f64>,
4135 visit: impl FnMut(StationaryRoot, &ObjectiveEval),
4136) -> Result<ProfileSelection, EstimationError> {
4137 enumerate_and_select_rho_with_controls(
4138 eval,
4139 enclose,
4140 init_rho,
4141 ProfileSearchControls::PRODUCTION,
4142 visit,
4143 )
4144}
4145
4146fn compactified_limit_costs(
4154 cache: &GaussianRemlEigenCache,
4155 ywy: ArrayView1<'_, f64>,
4156 projected_rhs_squared: ArrayView2<'_, f64>,
4157 n_outputs: usize,
4158 nu: f64,
4159) -> [f64; 2] {
4160 let mut sum_log_delta_pos = 0.0;
4161 for &delta in &cache.penalty_eigenvalues {
4162 if delta > 0.0 {
4163 sum_log_delta_pos += delta.ln();
4164 }
4165 }
4166 let logdet_limit = cache.logdet_xtwx + sum_log_delta_pos - cache.logdet_penalty_positive;
4167 let mut plus_inf = 0.5 * (n_outputs as f64) * logdet_limit;
4168 for j in 0..ywy.len() {
4169 let mut null_mass = 0.0;
4170 for i in 0..cache.penalty_eigenvalues.len() {
4171 if cache.penalty_eigenvalues[i] == 0.0 {
4172 null_mass += projected_rhs_squared[[i, j]];
4173 }
4174 }
4175 let dp_inf = ywy[j] - null_mass;
4176 if !(dp_inf.is_finite() && dp_inf > 0.0) {
4177 plus_inf = f64::INFINITY;
4178 break;
4179 }
4180 plus_inf += 0.5 * nu * (1.0 + (2.0 * std::f64::consts::PI * dp_inf / nu).ln());
4181 }
4182 [f64::INFINITY, plus_inf]
4183}
4184
4185#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4187pub enum RhoLandscape {
4188 NoInteriorOptimum,
4191 UniqueInterior,
4193 MultipleInterior,
4195}
4196
4197#[derive(Clone, Debug)]
4207pub struct RhoLandscapeCertificate {
4208 pub stationary_count: usize,
4209 pub root_brackets: Vec<[f64; 2]>,
4210 pub landscape: RhoLandscape,
4211 pub window_costs: [f64; 2],
4212 pub limit_costs: [f64; 2],
4213 pub selected_rho: f64,
4214 pub boundary_optimum: bool,
4215 pub rho_window: [f64; 2],
4216}
4217
4218fn rho_landscape_certificate_from_parts(
4219 cache: &GaussianRemlEigenCache,
4220 ywy: ArrayView1<'_, f64>,
4221 projected_rhs_squared: ArrayView2<'_, f64>,
4222 n_effective: usize,
4223 n_outputs: usize,
4224 init_rho: Option<f64>,
4225) -> Result<RhoLandscapeCertificate, EstimationError> {
4226 validate_reml_profile_residuals(cache, ywy, projected_rhs_squared, RHO_LOWER)?;
4227 let nu = n_effective as f64 - cache.nullity as f64;
4228 let eval = |rho: f64| {
4229 evaluate_reml_parts(
4230 cache,
4231 ywy,
4232 projected_rhs_squared,
4233 n_effective,
4234 n_outputs,
4235 rho,
4236 )
4237 };
4238 let window_costs = [eval(RHO_LOWER).cost, eval(RHO_UPPER).cost];
4239 let limit_costs = compactified_limit_costs(cache, ywy, projected_rhs_squared, n_outputs, nu);
4240
4241 let mut root_brackets = Vec::new();
4242 let selection = if cache.penalty_rank == 0 {
4243 ProfileSelection {
4244 rho: init_rho.unwrap_or(0.0).clamp(RHO_LOWER, RHO_UPPER),
4245 projected_gradient_residual: 0.0,
4246 }
4247 } else {
4248 let enclose = |a: f64, b: f64| {
4249 reml_deriv_enclosure(
4250 cache,
4251 ywy,
4252 projected_rhs_squared,
4253 n_effective,
4254 n_outputs,
4255 a,
4256 b,
4257 )
4258 };
4259 enumerate_and_select_rho(&eval, &enclose, init_rho, |root, _e| {
4260 root_brackets.push(root.bracket)
4261 })?
4262 };
4263
4264 let stationary_count = root_brackets.len();
4265 let landscape = match stationary_count {
4266 0 => RhoLandscape::NoInteriorOptimum,
4267 1 => RhoLandscape::UniqueInterior,
4268 _ => RhoLandscape::MultipleInterior,
4269 };
4270 let boundary_optimum = matches!(landscape, RhoLandscape::NoInteriorOptimum);
4271
4272 Ok(RhoLandscapeCertificate {
4273 stationary_count,
4274 root_brackets,
4275 landscape,
4276 window_costs,
4277 limit_costs,
4278 selected_rho: selection.rho,
4279 boundary_optimum,
4280 rho_window: [RHO_LOWER, RHO_UPPER],
4281 })
4282}
4283
4284pub fn gaussian_reml_rho_landscape_certificate(
4288 x: ArrayView2<'_, f64>,
4289 y: ArrayView1<'_, f64>,
4290 penalty: ArrayView2<'_, f64>,
4291 nullspace_dim: Option<usize>,
4292 weights: Option<ArrayView1<'_, f64>>,
4293 init_rho: Option<f64>,
4294) -> Result<RhoLandscapeCertificate, EstimationError> {
4295 if init_rho.is_some_and(|rho| !rho.is_finite()) {
4296 crate::bail_invalid_estim!(
4297 "Gaussian REML rho-landscape certificate requires a finite rho hint"
4298 );
4299 }
4300 let y2 = y.insert_axis(Axis(1));
4301 let prepared = prepare_gaussian_reml(x, y2.view(), penalty, nullspace_dim, weights, None)?;
4302 rho_landscape_certificate_from_parts(
4303 &prepared.cache,
4304 prepared.ywy.view(),
4305 prepared.projected_rhs_squared.view(),
4306 prepared.n_effective,
4307 prepared.n_outputs,
4308 init_rho,
4309 )
4310}
4311
4312fn optimize_rho(
4314 prepared: &GaussianRemlPrepared,
4315 init_rho: Option<f64>,
4316) -> Result<f64, EstimationError> {
4317 validate_reml_profile_residuals(
4318 &prepared.cache,
4319 prepared.ywy.view(),
4320 prepared.projected_rhs_squared.view(),
4321 RHO_LOWER,
4322 )?;
4323 if prepared.cache.penalty_rank == 0 {
4324 return Ok(init_rho.unwrap_or(0.0).clamp(RHO_LOWER, RHO_UPPER));
4325 }
4326 let eval = |rho: f64| prepared.evaluate(rho);
4327 let enclose = |a: f64, b: f64| {
4328 reml_deriv_enclosure(
4329 &prepared.cache,
4330 prepared.ywy.view(),
4331 prepared.projected_rhs_squared.view(),
4332 prepared.n_effective,
4333 prepared.n_outputs,
4334 a,
4335 b,
4336 )
4337 };
4338 Ok(enumerate_and_select_rho(eval, enclose, init_rho, |_r, _e| {})?.rho)
4339}
4340
4341fn fill_weighted_rhs_no_alloc(
4342 x: ArrayView2<'_, f64>,
4343 y: ArrayView2<'_, f64>,
4344 weights: Option<ArrayView1<'_, f64>>,
4345 workspace: &mut GaussianRemlNoAllocWorkspace,
4346) -> Result<(), EstimationError> {
4347 let d = y.ncols();
4348
4349 let (xtwy, ywy_full) = match weights {
4355 Some(w) => (fast_xt_diag_y(&x, &w, &y), fast_xt_diag_y(&y, &w, &y)),
4356 None => (fast_atb(&x, &y), fast_atb(&y, &y)),
4357 };
4358 workspace.xtwy.assign(&xtwy);
4359 for output in 0..d {
4360 workspace.ywy[output] = ywy_full[[output, output]];
4361 }
4362
4363 if workspace
4364 .xtwy
4365 .iter()
4366 .chain(workspace.ywy.iter())
4367 .any(|value| !value.is_finite())
4368 {
4369 crate::bail_invalid_estim!("Gaussian REML weighted cross-products must be finite");
4370 }
4371 Ok(())
4372}
4373
4374fn project_rhs_no_alloc(
4375 cache: &GaussianRemlEigenCache,
4376 workspace: &mut GaussianRemlNoAllocWorkspace,
4377) {
4378 let projected = fast_atb(&cache.coefficient_basis, &workspace.xtwy);
4381 workspace.projected_rhs.assign(&projected);
4382 let p = cache.penalty_eigenvalues.len();
4383 let d = workspace.ywy.len();
4384 for eig in 0..p {
4385 for output in 0..d {
4386 let value = workspace.projected_rhs[[eig, output]];
4387 workspace.projected_rhs_squared[[eig, output]] = value * value;
4388 }
4389 }
4390}
4391
4392fn evaluate_reml_parts(
4393 cache: &GaussianRemlEigenCache,
4394 ywy: ArrayView1<'_, f64>,
4395 projected_rhs_squared: ArrayView2<'_, f64>,
4396 n_effective: usize,
4397 n_outputs: usize,
4398 rho: f64,
4399) -> ObjectiveEval {
4400 evaluate_reml_profile(
4401 cache,
4402 ywy,
4403 projected_rhs_squared,
4404 n_outputs,
4405 n_effective as f64 - cache.nullity as f64,
4406 rho,
4407 )
4408}
4409
4410fn evaluate_reml_profile(
4414 cache: &GaussianRemlEigenCache,
4415 ywy: ArrayView1<'_, f64>,
4416 projected_rhs_squared: ArrayView2<'_, f64>,
4417 logdet_output_count: usize,
4418 dispersion_dof: f64,
4419 rho: f64,
4420) -> ObjectiveEval {
4421 let d = logdet_output_count as f64;
4422
4423 let (logdet_term, edf) = gaussian_reml_logdet_term(cache, rho, d);
4426 let mut eval = ObjectiveEval {
4427 cost: 0.0,
4428 grad: 0.0,
4429 hess: 0.0,
4430 edf,
4431 };
4432 eval += logdet_term;
4433 for output in 0..ywy.len() {
4434 eval += gaussian_reml_dispersion_term(
4435 cache,
4436 ywy,
4437 projected_rhs_squared,
4438 output,
4439 dispersion_dof,
4440 rho,
4441 );
4442 }
4443 eval
4444}
4445
4446fn optimize_rho_no_alloc(
4452 cache: &GaussianRemlEigenCache,
4453 ywy: ArrayView1<'_, f64>,
4454 projected_rhs_squared: ArrayView2<'_, f64>,
4455 n_effective: usize,
4456 n_outputs: usize,
4457 init_rho: Option<f64>,
4458) -> Result<f64, EstimationError> {
4459 validate_reml_profile_residuals(cache, ywy.view(), projected_rhs_squared.view(), RHO_LOWER)?;
4460 if cache.penalty_rank == 0 {
4461 return Ok(init_rho.unwrap_or(0.0).clamp(RHO_LOWER, RHO_UPPER));
4462 }
4463 let eval = |rho: f64| {
4464 evaluate_reml_parts(
4465 cache,
4466 ywy,
4467 projected_rhs_squared,
4468 n_effective,
4469 n_outputs,
4470 rho,
4471 )
4472 };
4473 let enclose = |a: f64, b: f64| {
4474 reml_deriv_enclosure(
4475 cache,
4476 ywy,
4477 projected_rhs_squared,
4478 n_effective,
4479 n_outputs,
4480 a,
4481 b,
4482 )
4483 };
4484 Ok(enumerate_and_select_rho(eval, enclose, init_rho, |_r, _e| {})?.rho)
4485}
4486
4487fn fill_coefficients_no_alloc(
4488 cache: &GaussianRemlEigenCache,
4489 workspace: &mut GaussianRemlNoAllocWorkspace,
4490 lambda: f64,
4491 mut coefficients: ArrayViewMut2<'_, f64>,
4492) {
4493 let p = cache.penalty_eigenvalues.len();
4494 let d = workspace.ywy.len();
4495 for eig in 0..p {
4496 let scale = 1.0 / (1.0 + lambda * cache.penalty_eigenvalues[eig]);
4497 for output in 0..d {
4498 workspace.scaled_projected_rhs[[eig, output]] =
4499 workspace.projected_rhs[[eig, output]] * scale;
4500 }
4501 }
4502
4503 for col in 0..p {
4504 for output in 0..d {
4505 let mut value = 0.0;
4506 for eig in 0..p {
4507 value += cache.coefficient_basis[[col, eig]]
4508 * workspace.scaled_projected_rhs[[eig, output]];
4509 }
4510 coefficients[[col, output]] = value;
4511 }
4512 }
4513}
4514
4515fn fill_fitted_no_alloc(
4516 x: ArrayView2<'_, f64>,
4517 coefficients: ArrayView2<'_, f64>,
4518 mut fitted: ArrayViewMut2<'_, f64>,
4519) {
4520 let n = x.nrows();
4521 let p = x.ncols();
4522 let d = coefficients.ncols();
4523 for row in 0..n {
4524 for output in 0..d {
4525 let mut value = 0.0;
4526 for col in 0..p {
4527 value += x[[row, col]] * coefficients[[col, output]];
4528 }
4529 fitted[[row, output]] = value;
4530 }
4531 }
4532}
4533
4534fn fill_sigma2_no_alloc(
4535 cache: &GaussianRemlEigenCache,
4536 ywy: ArrayView1<'_, f64>,
4537 projected_rhs_squared: ArrayView2<'_, f64>,
4538 n_effective: usize,
4539 n_outputs: usize,
4540 lambda: f64,
4541 mut sigma2: ArrayViewMut1<'_, f64>,
4542) {
4543 let nu = n_effective as f64 - cache.nullity as f64;
4544 for output in 0..n_outputs {
4545 let mut fitted_quadratic = 0.0;
4546 for eig in 0..cache.penalty_eigenvalues.len() {
4547 let denom = 1.0 + lambda * cache.penalty_eigenvalues[eig];
4548 fitted_quadratic += projected_rhs_squared[[eig, output]] / denom;
4549 }
4550 sigma2[output] = (ywy[output] - fitted_quadratic) / nu;
4551 }
4552}
4553
4554fn invert_lower_triangular(lower: &Array2<f64>) -> Result<Array2<f64>, EstimationError> {
4555 let n = lower.nrows();
4556 if lower.ncols() != n {
4557 crate::bail_invalid_estim!("lower-triangular solve requires a square matrix");
4558 }
4559 let eye = Array2::eye(n);
4560 solve_lower_triangular_matrix(lower, &eye)
4561}
4562
4563fn solve_lower_triangular_matrix(
4564 lower: &Array2<f64>,
4565 rhs: &Array2<f64>,
4566) -> Result<Array2<f64>, EstimationError> {
4567 let n = lower.nrows();
4568 if lower.ncols() != n || rhs.nrows() != n {
4569 crate::bail_invalid_estim!("lower-triangular solve dimension mismatch");
4570 }
4571 if let Some(out) = gam_gpu::try_solve_lower_triangular_matrix(lower.view(), rhs.view()) {
4572 return Ok(out);
4573 }
4574 let mut out = Array2::<f64>::zeros(rhs.dim());
4575 for col in 0..rhs.ncols() {
4576 for i in 0..n {
4577 let mut value = rhs[[i, col]];
4578 for k in 0..i {
4579 value -= lower[[i, k]] * out[[k, col]];
4580 }
4581 let diag = lower[[i, i]];
4582 if !(diag.is_finite() && diag.abs() > 0.0) {
4583 return Err(EstimationError::ModelIsIllConditioned {
4584 condition_number: f64::INFINITY,
4585 });
4586 }
4587 out[[i, col]] = value / diag;
4588 }
4589 }
4590 Ok(out)
4591}
4592
4593fn solve_spd_from_lower_factor(
4597 lower: &Array2<f64>,
4598 rhs: &Array2<f64>,
4599) -> Result<Array2<f64>, EstimationError> {
4600 let forward = solve_lower_triangular_matrix(lower, rhs)?;
4601 solve_upper_triangular_matrix(&lower.t().to_owned(), &forward)
4602}
4603
4604fn solve_upper_triangular_matrix(
4605 upper: &Array2<f64>,
4606 rhs: &Array2<f64>,
4607) -> Result<Array2<f64>, EstimationError> {
4608 let n = upper.nrows();
4609 if upper.ncols() != n || rhs.nrows() != n {
4610 crate::bail_invalid_estim!("upper-triangular solve dimension mismatch");
4611 }
4612 if let Some(out) = gam_gpu::try_solve_upper_triangular_matrix(upper.view(), rhs.view()) {
4613 return Ok(out);
4614 }
4615 let mut out = Array2::<f64>::zeros(rhs.dim());
4616 for col in 0..rhs.ncols() {
4617 for i_rev in 0..n {
4618 let i = n - 1 - i_rev;
4619 let mut value = rhs[[i, col]];
4620 for k in (i + 1)..n {
4621 value -= upper[[i, k]] * out[[k, col]];
4622 }
4623 let diag = upper[[i, i]];
4624 if !(diag.is_finite() && diag.abs() > 0.0) {
4625 return Err(EstimationError::ModelIsIllConditioned {
4626 condition_number: f64::INFINITY,
4627 });
4628 }
4629 out[[i, col]] = value / diag;
4630 }
4631 }
4632 Ok(out)
4633}
4634
4635#[cfg(test)]
4636mod tests {
4637 use super::*;
4638 use ndarray::array;
4639
4640 #[test]
4641 fn edf_does_not_double_count_penalty_nullspace() {
4642 let x = array![[1.0, 0.0], [1.0, 1.0], [1.0, 2.0], [1.0, 3.0], [1.0, 4.0],];
4643 let y = array![[0.0], [1.0], [1.8], [3.2], [4.1]];
4644 let penalty = array![[0.0, 0.0], [0.0, 1.0]];
4645 let result =
4646 gaussian_reml_multi_closed_form(x.view(), y.view(), penalty.view(), None, Some(0.0))
4647 .expect("small full-rank Gaussian REML fit");
4648
4649 assert!(result.edf >= result.cache.nullity as f64);
4650 assert!(result.edf <= x.ncols() as f64 + 1.0e-10);
4651 }
4652
4653 #[test]
4654 fn shared_dispersion_pools_projection_exact_and_missed_outputs() {
4655 let n = 12usize;
4656 let mut x = Array2::<f64>::zeros((n, 2));
4657 let mut y = Array2::<f64>::zeros((n, 2));
4658 for row in 0..n {
4659 let t = row as f64 - 5.5;
4660 x[[row, 0]] = 1.0;
4661 x[[row, 1]] = t;
4662 y[[row, 0]] = t;
4665 y[[row, 1]] = if row % 2 == 0 { -2.0 } else { 3.0 };
4667 }
4668 let penalty = Array2::<f64>::zeros((2, 2));
4669 let fit = gaussian_reml_multi_shared_dispersion_closed_form(
4670 x.view(),
4671 y.view(),
4672 penalty.view(),
4673 None,
4674 None,
4675 )
4676 .expect("shared-dispersion vector REML fit");
4677
4678 assert_eq!(fit.sigma2[0].to_bits(), fit.sigma2[1].to_bits());
4679 let mut pooled_rss = 0.0_f64;
4680 for row in 0..n {
4681 for output in 0..2 {
4682 let residual = y[[row, output]] - fit.fitted[[row, output]];
4683 pooled_rss += residual * residual;
4684 }
4685 }
4686 let shared_nu = (2 * (n - fit.cache.nullity)) as f64;
4687 let expected_sigma2 = pooled_rss / shared_nu;
4688 assert!(expected_sigma2 > 0.0);
4689 assert!(
4690 (fit.sigma2[0] - expected_sigma2).abs()
4691 <= f64::EPSILON.sqrt() * expected_sigma2.max(1.0),
4692 "shared sigma2 {} must equal pooled vector deviance / shared dof {}",
4693 fit.sigma2[0],
4694 expected_sigma2
4695 );
4696 }
4697
4698 #[test]
4699 fn shared_dispersion_penalty_envelope_gradient_matches_refitted_direction() {
4700 let n = 24usize;
4701 let mut x = Array2::<f64>::zeros((n, 3));
4702 let mut y = Array2::<f64>::zeros((n, 2));
4703 for row in 0..n {
4704 let t = -1.0 + 2.0 * row as f64 / (n - 1) as f64;
4705 x[[row, 0]] = 1.0;
4706 x[[row, 1]] = t;
4707 x[[row, 2]] = t * t;
4708 y[[row, 0]] = 0.3 + 1.2 * t - 0.8 * t * t + 0.04 * (7.0 * t).sin();
4709 y[[row, 1]] = -0.2 + 0.5 * t + 0.4 * t * t + 0.03 * (5.0 * t).cos();
4710 }
4711 let penalty = array![[0.0, 0.0, 0.0], [0.0, 0.7, 0.1], [0.0, 0.1, 1.4]];
4712 let direction = array![[0.0, 0.0, 0.0], [0.0, 0.3, -0.08], [0.0, -0.08, 0.6]];
4713 let fit = gaussian_reml_multi_shared_dispersion_closed_form(
4714 x.view(),
4715 y.view(),
4716 penalty.view(),
4717 None,
4718 None,
4719 )
4720 .unwrap();
4721 let gradient = gaussian_reml_multi_shared_dispersion_penalty_gradient_from_fit(
4722 x.view(),
4723 y.view(),
4724 penalty.view(),
4725 None,
4726 &fit,
4727 )
4728 .unwrap();
4729 let analytic = gradient
4730 .iter()
4731 .zip(direction.iter())
4732 .map(|(gradient, direction)| gradient * direction)
4733 .sum::<f64>();
4734
4735 let step = f64::EPSILON.cbrt();
4736 let plus_penalty = &penalty + &(direction.mapv(|value| step * value));
4737 let minus_penalty = &penalty - &(direction.mapv(|value| step * value));
4738 let plus = gaussian_reml_multi_shared_dispersion_closed_form(
4739 x.view(),
4740 y.view(),
4741 plus_penalty.view(),
4742 None,
4743 Some(fit.rho),
4744 )
4745 .unwrap();
4746 let minus = gaussian_reml_multi_shared_dispersion_closed_form(
4747 x.view(),
4748 y.view(),
4749 minus_penalty.view(),
4750 None,
4751 Some(fit.rho),
4752 )
4753 .unwrap();
4754 let numerical = (plus.reml_score - minus.reml_score) / (2.0 * step);
4755 let scale = analytic.abs().max(numerical.abs()).max(1.0);
4756 assert!(
4757 (analytic - numerical).abs() <= 2.0e-5 * scale,
4758 "shared-dispersion penalty envelope derivative mismatch: analytic={analytic}, refitted={numerical}"
4759 );
4760 }
4761
4762 #[test]
4763 fn block_orthogonal_score_matches_the_objective_derivative() {
4764 let gram = array![[3.0, 0.4], [0.4, 2.0]];
4765 let rhs = array![[1.2, -0.3], [0.6, 0.9]];
4766 let penalty = array![[1.0, 0.2], [0.2, 0.8]];
4767 let scale = array![1.3, 0.8];
4768 let rho = 0.37;
4769 let step = 1.0e-6;
4770 let eval = block_orthogonal_eval(&gram, &rhs, &penalty, rho).unwrap();
4771 let analytic = block_orthogonal_scale_objective(&eval, rho, scale.view(), 2).grad;
4772 let value_at = |candidate_rho: f64| {
4773 let candidate = block_orthogonal_eval(&gram, &rhs, &penalty, candidate_rho).unwrap();
4774 block_orthogonal_scale_objective(&candidate, candidate_rho, scale.view(), 2).value
4775 };
4776 let numerical = (value_at(rho + step) - value_at(rho - step)) / (2.0 * step);
4777 assert!(
4778 (analytic - numerical).abs() <= 1.0e-7 * analytic.abs().max(1.0),
4779 "analytic score {analytic:.12e} != objective derivative {numerical:.12e}"
4780 );
4781 }
4782
4783 #[test]
4784 fn block_orthogonal_profile_hessian_matches_the_profiled_objective() {
4785 let grams = [
4786 array![[3.0, 0.4], [0.4, 2.0]],
4787 array![[2.5, -0.2], [-0.2, 1.8]],
4788 ];
4789 let rhs = [
4790 array![[1.2, -0.3], [0.6, 0.9]],
4791 array![[0.5, 0.8], [-0.4, 0.7]],
4792 ];
4793 let penalties = [
4794 array![[1.0, 0.2], [0.2, 0.8]],
4795 array![[0.9, -0.1], [-0.1, 1.1]],
4796 ];
4797 let ranks = [2_usize, 2_usize];
4798 let ywy = array![8.0, 9.0];
4799 let nu = 7.0;
4800 let rhos = array![0.37, -0.21];
4801 let profile_value = |candidate_rhos: ArrayView1<'_, f64>| {
4802 let evals = (0..2)
4803 .map(|block| {
4804 block_orthogonal_eval(
4805 &grams[block],
4806 &rhs[block],
4807 &penalties[block],
4808 candidate_rhos[block],
4809 )
4810 .unwrap()
4811 })
4812 .collect::<Vec<_>>();
4813 let mut q = ywy.clone();
4814 for eval in &evals {
4815 q -= &eval.fitted_energy;
4816 }
4817 let determinant_term = evals
4818 .iter()
4819 .enumerate()
4820 .map(|(block, eval)| eval.logdet - ranks[block] as f64 * candidate_rhos[block])
4821 .sum::<f64>();
4822 0.5 * 2.0 * determinant_term + 0.5 * nu * q.iter().map(|value| value.ln()).sum::<f64>()
4823 };
4824 let evals = (0..2)
4825 .map(|block| {
4826 block_orthogonal_eval(&grams[block], &rhs[block], &penalties[block], rhos[block])
4827 .unwrap()
4828 })
4829 .collect::<Vec<_>>();
4830 let scale = block_orthogonal_conditional_scale(&evals, ywy.view(), nu).unwrap();
4831 let analytic =
4832 block_orthogonal_profile_hessian(&evals, rhos.view(), scale.view(), &ranks, nu)
4833 .unwrap();
4834 let step = 1.0e-4;
4835 let center = profile_value(rhos.view());
4836 let mut numerical = Array2::<f64>::zeros((2, 2));
4837 for coordinate in 0..2 {
4838 let mut plus = rhos.clone();
4839 let mut minus = rhos.clone();
4840 plus[coordinate] += step;
4841 minus[coordinate] -= step;
4842 numerical[[coordinate, coordinate]] = (profile_value(plus.view()) - 2.0 * center
4843 + profile_value(minus.view()))
4844 / (step * step);
4845 }
4846 let mut plus_plus = rhos.clone();
4847 let mut plus_minus = rhos.clone();
4848 let mut minus_plus = rhos.clone();
4849 let mut minus_minus = rhos.clone();
4850 plus_plus[0] += step;
4851 plus_plus[1] += step;
4852 plus_minus[0] += step;
4853 plus_minus[1] -= step;
4854 minus_plus[0] -= step;
4855 minus_plus[1] += step;
4856 minus_minus[0] -= step;
4857 minus_minus[1] -= step;
4858 let cross = (profile_value(plus_plus.view())
4859 - profile_value(plus_minus.view())
4860 - profile_value(minus_plus.view())
4861 + profile_value(minus_minus.view()))
4862 / (4.0 * step * step);
4863 numerical[[0, 1]] = cross;
4864 numerical[[1, 0]] = cross;
4865 for row in 0..2 {
4866 for col in 0..2 {
4867 assert!(
4868 (analytic[[row, col]] - numerical[[row, col]]).abs()
4869 <= 2.0e-6 * analytic[[row, col]].abs().max(1.0),
4870 "profile Hessian ({row}, {col}) analytic {:.12e} != numerical {:.12e}",
4871 analytic[[row, col]],
4872 numerical[[row, col]]
4873 );
4874 }
4875 }
4876 }
4877
4878 #[test]
4879 fn block_orthogonal_shared_scale_fit_carries_a_score_certificate() {
4880 let c0 = [1.0_f64; 8];
4886 let c1 = [1.0, 1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0];
4887 let c2 = [1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, -1.0];
4888 let c3 = [1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0];
4889 let mut d1 = Array2::<f64>::zeros((8, 2));
4890 let mut d2 = Array2::<f64>::zeros((8, 2));
4891 for i in 0..8 {
4892 d1[[i, 0]] = c0[i];
4893 d1[[i, 1]] = c1[i];
4894 d2[[i, 0]] = c2[i];
4895 d2[[i, 1]] = c3[i];
4896 }
4897 let penalties = vec![Array2::<f64>::eye(2), Array2::<f64>::eye(2)];
4898 let bumps = [0.03, -0.05, 0.02, 0.01, -0.02, 0.04, -0.01, -0.02];
4899 let mut y = Array2::<f64>::zeros((8, 1));
4900 for i in 0..8 {
4901 y[[i, 0]] = c0[i] + 0.5 * c1[i] + 0.25 * c2[i] + bumps[i];
4902 }
4903
4904 let result = gaussian_reml_blocks_orthogonal_shared_scale(
4905 &[d1.clone(), d2.clone()],
4906 &penalties,
4907 y.view(),
4908 None,
4909 None,
4910 )
4911 .expect("well-posed orthogonal-block fit must certify and mint");
4912
4913 let weight = Array1::<f64>::ones(8);
4914 let ywy = (0..8).map(|i| y[[i, 0]] * y[[i, 0]]).sum::<f64>();
4915 let nu = 8.0_f64;
4917 let mut evals = Vec::new();
4918 for (block, design) in [&d1, &d2].into_iter().enumerate() {
4919 let gram = canonicalize_penalty(dense_xt_diag_x(design.view(), weight.view()).view());
4920 let rhs = dense_xt_diag_y(design.view(), weight.view(), y.view());
4921 let pen = canonicalize_penalty(penalties[block].view());
4922 evals.push(
4923 block_orthogonal_eval(&gram, &rhs, &pen, result.log_lambdas[block])
4924 .expect("block eval at the minted rho"),
4925 );
4926 }
4927 let explained: f64 = evals.iter().map(|eval| eval.fitted_energy[0]).sum();
4928 let q = ywy - explained;
4929 assert!(q > 0.0);
4930 let scale = Array1::from_vec(vec![nu / q]);
4931 for (block, eval) in evals.iter().enumerate() {
4932 let derivs =
4933 block_orthogonal_scale_objective(eval, result.log_lambdas[block], scale.view(), 2);
4934 let residual = derivs.grad.abs() / 2.0;
4935 assert!(
4936 residual <= BLOCK_ORTHOGONAL_SCORE_TOL,
4937 "block {block} score residual {residual:.3e} exceeds the certificate tolerance"
4938 );
4939 }
4940 let curvature = block_orthogonal_profile_curvature(
4941 &evals,
4942 result.log_lambdas.view(),
4943 scale.view(),
4944 &[2, 2],
4945 nu,
4946 )
4947 .unwrap();
4948 assert!(
4949 curvature.min_eigenvalue >= -curvature.roundoff,
4950 "minted fit has negative profiled curvature {:.6e} beyond roundoff {:.3e}",
4951 curvature.min_eigenvalue,
4952 curvature.roundoff
4953 );
4954
4955 let err = gaussian_reml_blocks_orthogonal_shared_scale_with_controls(
4956 &[d1, d2],
4957 &penalties,
4958 y.view(),
4959 None,
4960 None,
4961 BlockOrthogonalControls {
4962 max_outer_passes: 0,
4963 ..BlockOrthogonalControls::default()
4964 },
4965 )
4966 .unwrap_err();
4967 match err {
4968 EstimationError::BlockOrthogonalRemlDidNotConverge {
4969 iterations,
4970 max_score_residual,
4971 rho_checkpoint,
4972 ..
4973 } => {
4974 assert_eq!(iterations, 0);
4975 assert!(max_score_residual.is_infinite());
4976 assert_eq!(rho_checkpoint, vec![0.0, 0.0]);
4977 }
4978 other => panic!("expected typed block-orthogonal exhaustion, got {other}"),
4979 }
4980 }
4981
4982 #[test]
4983 fn block_orthogonal_solver_rejects_cross_block_signal() {
4984 let first = array![[1.0], [1.0], [1.0], [1.0], [1.0], [1.0]];
4985 let second = array![[0.0], [1.0], [2.0], [3.0], [4.0], [5.0]];
4986 let penalties = vec![Array2::<f64>::eye(1), Array2::<f64>::eye(1)];
4987 let y = array![[0.2], [0.8], [1.7], [3.1], [3.9], [5.2]];
4988 let err = gaussian_reml_blocks_orthogonal_shared_scale(
4989 &[first, second],
4990 &penalties,
4991 y.view(),
4992 None,
4993 None,
4994 )
4995 .unwrap_err();
4996 assert!(
4997 matches!(&err, EstimationError::InvalidInput(_)),
4998 "nonorthogonal blocks must fail the decomposed-objective contract: {err}"
4999 );
5000 assert!(err.to_string().contains("weighted cross-product"));
5001 }
5002
5003 #[test]
5004 fn multi_output_duplicate_columns_match_scalar_fit() {
5005 let x = array![
5006 [1.0, -1.0],
5007 [1.0, -0.5],
5008 [1.0, 0.0],
5009 [1.0, 0.5],
5010 [1.0, 1.0],
5011 [1.0, 1.5],
5012 ];
5013 let y1 = array![0.5, 0.2, 0.0, 0.3, 1.1, 2.0];
5014 let y = Array2::from_shape_fn(
5015 (y1.len(), 2),
5016 |(i, j)| if j == 0 { y1[i] } else { 2.0 * y1[i] },
5017 );
5018 let penalty = array![[0.0, 0.0], [0.0, 1.0]];
5019
5020 let scalar =
5021 gaussian_reml_closed_form(x.view(), y1.view(), penalty.view(), None, Some(0.0))
5022 .expect("scalar Gaussian REML fit");
5023 let multi =
5024 gaussian_reml_multi_closed_form(x.view(), y.view(), penalty.view(), None, Some(0.0))
5025 .expect("multi-output Gaussian REML fit");
5026
5027 assert!((multi.rho - scalar.rho).abs() <= 1.0e-8);
5028 for i in 0..x.ncols() {
5029 assert!((multi.coefficients[[i, 0]] - scalar.coefficients[i]).abs() <= 1.0e-8);
5030 assert!((multi.coefficients[[i, 1]] - 2.0 * scalar.coefficients[i]).abs() <= 1.0e-8);
5031 }
5032 }
5033
5034 #[test]
5035 fn warm_start_reuses_cache_and_lambda_seed() {
5036 let x = array![
5037 [1.0, -1.0],
5038 [1.0, -0.25],
5039 [1.0, 0.5],
5040 [1.0, 1.25],
5041 [1.0, 2.0],
5042 ];
5043 let y = array![[0.1], [0.4], [0.7], [1.4], [2.2]];
5044 let penalty = array![[0.0, 0.0], [0.0, 1.0]];
5045
5046 let cold =
5047 gaussian_reml_multi_closed_form(x.view(), y.view(), penalty.view(), None, Some(0.0))
5048 .expect("cold fit");
5049 let warm_start = GaussianRemlWarmStart::from_multi_result(&cold);
5050 let warm = gaussian_reml_multi_closed_form_warm_started(
5051 x.view(),
5052 y.view(),
5053 penalty.view(),
5054 None,
5055 Some(&warm_start),
5056 )
5057 .expect("warm-started fit");
5058
5059 assert!((cold.lambda - warm.lambda).abs() <= 1.0e-10);
5060 assert_eq!(cold.cache.xtwx_fingerprint, warm.cache.xtwx_fingerprint);
5061 for i in 0..x.ncols() {
5062 assert!((cold.coefficients[[i, 0]] - warm.coefficients[[i, 0]]).abs() <= 1.0e-10);
5063 }
5064 }
5065
5066 #[test]
5067 fn warm_start_cache_rejects_different_penalty_geometry() {
5068 let x = array![
5069 [1.0, -1.0],
5070 [1.0, -0.25],
5071 [1.0, 0.5],
5072 [1.0, 1.25],
5073 [1.0, 2.0],
5074 ];
5075 let y = array![[0.1], [0.4], [0.7], [1.4], [2.2]];
5076 let penalty_a = array![[0.0, 0.0], [0.0, 1.0]];
5077 let penalty_b = array![[1.0, -1.0], [-1.0, 1.0]];
5078
5079 let first =
5080 gaussian_reml_multi_closed_form(x.view(), y.view(), penalty_a.view(), None, Some(0.0))
5081 .expect("first fit");
5082 let warm_start = GaussianRemlWarmStart::from_multi_result(&first);
5083 let err = gaussian_reml_multi_closed_form_warm_started(
5084 x.view(),
5085 y.view(),
5086 penalty_b.view(),
5087 None,
5088 Some(&warm_start),
5089 )
5090 .expect_err("penalty-mismatched cache must be rejected");
5091
5092 assert!(err.to_string().contains("penalty mismatch"));
5093 }
5094
5095 #[test]
5096 fn no_alloc_cache_path_matches_allocating_fit() {
5097 let x = array![
5098 [1.0, -1.0, 0.25],
5099 [1.0, -0.5, 0.10],
5100 [1.0, 0.0, -0.20],
5101 [1.0, 0.5, -0.05],
5102 [1.0, 1.0, 0.30],
5103 [1.0, 1.5, 0.60],
5104 ];
5105 let y = array![
5106 [0.0, 0.2],
5107 [0.3, 0.1],
5108 [0.4, -0.1],
5109 [0.9, 0.3],
5110 [1.6, 0.8],
5111 [2.2, 1.2],
5112 ];
5113 let weights = array![1.0, 0.8, 1.2, 1.1, 0.9, 1.3];
5114 let penalty = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 4.0]];
5115
5116 let allocating = gaussian_reml_multi_closed_form_with_cache(
5117 x.view(),
5118 y.view(),
5119 penalty.view(),
5120 Some(weights.view()),
5121 Some(1.0),
5122 None,
5123 )
5124 .expect("allocating fit");
5125 let mut workspace = GaussianRemlNoAllocWorkspace::new(x.ncols(), y.ncols());
5126 let mut coefficients = Array2::zeros((x.ncols(), y.ncols()));
5127 let mut fitted = Array2::zeros(y.dim());
5128 let mut sigma2 = Array1::zeros(y.ncols());
5129
5130 let no_alloc = gaussian_reml_multi_closed_form_with_cache_no_alloc(
5131 x.view(),
5132 y.view(),
5133 penalty.view(),
5134 Some(weights.view()),
5135 Some(allocating.lambda),
5136 &allocating.cache,
5137 &mut workspace,
5138 coefficients.view_mut(),
5139 fitted.view_mut(),
5140 sigma2.view_mut(),
5141 )
5142 .expect("no-alloc cached fit");
5143
5144 assert!((no_alloc.lambda - allocating.lambda).abs() <= 1.0e-10);
5145 assert!((no_alloc.reml_score - allocating.reml_score).abs() <= 1.0e-8);
5146 assert!((no_alloc.reml_grad_rho - allocating.reml_grad_rho).abs() <= 1.0e-8);
5147 assert!((no_alloc.reml_hess_rho - allocating.reml_hess_rho).abs() <= 1.0e-8);
5148 assert!((no_alloc.edf - allocating.edf).abs() <= 1.0e-10);
5149 for i in 0..x.ncols() {
5150 for j in 0..y.ncols() {
5151 assert!((coefficients[[i, j]] - allocating.coefficients[[i, j]]).abs() <= 1.0e-8);
5152 }
5153 }
5154 for i in 0..x.nrows() {
5155 for j in 0..y.ncols() {
5156 assert!((fitted[[i, j]] - allocating.fitted[[i, j]]).abs() <= 1.0e-8);
5157 }
5158 }
5159 for j in 0..y.ncols() {
5160 assert!((sigma2[j] - allocating.sigma2[j]).abs() <= 1.0e-10);
5161 }
5162 }
5163
5164 #[test]
5165 fn no_alloc_cache_path_rejects_bad_shapes_and_penalty_mismatch() {
5166 let x = array![[1.0, -1.0], [1.0, 0.0], [1.0, 1.0], [1.0, 2.0]];
5167 let y = array![[0.0], [0.2], [0.9], [1.8]];
5168 let penalty = array![[0.0, 0.0], [0.0, 1.0]];
5169 let cache = build_gaussian_reml_eigen_cache(x.view(), penalty.view(), None)
5170 .expect("Gaussian REML cache");
5171
5172 let mut bad_workspace = GaussianRemlNoAllocWorkspace::new(x.ncols(), y.ncols() + 1);
5173 let mut coefficients = Array2::zeros((x.ncols(), y.ncols()));
5174 let mut fitted = Array2::zeros(y.dim());
5175 let mut sigma2 = Array1::zeros(y.ncols());
5176 let err = gaussian_reml_multi_closed_form_with_cache_no_alloc(
5177 x.view(),
5178 y.view(),
5179 penalty.view(),
5180 None,
5181 Some(1.0),
5182 &cache,
5183 &mut bad_workspace,
5184 coefficients.view_mut(),
5185 fitted.view_mut(),
5186 sigma2.view_mut(),
5187 )
5188 .expect_err("workspace shape mismatch must be rejected");
5189 assert!(err.to_string().contains("workspace shape mismatch"));
5190
5191 let penalty_mismatch = array![[1.0, -1.0], [-1.0, 1.0]];
5192 let mut workspace = GaussianRemlNoAllocWorkspace::new(x.ncols(), y.ncols());
5193 let err = gaussian_reml_multi_closed_form_with_cache_no_alloc(
5194 x.view(),
5195 y.view(),
5196 penalty_mismatch.view(),
5197 None,
5198 Some(1.0),
5199 &cache,
5200 &mut workspace,
5201 coefficients.view_mut(),
5202 fitted.view_mut(),
5203 sigma2.view_mut(),
5204 )
5205 .expect_err("penalty mismatch must be rejected");
5206 assert!(err.to_string().contains("penalty mismatch"));
5207 }
5208
5209 #[derive(Clone, Copy, Debug)]
5210 enum ForwardScalar {
5211 Lambda,
5212 RemlScore,
5213 Coefficient(usize, usize),
5214 Fitted(usize, usize),
5215 Edf,
5216 }
5217
5218 fn finite_difference_design() -> Array2<f64> {
5219 Array2::from_shape_fn((20, 5), |(row, col)| {
5220 let t = (row as f64 - 9.5) / 10.0;
5221 match col {
5222 0 => 1.0,
5223 1 => t,
5224 2 => 0.5 * (3.0 * t * t - 1.0),
5225 3 => 0.5 * (5.0 * t * t * t - 3.0 * t),
5226 4 => (35.0 * t.powi(4) - 30.0 * t * t + 3.0) / 8.0,
5227 _ => unreachable!(),
5228 }
5229 })
5230 }
5231
5232 fn finite_difference_response(outputs: usize) -> Array2<f64> {
5233 Array2::from_shape_fn((20, outputs), |(row, output)| {
5244 let t = (row as f64 - 9.5) / 10.0;
5245 let phase = output as f64 + 1.0;
5246 0.2 + 0.25 * phase * t - 0.12 * t * t
5247 + (0.08 + 0.03 * phase) * (1.1 * t + 0.3 * phase).sin()
5248 + 0.05 * (7.0 * t + 0.5 * phase).sin()
5249 })
5250 }
5251
5252 fn finite_difference_penalty() -> Array2<f64> {
5253 Array2::from_diag(&array![0.0, 0.8, 1.2, 1.7, 2.3])
5254 }
5255
5256 fn finite_difference_weights() -> Array1<f64> {
5257 Array1::from_shape_fn(20, |row| {
5258 let t = (row as f64 - 9.5) / 10.0;
5259 1.0 + 0.025 * (1.1 * t).sin() + 0.01 * t
5260 })
5261 }
5262
5263 fn one_hot_objective_try(
5270 x: ArrayView2<'_, f64>,
5271 y: ArrayView2<'_, f64>,
5272 penalty: ArrayView2<'_, f64>,
5273 weights: ArrayView1<'_, f64>,
5274 target: ForwardScalar,
5275 ) -> Option<f64> {
5276 let fit = gaussian_reml_multi_closed_form_with_cache(
5277 x,
5278 y,
5279 penalty,
5280 Some(weights),
5281 Some(0.85),
5282 None,
5283 )
5284 .ok()?;
5285 Some(match target {
5286 ForwardScalar::Lambda => fit.lambda,
5287 ForwardScalar::RemlScore => fit.reml_score,
5288 ForwardScalar::Coefficient(row, col) => fit.coefficients[[row, col]],
5289 ForwardScalar::Fitted(row, col) => fit.fitted[[row, col]],
5290 ForwardScalar::Edf => fit.edf,
5291 })
5292 }
5293
5294 fn one_hot_objective(
5295 x: ArrayView2<'_, f64>,
5296 y: ArrayView2<'_, f64>,
5297 penalty: ArrayView2<'_, f64>,
5298 weights: ArrayView1<'_, f64>,
5299 target: ForwardScalar,
5300 ) -> f64 {
5301 one_hot_objective_try(x, y, penalty, weights, target)
5302 .expect("finite-difference forward fit")
5303 }
5304
5305 fn one_hot_backward(
5306 x: ArrayView2<'_, f64>,
5307 y: ArrayView2<'_, f64>,
5308 penalty: ArrayView2<'_, f64>,
5309 weights: ArrayView1<'_, f64>,
5310 target: ForwardScalar,
5311 ) -> GaussianRemlBackwardResult {
5312 let mut grad_coefficients = Array2::<f64>::zeros((x.ncols(), y.ncols()));
5313 let mut grad_fitted = Array2::<f64>::zeros(y.dim());
5314 let (grad_lambda, grad_score, grad_edf, coefficient_upstream, fitted_upstream) =
5315 match target {
5316 ForwardScalar::Lambda => (1.0, 0.0, 0.0, None, None),
5317 ForwardScalar::RemlScore => (0.0, 1.0, 0.0, None, None),
5318 ForwardScalar::Coefficient(row, col) => {
5319 grad_coefficients[[row, col]] = 1.0;
5320 (0.0, 0.0, 0.0, Some(grad_coefficients.view()), None)
5321 }
5322 ForwardScalar::Fitted(row, col) => {
5323 grad_fitted[[row, col]] = 1.0;
5324 (0.0, 0.0, 0.0, None, Some(grad_fitted.view()))
5325 }
5326 ForwardScalar::Edf => (0.0, 0.0, 1.0, None, None),
5327 };
5328 gaussian_reml_multi_closed_form_backward(
5329 x,
5330 y,
5331 penalty,
5332 Some(weights),
5333 Some(0.85),
5334 grad_lambda,
5335 coefficient_upstream,
5336 fitted_upstream,
5337 grad_score,
5338 grad_edf,
5339 )
5340 .expect("analytic backward VJP")
5341 }
5342
5343 fn assert_fd_close(label: &str, analytic: f64, finite_difference: f64) {
5344 let rel_tol = 1.0e-6_f64;
5345 let abs_tol = 1.0e-6_f64;
5346 let tol = abs_tol.max(rel_tol * analytic.abs().max(finite_difference.abs()));
5347 let diff = (analytic - finite_difference).abs();
5348 assert!(
5349 diff <= tol,
5350 "{label}: analytic={analytic:.12e}, finite_difference={finite_difference:.12e}, diff={diff:.3e}, tol={tol:.3e}"
5351 );
5352 }
5353
5354 fn adaptive_central_difference(mut eval: impl FnMut(f64) -> f64) -> f64 {
5355 let steps: [f64; 5] = [1.0e-3, 5.0e-4, 2.5e-4, 1.25e-4, 6.25e-5];
5356 let mut best = f64::NAN;
5357 let mut best_delta = f64::INFINITY;
5358 let mut previous: Option<f64> = None;
5359 for h in steps {
5360 let d1 = (eval(h) - eval(-h)) / (2.0 * h);
5361 let half_h = 0.5 * h;
5362 let d2 = (eval(half_h) - eval(-half_h)) / (2.0 * half_h);
5363 let estimate: f64 = d2 + (d2 - d1) / 3.0;
5364 if let Some(prev) = previous {
5365 let delta = (estimate - prev).abs();
5366 if delta < best_delta {
5367 best_delta = delta;
5368 best = estimate;
5369 }
5370 } else {
5371 best = estimate;
5372 }
5373 previous = Some(estimate);
5374 }
5375 best
5376 }
5377
5378 fn assert_backward_matches_forward_finite_difference(outputs: usize) {
5379 let x = finite_difference_design();
5380 let y = finite_difference_response(outputs);
5381 let penalty = finite_difference_penalty();
5382 let weights = finite_difference_weights();
5383 let targets = [
5384 ForwardScalar::Lambda,
5385 ForwardScalar::RemlScore,
5386 ForwardScalar::Coefficient(3, outputs - 1),
5387 ForwardScalar::Fitted(12, outputs - 1),
5388 ForwardScalar::Edf,
5389 ];
5390 for target in targets {
5391 let backward =
5392 one_hot_backward(x.view(), y.view(), penalty.view(), weights.view(), target);
5393
5394 for row in 0..x.nrows() {
5395 for col in 0..x.ncols() {
5396 let eval = |delta: f64| {
5397 let mut candidate = x.clone();
5398 candidate[[row, col]] += delta;
5399 one_hot_objective(
5400 candidate.view(),
5401 y.view(),
5402 penalty.view(),
5403 weights.view(),
5404 target,
5405 )
5406 };
5407 let fd = adaptive_central_difference(eval);
5408 assert_fd_close(
5409 &format!("target={target:?} x[{row},{col}]"),
5410 backward.grad_x[[row, col]],
5411 fd,
5412 );
5413 }
5414 }
5415
5416 for row in 0..y.nrows() {
5417 for col in 0..y.ncols() {
5418 let eval = |delta: f64| {
5419 let mut candidate = y.clone();
5420 candidate[[row, col]] += delta;
5421 one_hot_objective(
5422 x.view(),
5423 candidate.view(),
5424 penalty.view(),
5425 weights.view(),
5426 target,
5427 )
5428 };
5429 let fd = adaptive_central_difference(eval);
5430 assert_fd_close(
5431 &format!("target={target:?} y[{row},{col}]"),
5432 backward.grad_y[[row, col]],
5433 fd,
5434 );
5435 }
5436 }
5437
5438 for row in 0..weights.len() {
5439 let eval = |delta: f64| {
5440 let mut candidate = weights.clone();
5441 candidate[row] += delta;
5442 one_hot_objective(x.view(), y.view(), penalty.view(), candidate.view(), target)
5443 };
5444 let fd = adaptive_central_difference(eval);
5445 assert_fd_close(
5446 &format!("target={target:?} weights[{row}]"),
5447 backward.grad_weights[row],
5448 fd,
5449 );
5450 }
5451
5452 let null_index = 0usize; let probe_h = 1.0e-3_f64; for r in 0..penalty.nrows() {
5476 for c in 0..penalty.ncols() {
5477 if r == null_index || c == null_index {
5478 continue;
5479 }
5480 let eval = |delta: f64| {
5481 let mut candidate = penalty.clone();
5482 candidate[[r, c]] += delta;
5483 one_hot_objective(
5484 x.view(),
5485 y.view(),
5486 candidate.view(),
5487 weights.view(),
5488 target,
5489 )
5490 };
5491 let cone_safe = {
5492 let mut s_plus = penalty.clone();
5493 let mut s_minus = penalty.clone();
5494 s_plus[[r, c]] += probe_h;
5495 s_minus[[r, c]] -= probe_h;
5496 one_hot_objective_try(
5497 x.view(),
5498 y.view(),
5499 s_plus.view(),
5500 weights.view(),
5501 target,
5502 )
5503 .is_some()
5504 && one_hot_objective_try(
5505 x.view(),
5506 y.view(),
5507 s_minus.view(),
5508 weights.view(),
5509 target,
5510 )
5511 .is_some()
5512 };
5513 if !cone_safe {
5514 continue;
5515 }
5516 let fd = adaptive_central_difference(eval);
5517 assert_fd_close(
5518 &format!("target={target:?} penalty[{r},{c}]"),
5519 backward.grad_penalty[[r, c]],
5520 fd,
5521 );
5522 }
5523 }
5524 }
5525 }
5526
5527 #[test]
5528 fn scalar_backward_matches_forward_finite_difference_for_all_x_y_and_weight_entries() {
5529 assert_backward_matches_forward_finite_difference(1);
5530 }
5531
5532 #[test]
5533 fn multi_output_backward_matches_forward_finite_difference_for_all_x_y_and_weight_entries() {
5534 assert_backward_matches_forward_finite_difference(3);
5535 }
5536
5537 #[test]
5538 fn backward_vjp_matches_finite_difference() {
5539 let x = array![
5540 [1.0, -1.0, 0.2],
5541 [1.0, -0.3, -0.1],
5542 [1.0, 0.2, 0.4],
5543 [1.0, 0.8, 0.1],
5544 [1.0, 1.4, 0.5],
5545 [1.0, 2.0, 0.9],
5546 ];
5547 let y = array![
5548 [0.1, -0.2],
5549 [0.2, 0.1],
5550 [0.7, 0.0],
5551 [1.1, 0.3],
5552 [1.8, 0.9],
5553 [2.4, 1.4],
5554 ];
5555 let weights = array![1.0, 0.9, 1.1, 1.2, 0.8, 1.3];
5556 let penalty = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.2], [0.0, 0.2, 1.7]];
5557 let upstream_coefficients = array![[0.2, -0.1], [0.05, 0.03], [-0.04, 0.07]];
5558 let upstream_fitted = array![
5559 [0.01, -0.02],
5560 [0.03, 0.01],
5561 [-0.01, 0.02],
5562 [0.04, -0.03],
5563 [0.02, 0.05],
5564 [-0.02, 0.01],
5565 ];
5566 let upstream_lambda = 0.17;
5567 let upstream_score = -0.11;
5568
5569 let backward = gaussian_reml_multi_closed_form_backward(
5570 x.view(),
5571 y.view(),
5572 penalty.view(),
5573 Some(weights.view()),
5574 Some(0.8),
5575 upstream_lambda,
5576 Some(upstream_coefficients.view()),
5577 Some(upstream_fitted.view()),
5578 upstream_score,
5579 0.0,
5580 )
5581 .expect("backward VJP");
5582
5583 let objective = |x_eval: &Array2<f64>, y_eval: &Array2<f64>, w_eval: &Array1<f64>| {
5584 let fit = gaussian_reml_multi_closed_form_with_cache(
5585 x_eval.view(),
5586 y_eval.view(),
5587 penalty.view(),
5588 Some(w_eval.view()),
5589 Some(0.8),
5590 None,
5591 )
5592 .expect("fit for objective");
5593 upstream_lambda * fit.lambda
5594 + upstream_score * fit.reml_score
5595 + (&fit.coefficients * &upstream_coefficients).sum()
5596 + (&fit.fitted * &upstream_fitted).sum()
5597 };
5598 let eps = 1.0e-6;
5599 assert!(objective(&x, &y, &weights).is_finite());
5600
5601 let mut x_plus = x.clone();
5602 let mut x_minus = x.clone();
5603 x_plus[[3, 2]] += eps;
5604 x_minus[[3, 2]] -= eps;
5605 let fd_x =
5606 (objective(&x_plus, &y, &weights) - objective(&x_minus, &y, &weights)) / (2.0 * eps);
5607 assert!(
5608 (fd_x - backward.grad_x[[3, 2]]).abs() <= 2.0e-4,
5609 "grad_x mismatch: analytic={} fd={}",
5610 backward.grad_x[[3, 2]],
5611 fd_x
5612 );
5613
5614 let mut y_plus = y.clone();
5615 let mut y_minus = y.clone();
5616 y_plus[[4, 1]] += eps;
5617 y_minus[[4, 1]] -= eps;
5618 let fd_y =
5619 (objective(&x, &y_plus, &weights) - objective(&x, &y_minus, &weights)) / (2.0 * eps);
5620 assert!(
5621 (fd_y - backward.grad_y[[4, 1]]).abs() <= 2.0e-4,
5622 "grad_y mismatch: analytic={} fd={}",
5623 backward.grad_y[[4, 1]],
5624 fd_y
5625 );
5626
5627 let mut w_plus = weights.clone();
5628 let mut w_minus = weights.clone();
5629 w_plus[2] += eps;
5630 w_minus[2] -= eps;
5631 let fd_w = (objective(&x, &y, &w_plus) - objective(&x, &y, &w_minus)) / (2.0 * eps);
5632 assert!(
5633 (fd_w - backward.grad_weights[2]).abs() <= 2.0e-4,
5634 "grad_weight mismatch: analytic={} fd={}",
5635 backward.grad_weights[2],
5636 fd_w
5637 );
5638
5639 let objective_s = |s_eval: &Array2<f64>| {
5651 let fit = gaussian_reml_multi_closed_form_with_cache(
5652 x.view(),
5653 y.view(),
5654 s_eval.view(),
5655 Some(weights.view()),
5656 Some(0.8),
5657 None,
5658 )
5659 .expect("fit for penalty objective");
5660 upstream_lambda * fit.lambda
5661 + upstream_score * fit.reml_score
5662 + (&fit.coefficients * &upstream_coefficients).sum()
5663 + (&fit.fitted * &upstream_fitted).sum()
5664 };
5665 for (r, c) in [(1usize, 1usize), (1, 2), (2, 2)] {
5669 let mut s_plus = penalty.clone();
5670 let mut s_minus = penalty.clone();
5671 s_plus[[r, c]] += eps;
5672 s_minus[[r, c]] -= eps;
5673 let fd_s = (objective_s(&s_plus) - objective_s(&s_minus)) / (2.0 * eps);
5674 assert!(
5675 (fd_s - backward.grad_penalty[[r, c]]).abs() <= 2.0e-4,
5676 "grad_penalty[{r},{c}] mismatch: analytic={} fd={}",
5677 backward.grad_penalty[[r, c]],
5678 fd_s
5679 );
5680 }
5681 }
5682
5683 #[test]
5684 fn batched_eigen_cache_matches_per_fit_build() {
5685 let xtwx_a = array![[4.0, 1.0], [1.0, 3.0]];
5691 let xtwx_b = array![[2.5, -0.5], [-0.5, 1.7]];
5692 let xtwx_c = array![[7.2, 0.3], [0.3, 5.1]];
5693 let penalty = array![[0.0, 0.0], [0.0, 1.0]];
5694
5695 let batched = build_gaussian_reml_eigen_cache_batched(
5696 vec![xtwx_a.clone(), xtwx_b.clone(), xtwx_c.clone()],
5697 penalty.view(),
5698 None,
5699 );
5700 assert_eq!(batched.len(), 3);
5701
5702 for (xtwx, batched_cache) in [&xtwx_a, &xtwx_b, &xtwx_c].into_iter().zip(batched.iter()) {
5703 let single = gaussian_reml_eigen_cache_from_xtwx(xtwx.clone(), penalty.view(), None)
5704 .expect("per-fit cache");
5705 let batched_cache = batched_cache.as_ref().expect("batched cache");
5706 assert_eq!(batched_cache.penalty_rank, single.penalty_rank);
5707 assert_eq!(batched_cache.nullity, single.nullity);
5708 assert_eq!(batched_cache.xtwx_fingerprint, single.xtwx_fingerprint);
5709 assert_eq!(
5710 batched_cache.penalty_fingerprint,
5711 single.penalty_fingerprint
5712 );
5713 assert!((batched_cache.logdet_xtwx - single.logdet_xtwx).abs() <= 1.0e-12);
5714 assert!(
5715 (batched_cache.logdet_penalty_positive - single.logdet_penalty_positive).abs()
5716 <= 1.0e-12
5717 );
5718 for (a, b) in batched_cache
5719 .penalty_eigenvalues
5720 .iter()
5721 .zip(single.penalty_eigenvalues.iter())
5722 {
5723 assert!((a - b).abs() <= 1.0e-12);
5724 }
5725 for ((a, b), _) in batched_cache
5726 .coefficient_basis
5727 .iter()
5728 .zip(single.coefficient_basis.iter())
5729 .zip(0..)
5730 {
5731 assert!((a - b).abs() <= 1.0e-12);
5732 }
5733 }
5734 }
5735
5736 #[test]
5737 fn scalar_rho_optimizer_chooses_lowest_cost_stationary_point() {
5738 let cache = GaussianRemlEigenCache {
5739 penalty_eigenvalues: array![5.2430192311066924e-05, 81734184.18548436],
5740 eigenvectors: Array2::eye(2),
5741 coefficient_basis: Array2::eye(2),
5742 xtwx_fingerprint: 0,
5743 penalty_fingerprint: 0,
5744 logdet_xtwx: 0.0,
5745 logdet_penalty_positive: 0.0,
5746 penalty_rank: 2,
5747 nullity: 0,
5748 };
5749 let prepared = GaussianRemlPrepared {
5750 cache: cache.clone(),
5751 ywy: array![0.5021347226586624],
5752 projected_rhs_squared: array![[0.361060218768292], [0.01014486085547482]],
5753 projected_rhs: array![
5754 [0.361060218768292_f64.sqrt()],
5755 [0.01014486085547482_f64.sqrt()]
5756 ],
5757 n_effective: 100,
5758 n_outputs: 1,
5759 };
5760
5761 let rho = optimize_rho(&prepared, None).expect("allocating rho optimizer");
5762 let no_alloc_rho = optimize_rho_no_alloc(
5763 &cache,
5764 prepared.ywy.view(),
5765 prepared.projected_rhs_squared.view(),
5766 prepared.n_effective,
5767 prepared.n_outputs,
5768 None,
5769 )
5770 .expect("no-alloc rho optimizer");
5771
5772 assert!(
5773 (rho - 4.3251059890).abs() < 1.0e-6,
5774 "rho optimizer selected {rho}, expected the lower-cost later stationary point"
5775 );
5776 assert_eq!(
5780 no_alloc_rho, rho,
5781 "no-alloc optimizer selected {no_alloc_rho}, allocating selected {rho}"
5782 );
5783 assert!(prepared.evaluate(rho).cost < prepared.evaluate(-18.9277503549).cost);
5784 }
5785
5786 struct Lcg(u64);
5789 impl Lcg {
5790 fn new(seed: u64) -> Self {
5791 Lcg(seed)
5792 }
5793 fn next_u64(&mut self) -> u64 {
5794 self.0 = self
5795 .0
5796 .wrapping_mul(6364136223846793005)
5797 .wrapping_add(1442695040888963407);
5798 self.0
5799 }
5800 fn unit(&mut self) -> f64 {
5801 (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
5802 }
5803 fn range(&mut self, lo: f64, hi: f64) -> f64 {
5804 lo + (hi - lo) * self.unit()
5805 }
5806 }
5807
5808 fn synthetic_cache(eigs: &[f64]) -> GaussianRemlEigenCache {
5813 let n = eigs.len();
5814 let rank = eigs.iter().filter(|&&delta| delta > 0.0).count();
5815 GaussianRemlEigenCache {
5816 penalty_eigenvalues: Array1::from(eigs.to_vec()),
5817 eigenvectors: Array2::eye(n),
5818 coefficient_basis: Array2::eye(n),
5819 xtwx_fingerprint: 0,
5820 penalty_fingerprint: 0,
5821 logdet_xtwx: 0.0,
5822 logdet_penalty_positive: 0.0,
5823 penalty_rank: rank,
5824 nullity: n - rank,
5825 }
5826 }
5827
5828 #[test]
5837 fn profiled_one_mode_certificate_matches_analytic_root_and_ignores_seed_as_candidate() {
5838 let delta = 4.0;
5839 let q = 2.0;
5840 let irreducible_residual = 3.0;
5841 let n_effective = 10usize;
5842 let cache = synthetic_cache(&[delta]);
5843 let ywy = array![q + irreducible_residual];
5844 let projected = array![[q]];
5845 let eval = |rho: f64| {
5846 evaluate_reml_parts(&cache, ywy.view(), projected.view(), n_effective, 1, rho)
5847 };
5848 let enclose = |a: f64, b: f64| {
5849 reml_deriv_enclosure(&cache, ywy.view(), projected.view(), n_effective, 1, a, b)
5850 };
5851 let expected_t =
5852 irreducible_residual / (((n_effective - 1) as f64) * q - irreducible_residual);
5853 let expected_rho = (expected_t / delta).ln();
5854 let mut roots = Vec::new();
5855 let selection =
5856 enumerate_and_select_rho(&eval, &enclose, Some(-20.0), |root, _| roots.push(root))
5857 .expect("profile certificate");
5858
5859 assert_eq!(roots.len(), 1, "unexpected stationary set");
5860 assert!(
5861 roots[0].bracket[0] <= expected_rho && expected_rho <= roots[0].bracket[1],
5862 "analytic root {expected_rho} outside certified bracket {:?}",
5863 roots[0].bracket
5864 );
5865 assert!(
5866 (selection.rho - expected_rho).abs()
5867 <= RHO_BRACKET_RESOLUTION * (1.0 + expected_rho.abs()),
5868 "selected rho {} differs from analytic profiled root {expected_rho}",
5869 selection.rho
5870 );
5871 assert_ne!(
5872 selection.rho.to_bits(),
5873 (-20.0_f64).to_bits(),
5874 "a nonstationary warm hint must never enter the objective argmin"
5875 );
5876 }
5877
5878 #[test]
5879 fn unresolved_stationary_structure_is_a_typed_refusal() {
5880 let eval = |rho: f64| ObjectiveEval {
5881 cost: rho * rho,
5882 grad: 2.0 * rho,
5883 hess: 2.0,
5884 edf: 0.0,
5885 };
5886 let enclose = |_a: f64, _b: f64| (Interval::entire(), Interval::entire());
5889 let error = enumerate_and_select_rho_with_controls(
5890 eval,
5891 enclose,
5892 None,
5893 ProfileSearchControls {
5894 lower: -1.0,
5895 upper: 1.0,
5896 resolution: 0.25,
5897 max_depth: 0,
5898 },
5899 |_root, _eval| {},
5900 )
5901 .expect_err("ambiguous stationary structure must refuse");
5902 assert!(matches!(error, EstimationError::RemlDidNotConverge { .. }));
5903 }
5904
5905 #[test]
5906 fn profiled_modal_evaluation_is_finite_beyond_exp_range() {
5907 let cache = synthetic_cache(&[4.0]);
5908 let ywy = array![5.0];
5909 let projected = array![[2.0]];
5910 for rho in [-1_000.0, 1_000.0] {
5911 let mode = modal_kernels(rho, 4.0);
5912 assert!(mode.log_one_plus_t.is_finite());
5913 assert!(mode.u.is_finite());
5914 assert!(mode.v.is_finite());
5915 assert!(mode.w.is_finite());
5916 assert!(mode.k.is_finite());
5917 let value = evaluate_reml_parts(&cache, ywy.view(), projected.view(), 10, 1, rho);
5918 assert!(value.cost.is_finite(), "non-finite cost at rho={rho}");
5919 assert!(value.grad.is_finite(), "non-finite gradient at rho={rho}");
5920 assert!(value.hess.is_finite(), "non-finite Hessian at rho={rho}");
5921 }
5922 }
5923
5924 #[test]
5929 fn landscape_certificate_classifies_small_designs() {
5930 let one_mode: &[(f64, f64, f64)] = &[(4.0, 2.0, 3.0), (0.5, 1.5, 2.0), (9.0, 0.8, 1.2)];
5932 for &(delta, q, resid) in one_mode {
5933 let cache = synthetic_cache(&[delta]);
5934 let ywy = array![q + resid];
5935 let prs = array![[q]];
5936 let n_eff = 12usize;
5937 let cert = rho_landscape_certificate_from_parts(
5938 &cache,
5939 ywy.view(),
5940 prs.view(),
5941 n_eff,
5942 1,
5943 None,
5944 )
5945 .expect("one-mode certificate");
5946 assert_eq!(cert.stationary_count, 1);
5947 assert_eq!(cert.landscape, RhoLandscape::UniqueInterior);
5948 assert_eq!(cert.root_brackets.len(), cert.stationary_count);
5949 }
5950
5951 let cache = synthetic_cache(&[0.5, 3.0]);
5953 let prs = array![[1.0], [0.4]];
5954 let ywy = array![1.0 + 0.4 + 1.5];
5955 let cert =
5956 rho_landscape_certificate_from_parts(&cache, ywy.view(), prs.view(), 30, 1, None)
5957 .expect("two-mode certificate");
5958 assert_eq!(cert.root_brackets.len(), cert.stationary_count);
5959 }
5960
5961 #[test]
5967 fn compactified_limit_cost_selects_boundary_when_no_interior_stationary_point() {
5968 let cache = synthetic_cache(&[1.0, 2.0]);
5969 let prs = array![[0.0], [0.0]];
5970 let ywy = array![1.0];
5971 let cert =
5972 rho_landscape_certificate_from_parts(&cache, ywy.view(), prs.view(), 20, 1, None)
5973 .expect("monotone certificate");
5974 assert_eq!(cert.stationary_count, 0);
5975 assert_eq!(cert.landscape, RhoLandscape::NoInteriorOptimum);
5976 assert!(cert.boundary_optimum);
5977 assert!(
5978 cert.limit_costs[0].is_infinite() && cert.limit_costs[0] > 0.0,
5979 "rho->-inf cost must diverge to +inf, got {}",
5980 cert.limit_costs[0]
5981 );
5982 assert!(
5983 cert.limit_costs[1].is_finite(),
5984 "rho->+inf limit cost must be finite, got {}",
5985 cert.limit_costs[1]
5986 );
5987 assert!(
5988 cert.limit_costs[1] < cert.window_costs[0],
5989 "large-λ boundary cost {} must undercut the small-λ endpoint {}",
5990 cert.limit_costs[1],
5991 cert.window_costs[0]
5992 );
5993 }
5994
5995 #[test]
5998 fn selected_rho_beats_every_certified_profile_candidate() {
5999 let mut rng = Lcg::new(0x9911_7733_5522_0044);
6000 for _case in 0..40 {
6001 let n_eig = 2 + (rng.next_u64() % 4) as usize;
6002 let eigs: Vec<f64> = (0..n_eig).map(|_| rng.range(-5.0, 6.0).exp()).collect();
6003 let cache = synthetic_cache(&eigs);
6004 let c2: Vec<f64> = (0..n_eig)
6005 .map(|_| {
6006 let v = rng.range(0.0, 2.5);
6007 v * v
6008 })
6009 .collect();
6010 let sum_c2: f64 = c2.iter().sum();
6011 let prs = Array2::from_shape_vec((n_eig, 1), c2).unwrap();
6012 let ywy = Array1::from(vec![sum_c2 + rng.range(0.05, 2.0)]);
6013 let n_eff = 80usize;
6014 let n_out = 1usize;
6015
6016 let eval =
6017 |rho: f64| evaluate_reml_parts(&cache, ywy.view(), prs.view(), n_eff, n_out, rho);
6018 let enclose = |a: f64, b: f64| {
6019 reml_deriv_enclosure(&cache, ywy.view(), prs.view(), n_eff, n_out, a, b)
6020 };
6021 let mut roots = Vec::new();
6022 let selection =
6023 enumerate_and_select_rho(&eval, &enclose, None, |root, _| roots.push(root.rho))
6024 .unwrap();
6025 let selected = selection.rho;
6026 let selected_cost = eval(selected).cost;
6027 let tol = 1.0e-8 * (1.0 + selected_cost.abs());
6028
6029 for &r in &roots {
6030 assert!(selected_cost <= eval(r).cost + tol);
6031 }
6032 assert!(selected_cost <= eval(RHO_LOWER).cost + tol);
6033 assert!(selected_cost <= eval(RHO_UPPER).cost + tol);
6034 }
6035 }
6036
6037 #[test]
6038 fn backward_from_fit_matches_backward_with_refit() {
6039 let x = array![[1.0, -0.9], [1.0, -0.4], [1.0, 0.1], [1.0, 0.6], [1.0, 1.1],];
6044 let y = array![[0.2, -0.1], [0.4, 0.1], [0.7, 0.3], [1.0, 0.5], [1.5, 0.8]];
6045 let penalty = array![[0.0, 0.0], [0.0, 1.5]];
6046 let weights = array![1.05, 0.95, 1.01, 0.99, 1.03];
6047
6048 let refit = gaussian_reml_multi_closed_form_backward(
6049 x.view(),
6050 y.view(),
6051 penalty.view(),
6052 Some(weights.view()),
6053 Some(0.85),
6054 0.2,
6055 None,
6056 None,
6057 -0.1,
6058 0.0,
6059 )
6060 .expect("refit backward");
6061
6062 let fit = gaussian_reml_multi_closed_form_with_cache(
6063 x.view(),
6064 y.view(),
6065 penalty.view(),
6066 Some(weights.view()),
6067 Some(0.85),
6068 None,
6069 )
6070 .expect("forward fit");
6071 let from_fit = gaussian_reml_multi_closed_form_backward_from_fit(
6072 x.view(),
6073 y.view(),
6074 penalty.view(),
6075 Some(weights.view()),
6076 &fit,
6077 0.2,
6078 None,
6079 None,
6080 -0.1,
6081 0.0,
6082 )
6083 .expect("from_fit backward");
6084
6085 for (a, b) in refit.grad_x.iter().zip(from_fit.grad_x.iter()) {
6086 assert!((a - b).abs() <= 1.0e-12);
6087 }
6088 for (a, b) in refit.grad_y.iter().zip(from_fit.grad_y.iter()) {
6089 assert!((a - b).abs() <= 1.0e-12);
6090 }
6091 for (a, b) in refit.grad_weights.iter().zip(from_fit.grad_weights.iter()) {
6092 assert!((a - b).abs() <= 1.0e-12);
6093 }
6094 }
6095
6096 #[test]
6106 fn backward_degrades_gracefully_when_k_is_near_singular() {
6107 let x = array![
6111 [1.0, -1.0, 0.5],
6112 [1.0, -0.5, 0.2],
6113 [1.0, 0.0, -0.1],
6114 [1.0, 0.5, 0.3],
6115 [1.0, 1.0, 0.8],
6116 [1.0, 1.5, 1.1],
6117 [1.0, 2.0, 1.5],
6118 [1.0, 2.5, 2.0],
6119 [1.0, 3.0, 2.6],
6120 [1.0, 3.5, 3.1],
6121 ];
6122 let y = array![
6123 [0.1],
6124 [0.3],
6125 [0.4],
6126 [0.7],
6127 [1.0],
6128 [1.5],
6129 [2.0],
6130 [2.7],
6131 [3.3],
6132 [4.0]
6133 ];
6134 let penalty = array![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
6136
6137 let mut fit =
6138 gaussian_reml_multi_closed_form(x.view(), y.view(), penalty.view(), None, Some(0.0))
6139 .expect("forward fit must succeed for well-posed input");
6140 fit.reml_hess_rho = 0.0;
6144
6145 let result = gaussian_reml_multi_closed_form_backward_from_fit(
6146 x.view(),
6147 y.view(),
6148 penalty.view(),
6149 None,
6150 &fit,
6151 1.0,
6154 None,
6155 None,
6156 1.0,
6157 1.0,
6158 )
6159 .expect("backward must NOT error on near-singular K");
6160
6161 assert_eq!(result.grad_x.dim(), (x.nrows(), x.ncols()));
6162 assert_eq!(result.grad_y.dim(), (y.nrows(), y.ncols()));
6163 assert_eq!(result.grad_penalty.dim(), (x.ncols(), x.ncols()));
6164 assert_eq!(result.grad_weights.dim(), x.nrows());
6165 for v in result.grad_x.iter() {
6166 assert!(v.is_finite(), "grad_x must be finite, got {v}");
6167 }
6168 for v in result.grad_y.iter() {
6169 assert!(v.is_finite(), "grad_y must be finite, got {v}");
6170 }
6171 for v in result.grad_penalty.iter() {
6172 assert!(v.is_finite(), "grad_penalty must be finite, got {v}");
6173 }
6174 for v in result.grad_weights.iter() {
6175 assert!(v.is_finite(), "grad_weights must be finite, got {v}");
6176 }
6177 }
6178}
6179
6180pub struct GaussianRemlBlocksBackwardAnalytic {
6184 pub grad_designs: Vec<Array2<f64>>,
6185 pub grad_penalties: Vec<Array2<f64>>,
6186 pub grad_y: Array2<f64>,
6187 pub grad_weights: Array1<f64>,
6188}
6189
6190pub fn gaussian_reml_fit_blocks_backward_analytic(
6199 designs: &[Array2<f64>],
6200 penalties_raw: &[Array2<f64>],
6201 y: ArrayView1<'_, f64>,
6202 weights: ArrayView1<'_, f64>,
6203 rhos: &[f64],
6204 grad_coefficients: Option<ArrayView2<'_, f64>>,
6205 grad_fitted: Option<ArrayView2<'_, f64>>,
6206 grad_lambdas: Option<ArrayView1<'_, f64>>,
6207 grad_log_lambdas: Option<ArrayView1<'_, f64>>,
6208 grad_reml_score: f64,
6209 grad_edf: Option<ArrayView1<'_, f64>>,
6210) -> Result<GaussianRemlBlocksBackwardAnalytic, EstimationError> {
6211 let n = y.len();
6212 let f_blocks = designs.len();
6213 let mut offsets = Vec::with_capacity(f_blocks + 1);
6214 offsets.push(0_usize);
6215 for design in designs {
6216 offsets.push(offsets.last().copied().unwrap() + design.ncols());
6217 }
6218 let p_total = *offsets.last().unwrap();
6219 if n == 0 || p_total == 0 {
6220 return Err(EstimationError::InvalidInput(
6221 "gaussian_reml_fit_blocks_backward requires non-empty rows and at least one coefficient column"
6222 .to_string(),
6223 ));
6224 }
6225
6226 if rhos.len() != f_blocks {
6227 return Err(EstimationError::InvalidInput(format!(
6228 "log_lambdas length mismatch: expected {f_blocks}, got {}",
6229 rhos.len()
6230 )));
6231 }
6232 if let Some(gc) = grad_coefficients {
6233 if gc.dim() != (p_total, 1) {
6234 return Err(EstimationError::InvalidInput(format!(
6235 "grad_coefficients shape mismatch: expected {}x1, got {}x{}",
6236 p_total,
6237 gc.nrows(),
6238 gc.ncols()
6239 )));
6240 }
6241 }
6242 if let Some(gf) = grad_fitted {
6243 if gf.dim() != (n, 1) {
6244 return Err(EstimationError::InvalidInput(format!(
6245 "grad_fitted shape mismatch: expected {}x1, got {}x{}",
6246 n,
6247 gf.nrows(),
6248 gf.ncols()
6249 )));
6250 }
6251 }
6252 if !grad_reml_score.is_finite() {
6253 return Err(EstimationError::InvalidInput(format!(
6254 "grad_reml_score must be finite; got {grad_reml_score}"
6255 )));
6256 }
6257 if let Some(vec) = grad_lambdas {
6258 if vec.len() != f_blocks {
6259 return Err(EstimationError::InvalidInput(format!(
6260 "grad_lambdas length mismatch: expected {f_blocks}, got {}",
6261 vec.len()
6262 )));
6263 }
6264 }
6265 if let Some(vec) = grad_log_lambdas {
6266 if vec.len() != f_blocks {
6267 return Err(EstimationError::InvalidInput(format!(
6268 "grad_log_lambdas length mismatch: expected {f_blocks}, got {}",
6269 vec.len()
6270 )));
6271 }
6272 }
6273 if let Some(vec) = grad_edf {
6274 if vec.len() != f_blocks {
6275 return Err(EstimationError::InvalidInput(format!(
6276 "grad_edf length mismatch: expected {f_blocks}, got {}",
6277 vec.len()
6278 )));
6279 }
6280 }
6281 if let Some(gc) = grad_coefficients {
6282 if let Some(((row, col), value)) = gc.indexed_iter().find(|(_, value)| !value.is_finite()) {
6283 return Err(EstimationError::InvalidInput(format!(
6284 "grad_coefficients[{row},{col}] must be finite; got {value}"
6285 )));
6286 }
6287 }
6288 if let Some(gf) = grad_fitted {
6289 if let Some(((row, col), value)) = gf.indexed_iter().find(|(_, value)| !value.is_finite()) {
6290 return Err(EstimationError::InvalidInput(format!(
6291 "grad_fitted[{row},{col}] must be finite; got {value}"
6292 )));
6293 }
6294 }
6295 if let Some(vec) = grad_lambdas {
6296 if let Some((block, value)) = vec.iter().enumerate().find(|(_, value)| !value.is_finite()) {
6297 return Err(EstimationError::InvalidInput(format!(
6298 "grad_lambdas[{block}] must be finite; got {value}"
6299 )));
6300 }
6301 }
6302 if let Some(vec) = grad_log_lambdas {
6303 if let Some((block, value)) = vec.iter().enumerate().find(|(_, value)| !value.is_finite()) {
6304 return Err(EstimationError::InvalidInput(format!(
6305 "grad_log_lambdas[{block}] must be finite; got {value}"
6306 )));
6307 }
6308 }
6309 if let Some(vec) = grad_edf {
6310 if let Some((block, value)) = vec.iter().enumerate().find(|(_, value)| !value.is_finite()) {
6311 return Err(EstimationError::InvalidInput(format!(
6312 "grad_edf[{block}] must be finite; got {value}"
6313 )));
6314 }
6315 }
6316 for (block, design) in designs.iter().enumerate() {
6317 if let Some(((row, col), value)) =
6318 design.indexed_iter().find(|(_, value)| !value.is_finite())
6319 {
6320 return Err(EstimationError::InvalidInput(format!(
6321 "designs[{block}][{row},{col}] must be finite; got {value}"
6322 )));
6323 }
6324 }
6325 for (block, penalty) in penalties_raw.iter().enumerate() {
6326 if let Some(((row, col), value)) =
6327 penalty.indexed_iter().find(|(_, value)| !value.is_finite())
6328 {
6329 return Err(EstimationError::InvalidInput(format!(
6330 "penalties[{block}][{row},{col}] must be finite; got {value}"
6331 )));
6332 }
6333 }
6334 if let Some((row, value)) = y.iter().enumerate().find(|(_, value)| !value.is_finite()) {
6335 return Err(EstimationError::InvalidInput(format!(
6336 "y[{row}] must be finite; got {value}"
6337 )));
6338 }
6339 if let Some((row, value)) = weights
6340 .iter()
6341 .enumerate()
6342 .find(|(_, value)| !value.is_finite() || **value < 0.0)
6343 {
6344 return Err(EstimationError::InvalidInput(format!(
6345 "weights[{row}] must be finite and non-negative; got {value}"
6346 )));
6347 }
6348
6349 let mut z = Array2::<f64>::zeros((n, p_total));
6350 for k in 0..f_blocks {
6351 z.slice_mut(s![.., offsets[k]..offsets[k + 1]])
6352 .assign(&designs[k]);
6353 }
6354
6355 let penalties: Vec<Array2<f64>> = penalties_raw
6356 .iter()
6357 .map(|p| {
6358 let mut out = p.clone();
6359 gam_linalg::matrix::symmetrize_in_place(&mut out);
6360 out
6361 })
6362 .collect();
6363 let mut ranks = Vec::with_capacity(f_blocks);
6364 let mut pinvs = Vec::with_capacity(f_blocks);
6365 for penalty in &penalties {
6366 let geometry = gam_linalg::utils::rank_certified_psd_pseudoinverse(penalty, 1.0e-10)?;
6367 ranks.push(geometry.rank());
6368 pinvs.push(geometry.into_pseudoinverse());
6369 }
6370
6371 let lambdas = Array1::from_iter(rhos.iter().map(|rho| rho.exp()));
6372 if let Some((block, lambda)) = lambdas
6373 .iter()
6374 .enumerate()
6375 .find(|(_, lambda)| !lambda.is_finite() || **lambda <= 0.0)
6376 {
6377 return Err(EstimationError::InvalidInput(format!(
6378 "exp(log_lambdas[{block}]) must be finite and positive; got {lambda}"
6379 )));
6380 }
6381 let mut k_matrix = fast_xt_diag_x(&z.view(), &weights);
6382 for block in 0..f_blocks {
6383 let lambda = lambdas[block];
6384 for local_i in 0..penalties[block].nrows() {
6385 let global_i = offsets[block] + local_i;
6386 for local_j in 0..penalties[block].ncols() {
6387 let global_j = offsets[block] + local_j;
6388 k_matrix[[global_i, global_j]] += lambda * penalties[block][[local_i, local_j]];
6389 }
6390 }
6391 }
6392 let r = gam_linalg::utils::certified_spd_inverse(
6393 &k_matrix,
6394 "block Gaussian REML penalized normal matrix",
6395 )
6396 .map(gam_linalg::utils::CertifiedSpdInverse::into_inverse)
6397 .map_err(|error| {
6398 EstimationError::InvalidInput(format!(
6399 "block Gaussian REML requires an exact SPD penalized normal matrix: {error}"
6400 ))
6401 })?;
6402
6403 let mut xtwy = Array1::<f64>::zeros(p_total);
6404 for row in 0..n {
6405 let wy = weights[row] * y[row];
6406 for col in 0..p_total {
6407 xtwy[col] += z[[row, col]] * wy;
6408 }
6409 }
6410 let beta = r.dot(&xtwy);
6411 let fitted = z.dot(&beta);
6412 if let Some((col, value)) = beta
6413 .iter()
6414 .enumerate()
6415 .find(|(_, value)| !value.is_finite())
6416 {
6417 return Err(EstimationError::InvalidInput(format!(
6418 "solved coefficient {col} is non-finite: {value}"
6419 )));
6420 }
6421 let residual = &y.to_owned() - &fitted;
6422 let weighted_residual = &residual * &weights.to_owned();
6423 let ywy = y
6424 .iter()
6425 .zip(weights.iter())
6426 .map(|(&yi, &wi)| wi * yi * yi)
6427 .sum::<f64>();
6428 let q_raw = ywy - xtwy.dot(&beta);
6429 if !q_raw.is_finite() {
6430 return Err(EstimationError::InvalidInput(format!(
6431 "Gaussian REML residual quadratic form must be finite; got {q_raw}"
6432 )));
6433 }
6434 let q = q_raw.max(1.0e-300);
6435 let nullity = penalties
6436 .iter()
6437 .zip(ranks.iter())
6438 .map(|(penalty, rank)| penalty.nrows().saturating_sub(*rank))
6439 .sum::<usize>();
6440 let nu = effective_observation_count(weights) as f64 - nullity as f64;
6443 if !(nu.is_finite() && nu > 0.0) {
6444 return Err(EstimationError::InvalidInput(format!(
6445 "Gaussian REML residual degrees of freedom must be positive; got {nu}"
6446 )));
6447 }
6448 let tau = nu / q;
6449 let tau_q = -nu / (q * q);
6450 if !(tau.is_finite() && tau_q.is_finite()) {
6451 return Err(EstimationError::InvalidInput(format!(
6452 "Gaussian REML scale derivatives are non-finite: tau={tau}, tau_q={tau_q}"
6453 )));
6454 }
6455
6456 let mut grad_z = Array2::<f64>::zeros((n, p_total));
6457 let mut g_kernel = Array2::<f64>::zeros((p_total, p_total));
6458 let mut h_kernel = Array1::<f64>::zeros(p_total);
6459 let mut q_kernel = 0.0_f64;
6460 let mut j_blocks: Vec<Array2<f64>> = penalties
6461 .iter()
6462 .map(|p| Array2::<f64>::zeros(p.dim()))
6463 .collect();
6464
6465 let mut beta_tilde = Array1::<f64>::zeros(p_total);
6466 if let Some(gc) = grad_coefficients {
6467 beta_tilde += &gc.column(0).to_owned();
6468 }
6469 if let Some(gf) = grad_fitted {
6470 let gf_col = gf.column(0).to_owned();
6471 beta_tilde += &z.t().dot(&gf_col);
6472 for row in 0..n {
6473 for col in 0..p_total {
6474 grad_z[[row, col]] += gf_col[row] * beta[col];
6475 }
6476 }
6477 }
6478
6479 let u = r.dot(&beta_tilde);
6484 h_kernel += &u;
6485 for i in 0..p_total {
6486 for j in 0..p_total {
6487 g_kernel[[i, j]] -= 0.5 * (beta[i] * u[j] + u[i] * beta[j]);
6488 }
6489 }
6490
6491 let mut alpha = Array1::<f64>::zeros(f_blocks);
6492 if let Some(gl) = grad_lambdas {
6493 for block in 0..f_blocks {
6494 alpha[block] += gl[block] * lambdas[block];
6495 }
6496 }
6497 if let Some(grho) = grad_log_lambdas {
6498 alpha += &grho.to_owned();
6499 }
6500
6501 let mut p_betas = Vec::with_capacity(f_blocks);
6502 let mut m_vectors = Vec::with_capacity(f_blocks);
6503 let mut rp_matrices = Vec::with_capacity(f_blocks);
6504 let mut rpr_matrices = Vec::with_capacity(f_blocks);
6505 let mut b_values = Array1::<f64>::zeros(f_blocks);
6506 let mut t_values = Array1::<f64>::zeros(f_blocks);
6507
6508 for block in 0..f_blocks {
6509 let start = offsets[block];
6510 let end = offsets[block + 1];
6511 let beta_k = beta.slice(s![start..end]).to_owned();
6512 let s_beta = penalties[block].dot(&beta_k);
6513 let lambda = lambdas[block];
6514 let lambda_s_beta = s_beta.mapv(|value| lambda * value);
6515 let mut p_beta = Array1::<f64>::zeros(p_total);
6516 for local_i in 0..(end - start) {
6517 p_beta[start + local_i] = lambda_s_beta[local_i];
6518 }
6519 let weighted_penalty = penalties[block].mapv(|value| lambda * value);
6520 let rp_block = r.slice(s![.., start..end]).dot(&weighted_penalty);
6521 let mut rp = Array2::<f64>::zeros((p_total, p_total));
6522 rp.slice_mut(s![.., start..end]).assign(&rp_block);
6523 let rpr = rp_block.dot(&r.slice(s![start..end, ..]));
6524 let m = r.slice(s![.., start..end]).dot(&lambda_s_beta);
6525 b_values[block] = beta.dot(&p_beta);
6526 t_values[block] = (0..(end - start))
6527 .map(|local_i| rp_block[[start + local_i, local_i]])
6528 .sum::<f64>();
6529 alpha[block] -= u.dot(&p_beta);
6530 p_betas.push(p_beta);
6531 m_vectors.push(m);
6532 rp_matrices.push(rp);
6533 rpr_matrices.push(rpr);
6534 }
6535
6536 if grad_reml_score != 0.0 {
6537 q_kernel += 0.5 * grad_reml_score * tau;
6538 g_kernel += &(r.clone() * (0.5 * grad_reml_score));
6539 for block in 0..f_blocks {
6540 j_blocks[block] -= &(pinvs[block].clone() * (0.5 * grad_reml_score / lambdas[block]));
6541 }
6542 }
6543
6544 let mut trace_pairs = Array2::<f64>::zeros((f_blocks, f_blocks));
6545 for i in 0..f_blocks {
6546 for j in 0..f_blocks {
6547 trace_pairs[[i, j]] =
6548 gam_linalg::utils::trace_of_product(rp_matrices[i].view(), rp_matrices[j].view());
6549 }
6550 }
6551
6552 if let Some(ge) = grad_edf {
6553 for edf_block in 0..f_blocks {
6554 let scale = ge[edf_block];
6555 if scale == 0.0 {
6556 continue;
6557 }
6558 let start = offsets[edf_block];
6559 let end = offsets[edf_block + 1];
6560 g_kernel += &(rpr_matrices[edf_block].clone() * scale);
6561 j_blocks[edf_block] -= &(r.slice(s![start..end, start..end]).to_owned() * scale);
6562 for rho_block in 0..f_blocks {
6563 alpha[rho_block] += scale * trace_pairs[[edf_block, rho_block]];
6564 if rho_block == edf_block {
6565 alpha[rho_block] -= scale * t_values[edf_block];
6566 }
6567 }
6568 }
6569 }
6570
6571 if let Some((block, value)) = alpha
6572 .iter()
6573 .enumerate()
6574 .find(|(_, value)| !value.is_finite())
6575 {
6576 return Err(EstimationError::InvalidInput(format!(
6577 "rho adjoint seed for block {block} is non-finite: {value}"
6578 )));
6579 }
6580
6581 if alpha.iter().any(|value| *value != 0.0) {
6582 let mut outer_h = Array2::<f64>::zeros((f_blocks, f_blocks));
6583 for k in 0..f_blocks {
6584 for j in 0..f_blocks {
6585 let beta_pk_r_pj_beta = p_betas[k].dot(&m_vectors[j]);
6586 outer_h[[k, j]] = 0.5 * trace_pairs[[k, j]] + tau * beta_pk_r_pj_beta
6587 - if k == j {
6588 0.5 * (t_values[k] + tau * b_values[k])
6589 } else {
6590 0.0
6591 }
6592 - 0.5 * tau_q * b_values[k] * b_values[j];
6593 }
6594 }
6595 gam_linalg::matrix::symmetrize_in_place(&mut outer_h);
6600 if let Some(((row, col), value)) =
6601 outer_h.indexed_iter().find(|(_, value)| !value.is_finite())
6602 {
6603 return Err(EstimationError::InvalidInput(format!(
6604 "outer rho curvature entry ({row},{col}) is non-finite: {value}"
6605 )));
6606 }
6607 let rho_adj = gam_linalg::utils::certified_symmetric_solve(
6608 &outer_h,
6609 &alpha,
6610 "block Gaussian REML outer-rho adjoint",
6611 )
6612 .map(gam_linalg::utils::CertifiedSymmetricSolution::into_solution)
6613 .map_err(|error| {
6614 EstimationError::InvalidInput(format!(
6615 "block Gaussian REML outer-rho adjoint is not exactly solvable: {error}"
6616 ))
6617 })?;
6618 if let Some((block, value)) = rho_adj
6619 .iter()
6620 .enumerate()
6621 .find(|(_, value)| !value.is_finite())
6622 {
6623 return Err(EstimationError::InvalidInput(format!(
6624 "outer rho adjoint for block {block} is non-finite: {value}"
6625 )));
6626 }
6627 let weighted_b_sum = rho_adj
6628 .iter()
6629 .zip(b_values.iter())
6630 .map(|(&zk, &bk)| zk * bk)
6631 .sum::<f64>();
6632 q_kernel += 0.5 * tau_q * weighted_b_sum;
6633 for block in 0..f_blocks {
6634 let zk = rho_adj[block];
6635 if zk == 0.0 {
6636 continue;
6637 }
6638 g_kernel -= &(rpr_matrices[block].clone() * (0.5 * zk));
6639 let m = &m_vectors[block];
6640 for i in 0..p_total {
6641 h_kernel[i] += tau * zk * m[i];
6642 for j in 0..p_total {
6643 g_kernel[[i, j]] -= 0.5 * tau * zk * (beta[i] * m[j] + m[i] * beta[j]);
6644 }
6645 }
6646 let start = offsets[block];
6647 let end = offsets[block + 1];
6648 j_blocks[block] += &(r.slice(s![start..end, start..end]).to_owned() * (0.5 * zk));
6649 for i in 0..(end - start) {
6650 for j in 0..(end - start) {
6651 j_blocks[block][[i, j]] += 0.5 * tau * zk * beta[start + i] * beta[start + j];
6652 }
6653 }
6654 }
6655 }
6656
6657 for row in 0..n {
6658 for col in 0..p_total {
6659 grad_z[[row, col]] += -2.0 * q_kernel * weighted_residual[row] * beta[col];
6660 }
6661 }
6662 let zg = z.dot(&g_kernel);
6663 for row in 0..n {
6664 for col in 0..p_total {
6665 grad_z[[row, col]] += 2.0 * weights[row] * zg[[row, col]];
6666 }
6667 }
6668 let wy = y.to_owned() * &weights.to_owned();
6669 for row in 0..n {
6670 for col in 0..p_total {
6671 grad_z[[row, col]] += wy[row] * h_kernel[col];
6672 }
6673 }
6674
6675 let mut grad_y = Array2::<f64>::zeros((n, 1));
6676 let zh = z.dot(&h_kernel);
6677 for row in 0..n {
6678 grad_y[[row, 0]] = 2.0 * q_kernel * weighted_residual[row] + weights[row] * zh[row];
6679 }
6680
6681 let mut grad_weights = Array1::<f64>::zeros(n);
6682 for row in 0..n {
6683 let diag_zgz = (0..p_total)
6684 .map(|col| z[[row, col]] * zg[[row, col]])
6685 .sum::<f64>();
6686 grad_weights[row] = q_kernel * residual[row] * residual[row] + diag_zgz + y[row] * zh[row];
6687 }
6688
6689 if grad_reml_score != 0.0 {
6711 let q_kernel_score = 0.5 * grad_reml_score * tau;
6712 let zr = z.dot(&r);
6713 let n_pos = (0..n).filter(|&i| weights[i] > 0.0).count();
6714 if n_pos > 0 {
6715 let mut weighted_score_partial_sum = 0.0_f64;
6716 for row in 0..n {
6717 if weights[row] <= 0.0 {
6718 continue;
6719 }
6720 let z_r_z = (0..p_total)
6721 .map(|col| z[[row, col]] * zr[[row, col]])
6722 .sum::<f64>();
6723 let a_score =
6724 q_kernel_score * residual[row] * residual[row] + 0.5 * grad_reml_score * z_r_z;
6725 weighted_score_partial_sum += weights[row] * a_score;
6726 }
6727 let projection = weighted_score_partial_sum / n_pos as f64;
6728 for row in 0..n {
6729 if weights[row] > 0.0 {
6730 grad_weights[row] -= projection / weights[row];
6731 }
6732 }
6733 }
6734 }
6735
6736 let mut grad_penalties = Vec::with_capacity(f_blocks);
6737 for block in 0..f_blocks {
6738 let start = offsets[block];
6739 let end = offsets[block + 1];
6740 let mut local = g_kernel.slice(s![start..end, start..end]).to_owned();
6741 for i in 0..(end - start) {
6742 for j in 0..(end - start) {
6743 local[[i, j]] += q_kernel * beta[start + i] * beta[start + j];
6744 }
6745 }
6746 local += &j_blocks[block];
6747 local *= lambdas[block];
6748 gam_linalg::matrix::symmetrize_in_place(&mut local);
6749 grad_penalties.push(local);
6750 }
6751
6752 let mut grad_designs = Vec::with_capacity(f_blocks);
6753 for block in 0..f_blocks {
6754 grad_designs.push(
6755 grad_z
6756 .slice(s![.., offsets[block]..offsets[block + 1]])
6757 .to_owned(),
6758 );
6759 }
6760
6761 Ok(GaussianRemlBlocksBackwardAnalytic {
6762 grad_designs,
6763 grad_penalties,
6764 grad_y,
6765 grad_weights,
6766 })
6767}
6768
6769pub struct DenseFisherGaussianFit {
6773 pub coefficients: Array2<f64>,
6774 pub fitted: Array2<f64>,
6775 pub sigma2: Array1<f64>,
6776 pub objective: f64,
6777}
6778
6779pub fn add_block_diagonal_penalty(
6782 hessian: &mut Array2<f64>,
6783 penalty: ArrayView2<'_, f64>,
6784 lambda: f64,
6785 n_outputs: usize,
6786) -> Result<(), EstimationError> {
6787 let k = penalty.ncols();
6788 if penalty.nrows() != k {
6789 return Err(EstimationError::InvalidInput(format!(
6790 "penalty must be square for dense Fisher fit; got {}x{}",
6791 penalty.nrows(),
6792 penalty.ncols()
6793 )));
6794 }
6795 if hessian.dim() != (k * n_outputs, k * n_outputs) {
6796 return Err(EstimationError::InvalidInput(
6797 "dense Fisher Hessian shape mismatch while adding penalty".to_string(),
6798 ));
6799 }
6800 for output in 0..n_outputs {
6801 let offset = output * k;
6802 for row in 0..k {
6803 for col in 0..k {
6804 let s_sym = 0.5 * (penalty[[row, col]] + penalty[[col, row]]);
6805 hessian[[offset + row, offset + col]] += lambda * s_sym;
6806 }
6807 }
6808 }
6809 Ok(())
6810}
6811
6812pub fn dense_fisher_gaussian_fit(
6819 design: ArrayView2<'_, f64>,
6820 y: ArrayView2<'_, f64>,
6821 penalty: ArrayView2<'_, f64>,
6822 row_weights: ArrayView1<'_, f64>,
6823 fisher_w: ArrayView3<'_, f64>,
6824 lambda: f64,
6825 latent_prior_score: f64,
6826) -> Result<DenseFisherGaussianFit, EstimationError> {
6827 let n_obs = design.nrows();
6828 let k = design.ncols();
6829 let n_outputs = y.ncols();
6830 let mut hessian = crate::pirls::dense_block_xtwx(design, fisher_w, Some(row_weights))?;
6831 add_block_diagonal_penalty(&mut hessian, penalty, lambda, n_outputs)?;
6832 let rhs = crate::pirls::dense_block_xtwy(design, fisher_w, y, Some(row_weights))?;
6833 let beta_vec =
6834 gam_linalg::utils::solve_dense_block_system(&hessian, &rhs, "dense Fisher Gaussian")
6835 .map_err(EstimationError::InvalidInput)?;
6836 let mut coefficients = Array2::<f64>::zeros((k, n_outputs));
6837 for output in 0..n_outputs {
6838 for col in 0..k {
6839 coefficients[[col, output]] = beta_vec[output * k + col];
6840 }
6841 }
6842 let fitted = design.dot(&coefficients);
6843 let mut sigma2 = Array1::<f64>::zeros(n_outputs);
6844 let mut objective = latent_prior_score;
6845 for row in 0..n_obs {
6846 for a in 0..n_outputs {
6847 let ra = y[[row, a]] - fitted[[row, a]];
6848 sigma2[a] += row_weights[row] * ra * ra;
6849 for b in 0..n_outputs {
6850 objective += 0.5
6851 * row_weights[row]
6852 * ra
6853 * fisher_w[[row, a, b]]
6854 * (y[[row, b]] - fitted[[row, b]]);
6855 }
6856 }
6857 }
6858 for output in 0..n_outputs {
6859 sigma2[output] /= (n_obs.saturating_sub(k).max(1)) as f64;
6860 let beta_col = coefficients.column(output);
6861 let s_beta = penalty.dot(&beta_col);
6862 objective += 0.5 * lambda * beta_col.dot(&s_beta);
6863 }
6864 Ok(DenseFisherGaussianFit {
6865 coefficients,
6866 fitted,
6867 sigma2,
6868 objective,
6869 })
6870}