1use crate::estimate::EstimationError;
2use crate::estimate::{FitGeometry, UnifiedFitResult};
3use crate::pirls;
4use faer::Mat as FaerMat;
5use faer::linalg::matmul::matmul;
6use faer::prelude::ReborrowMut;
7use faer::{Accum, Par};
8use gam_linalg::faer_ndarray::{FaerArrayView, FaerCholesky};
9use gam_linalg::matrix::{PsdWeightsView, SignedWeightsView};
10use gam_linalg::utils::StableSolver;
11use opt::{BacktrackConfig, backtracking_line_search};
12use gam_problem::LinkFunction;
13use ndarray::{Array1, Array2, ArrayView1, ShapeBuilder, s};
14use std::convert::Infallible;
15use std::fmt;
16
17#[derive(Debug, Clone)]
26pub enum AloError {
27 InvalidInput { reason: String },
31 WeightInvalid { reason: String },
34 DesignDegenerate { reason: String },
37 InfluenceMatrixFailed { condition_number: f64 },
40 LooComputationFailed { reason: String },
43}
44
45impl fmt::Display for AloError {
46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47 match self {
48 AloError::InvalidInput { reason }
49 | AloError::WeightInvalid { reason }
50 | AloError::DesignDegenerate { reason }
51 | AloError::LooComputationFailed { reason } => f.write_str(reason),
52 AloError::InfluenceMatrixFailed { condition_number } => {
53 write!(
54 f,
55 "ALO influence matrix failed (condition number {condition_number:.3e})"
56 )
57 }
58 }
59 }
60}
61
62impl std::error::Error for AloError {}
63
64impl From<AloError> for EstimationError {
65 fn from(err: AloError) -> EstimationError {
66 match err {
67 AloError::InvalidInput { reason }
68 | AloError::WeightInvalid { reason }
69 | AloError::DesignDegenerate { reason }
70 | AloError::LooComputationFailed { reason } => EstimationError::InvalidInput(reason),
71 AloError::InfluenceMatrixFailed { condition_number } => {
72 EstimationError::ModelIsIllConditioned { condition_number }
73 }
74 }
75 }
76}
77
78impl From<AloError> for String {
79 fn from(err: AloError) -> String {
80 err.to_string()
81 }
82}
83
84#[derive(Debug, Clone)]
86pub struct AloDiagnostics {
87 pub eta_tilde: Array1<f64>,
88 pub se_bayes: Array1<f64>,
91 pub se_sandwich: Array1<f64>,
94 pub pred_identity: Array1<f64>,
95 pub leverage: Array1<f64>,
96 pub fisherweights: Array1<f64>,
97}
98
99#[inline]
100fn alo_eta_updatewith_offset(
101 eta_hat: f64,
102 z: f64,
103 offset: f64,
104 x_hinv_x: f64,
105 score_weight: f64,
106 denom: f64,
107) -> f64 {
108 let eta_centered = eta_hat - offset;
111 let z_centered = z - offset;
112 let score = score_weight * (eta_centered - z_centered);
113 offset + eta_centered + x_hinv_x * score / denom
114}
115
116pub type AloScalarScoreCurvature<'a> = dyn Fn(usize, f64) -> (f64, f64) + Sync + 'a;
126
127const ALO_EXACT_SCALAR_MAX_ITERS: usize = 64;
133
134const ALO_EXACT_SCALAR_TOL: f64 = 1e-12;
138
139#[derive(Debug, Clone, Copy, PartialEq)]
160enum AloExactScalarError {
161 NonFiniteScoreCurvature {
162 eta: f64,
163 ell_prime: f64,
164 ell_double: f64,
165 },
166 DegenerateJacobian {
167 eta: f64,
168 jacobian: f64,
169 },
170 NonFiniteStep {
171 eta: f64,
172 residual: f64,
173 jacobian: f64,
174 next: f64,
175 },
176 MaxIterations {
177 iterations: usize,
178 residual: f64,
179 eta: f64,
180 },
181}
182
183impl fmt::Display for AloExactScalarError {
184 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185 match *self {
186 AloExactScalarError::NonFiniteScoreCurvature {
187 eta,
188 ell_prime,
189 ell_double,
190 } => write!(
191 f,
192 "non-finite score/curvature at eta={eta:.6e}: ell_prime={ell_prime:.6e}, ell_double={ell_double:.6e}"
193 ),
194 AloExactScalarError::DegenerateJacobian { eta, jacobian } => write!(
195 f,
196 "degenerate Newton Jacobian at eta={eta:.6e}: jacobian={jacobian:.6e}, min={ALO_DENOMINATOR_MIN:.1e}"
197 ),
198 AloExactScalarError::NonFiniteStep {
199 eta,
200 residual,
201 jacobian,
202 next,
203 } => write!(
204 f,
205 "non-finite Newton step from eta={eta:.6e}: residual={residual:.6e}, jacobian={jacobian:.6e}, next={next:.6e}"
206 ),
207 AloExactScalarError::MaxIterations {
208 iterations,
209 residual,
210 eta,
211 } => write!(
212 f,
213 "did not converge within {iterations} iterations: residual={residual:.6e}, eta={eta:.6e}, tol={ALO_EXACT_SCALAR_TOL:.1e}"
214 ),
215 }
216 }
217}
218
219const ALO_EXACT_SCALAR_BACKTRACKS: usize = 40;
225
226#[inline]
227fn alo_eta_exact_frozen_curvature(
228 eta_hat: f64,
229 a_ii: f64,
230 score_curvature: &dyn Fn(f64) -> (f64, f64),
231) -> Result<f64, AloExactScalarError> {
232 let residual_and_jac = |eta: f64| -> Result<(f64, f64), AloExactScalarError> {
256 let (ell_prime, ell_double) = score_curvature(eta);
257 if !ell_prime.is_finite() || !ell_double.is_finite() {
258 return Err(AloExactScalarError::NonFiniteScoreCurvature {
259 eta,
260 ell_prime,
261 ell_double,
262 });
263 }
264 Ok((eta - eta_hat - a_ii * ell_prime, 1.0 - a_ii * ell_double))
265 };
266
267 let mut eta = eta_hat;
268 let (mut residual, mut jac) = residual_and_jac(eta)?;
269 for _ in 0..ALO_EXACT_SCALAR_MAX_ITERS {
270 if residual.abs() <= ALO_EXACT_SCALAR_TOL {
271 return Ok(eta);
272 }
273 if jac.abs() <= ALO_DENOMINATOR_MIN || !jac.is_finite() {
274 return Err(AloExactScalarError::DegenerateJacobian { eta, jacobian: jac });
275 }
276 let step = residual / jac;
277 if !step.is_finite() {
278 return Err(AloExactScalarError::NonFiniteStep {
279 eta,
280 residual,
281 jacobian: jac,
282 next: eta - step,
283 });
284 }
285 let accepted = match backtracking_line_search::<_, Infallible>(
291 BacktrackConfig {
292 max_steps: ALO_EXACT_SCALAR_BACKTRACKS,
293 ..BacktrackConfig::default()
294 },
295 |t| {
296 let trial = eta - t * step;
297 Ok(residual_and_jac(trial)
298 .ok()
299 .map(|(r_trial, j_trial)| (r_trial.abs(), (trial, r_trial, j_trial))))
300 },
301 |_t, merit| merit < residual.abs(),
302 ) {
303 Ok(result) => result,
304 Err(never) => match never {},
305 };
306 let Some(step) = accepted else {
307 break;
308 };
309 (eta, residual, jac) = step.payload;
310 }
311 Err(AloExactScalarError::MaxIterations {
312 iterations: ALO_EXACT_SCALAR_MAX_ITERS,
313 residual,
314 eta,
315 })
316}
317
318#[inline]
319fn bayesvar_eta(phi: f64, x_hinv_x: f64) -> f64 {
320 phi * x_hinv_x
321}
322
323#[inline]
324fn sandwichvar_eta_from_meat(phi: f64, meat_quad: f64) -> f64 {
325 phi * meat_quad
326}
327
328#[inline]
329fn variance_negative_tolerance(scale: f64) -> f64 {
330 1e-12 * scale.abs().max(1.0)
332}
333
334const LEVERAGE_HIGH_THRESHOLD: f64 = 0.99;
335const LEVERAGE_VERY_HIGH_THRESHOLD: f64 = 0.999;
336const LEVERAGE_RATE_THRESHOLDS: [f64; 3] = [0.90, 0.95, 0.99];
337const LEVERAGE_PERCENTILES: [f64; 3] = [0.50, 0.95, 0.99];
338const ALO_DENOMINATOR_MIN: f64 = 1e-12;
339const MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES: usize = 256 * 1024 * 1024;
340
341const ALO_RHS_BLOCK_COLS: usize = 8192;
346
347const HESSIAN_SYMMETRY_REL_TOL: f64 = 1e-8;
353
354const ALO_LOCAL_BLOCK_RIDGE: f64 = 1e-6;
360
361const LU_PIVOT_SINGULAR_TOL: f64 = 1e-12;
366
367#[inline]
368fn percentile_index(sample_size: usize, quantile: f64) -> usize {
369 if sample_size <= 1 {
370 return 0;
371 }
372 let max_index = sample_size - 1;
373 ((quantile * max_index as f64).round() as usize).min(max_index)
374}
375
376#[inline]
377fn percentile_from_sorted(sorted: &[f64], quantile: f64) -> f64 {
378 if sorted.is_empty() {
379 0.0
380 } else {
381 sorted[percentile_index(sorted.len(), quantile)]
382 }
383}
384
385#[inline]
386fn multiblock_col_offsets(block_designs: &[Array2<f64>]) -> Vec<usize> {
387 let mut offsets = Vec::with_capacity(block_designs.len());
388 let mut off = 0usize;
389 for design in block_designs {
390 offsets.push(off);
391 off += design.ncols();
392 }
393 offsets
394}
395
396#[inline]
397fn multiblock_alo_parallel_leverage_chunk_size(
398 p_tot: usize,
399 n_blocks: usize,
400 n_obs: usize,
401 max_workers: usize,
402) -> usize {
403 if p_tot == 0 || n_blocks == 0 || n_obs == 0 {
404 return 1;
405 }
406
407 let workers = max_workers.max(1);
413 let per_worker_budget = (MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES / workers).max(1);
414 let elem_count_per_obs = p_tot.saturating_mul(n_blocks.saturating_add(1)).max(1);
415 let bytes_per_obs = elem_count_per_obs
416 .saturating_mul(std::mem::size_of::<f64>())
417 .max(1);
418 let budget_obs = (per_worker_budget / bytes_per_obs).max(1);
419 budget_obs.min(n_obs)
420}
421
422fn compute_alo_diagnostics_from_pirls_impl(
423 base: &pirls::PirlsResult,
424 y: ArrayView1<f64>,
425 link: LinkFunction,
426) -> Result<AloDiagnostics, EstimationError> {
427 compute_alo_diagnostics_from_pirls_inner(base, y, link).map_err(EstimationError::from)
428}
429
430fn alo_link_needs_exact_curvature_refinement(likelihood: &gam_problem::GlmLikelihoodSpec) -> bool {
443 use gam_problem::ResponseFamily;
444 matches!(
445 (&likelihood.spec.response, likelihood.link_function()),
446 (ResponseFamily::Binomial, LinkFunction::Logit)
447 | (ResponseFamily::Poisson, LinkFunction::Log)
448 )
449}
450
451fn compute_alo_diagnostics_from_pirls_inner(
452 base: &pirls::PirlsResult,
453 y: ArrayView1<f64>,
454 link: LinkFunction,
455) -> Result<AloDiagnostics, AloError> {
456 let x_dense_arc = base
457 .x_transformed
458 .try_to_dense_arc("ALO diagnostics require dense transformed design")
459 .map_err(|reason| AloError::DesignDegenerate { reason })?;
460 let x_dense = x_dense_arc.as_ref();
461 let n = x_dense.nrows();
462
463 let phi = match link {
465 LinkFunction::Log => 1.0,
466 LinkFunction::Logit
467 | LinkFunction::Probit
468 | LinkFunction::CLogLog
469 | LinkFunction::LogLog
470 | LinkFunction::Cauchit
471 | LinkFunction::Sas
472 | LinkFunction::BetaLogistic => 1.0,
473 LinkFunction::Identity => {
474 use rayon::iter::{IntoParallelIterator, ParallelIterator};
475 let rss: f64 = (0..n)
476 .into_par_iter()
477 .map(|i| {
478 let r = y[i] - base.finalmu[i];
479 base.finalweights[i] * r * r
480 })
481 .sum();
482 let n_pos = (0..n).filter(|&i| base.finalweights[i] > 0.0).count();
489 let dof = (n_pos as f64) - base.edf;
490 let denom = dof.max(1.0);
491 rss / denom
492 }
493 };
494
495 let e = &base.reparam_result.e_transformed;
496 let ridge = base.ridge_passport.laplacehessianridge().max(0.0);
497
498 let h_dense_for_alo = base
502 .dense_stabilizedhessian_transformed(
503 "ALO diagnostics require exact dense stabilized penalized Hessian",
504 )
505 .map_err(|e| match e {
506 EstimationError::InvalidInput(reason) => AloError::InvalidInput { reason },
507 other => AloError::InvalidInput {
508 reason: format!("{other:?}"),
509 },
510 })?;
511
512 let canonical_scale: Option<Array1<f64>> =
531 if alo_link_needs_exact_curvature_refinement(&base.likelihood) {
532 let mut c = Array1::<f64>::zeros(n);
533 for i in 0..n {
534 let dmu = base.solve_dmu_deta[i];
535 let w_h = base.finalweights[i];
536 c[i] = if dmu.abs() <= ALO_DENOMINATOR_MIN || !dmu.is_finite() || !w_h.is_finite() {
537 f64::NAN
538 } else {
539 w_h / dmu
540 };
541 }
542 Some(c)
543 } else {
544 None
545 };
546
547 let inv_link_for_closure = base.likelihood.spec.link.clone();
548 let score_curvature_closure = canonical_scale.as_ref().map(|scale| {
549 move |i: usize, eta: f64| -> (f64, f64) {
550 let (mu, dmu) = crate::mixture_link::inverse_link_mu_d1_for_inverse_link(
551 &inv_link_for_closure,
552 eta,
553 )
554 .unwrap_or((f64::NAN, f64::NAN));
555 let c_i = scale[i];
556 (c_i * (mu - y[i]), c_i * dmu)
557 }
558 });
559 let score_curvature_ref: Option<&AloScalarScoreCurvature> = score_curvature_closure
560 .as_ref()
561 .map(|f| f as &AloScalarScoreCurvature);
562
563 let alo_working_response = base.solveworking_response.to_owned();
568 let alo_final_eta = base.final_eta.to_owned();
569 let alo_final_offset = base.final_offset.to_owned();
570 let input = AloInput {
571 design: x_dense,
572 penalized_hessian: &h_dense_for_alo,
573 hessian_weights: base.final_weights_signed(),
574 score_weights: base.solve_weights_psd(),
575 working_response: &alo_working_response,
576 eta: &alo_final_eta,
577 offset: &alo_final_offset,
578 link,
579 phi,
580 penalty_root: if e.nrows() > 0 { Some(e) } else { None },
581 ridge,
582 score_curvature: score_curvature_ref,
583 };
584
585 let result = compute_alo_from_input_inner(&input)?;
586
587 log_leverage_diagnostics(&result.leverage, phi);
589
590 let has_nan_pred = result.eta_tilde.iter().any(|&x| x.is_nan());
592 let has_nan_se_bayes = result.se_bayes.iter().any(|&x| x.is_nan());
593 let has_nan_se_sandwich = result.se_sandwich.iter().any(|&x| x.is_nan());
594 let has_nan_leverage = result.leverage.iter().any(|&x| x.is_nan());
595
596 if has_nan_pred || has_nan_se_bayes || has_nan_se_sandwich || has_nan_leverage {
597 log::error!("[GAM ALO] NaN values found in ALO diagnostics:");
598 log::error!(
599 "[GAM ALO] eta_tilde: {} NaN values",
600 result.eta_tilde.iter().filter(|&&x| x.is_nan()).count()
601 );
602 log::error!(
603 "[GAM ALO] se_bayes: {} NaN values",
604 result.se_bayes.iter().filter(|&&x| x.is_nan()).count()
605 );
606 log::error!(
607 "[GAM ALO] se_sandwich: {} NaN values",
608 result.se_sandwich.iter().filter(|&&x| x.is_nan()).count()
609 );
610 log::error!(
611 "[GAM ALO] leverage: {} NaN values",
612 result.leverage.iter().filter(|&&x| x.is_nan()).count()
613 );
614 return Err(AloError::InfluenceMatrixFailed {
615 condition_number: f64::INFINITY,
616 });
617 }
618
619 Ok(result)
620}
621
622fn log_leverage_diagnostics(leverage: &Array1<f64>, phi: f64) {
624 let n = leverage.len();
625 if n == 0 {
626 return;
627 }
628
629 let mut invalid_count = 0usize;
630 let mut high_leverage_count = 0usize;
631 let mut threshold_counts = [0usize; LEVERAGE_RATE_THRESHOLDS.len()];
632 let mut finite_leverage = Vec::with_capacity(n);
633
634 for (obs, &ai) in leverage.iter().enumerate() {
635 if ai.is_finite() {
636 finite_leverage.push(ai);
637 }
638
639 if !(0.0..=1.0).contains(&ai) || !ai.is_finite() {
640 invalid_count += 1;
641 log::warn!("[GAM ALO] invalid leverage at i={}, a_ii={:.6e}", obs, ai);
642 } else if ai > LEVERAGE_HIGH_THRESHOLD {
643 high_leverage_count += 1;
644 if ai > LEVERAGE_VERY_HIGH_THRESHOLD {
645 log::warn!("[GAM ALO] very high leverage at i={}, a_ii={:.6e}", obs, ai);
646 }
647 }
648
649 for (idx, threshold) in LEVERAGE_RATE_THRESHOLDS.iter().enumerate() {
650 if ai > *threshold {
651 threshold_counts[idx] += 1;
652 }
653 }
654 }
655
656 if invalid_count > 0 || high_leverage_count > 0 {
657 log::warn!(
658 "[GAM ALO] leverage diagnostics: {} invalid values, {} high values (>0.99)",
659 invalid_count,
660 high_leverage_count
661 );
662 }
663
664 finite_leverage.sort_by(f64::total_cmp);
665
666 let finite_n = finite_leverage.len();
667 let a_mean = if finite_n > 0 {
668 finite_leverage.iter().copied().sum::<f64>() / finite_n as f64
669 } else {
670 0.0
671 };
672 let a_median = percentile_from_sorted(&finite_leverage, LEVERAGE_PERCENTILES[0]);
673 let a_p95 = percentile_from_sorted(&finite_leverage, LEVERAGE_PERCENTILES[1]);
674 let a_p99 = percentile_from_sorted(&finite_leverage, LEVERAGE_PERCENTILES[2]);
675 let a_max = finite_leverage.last().copied().unwrap_or(0.0);
676
677 log::info!(
686 "[GAM ALO] leverage: n={}, mean={:.3e}, median={:.3e}, p95={:.3e}, p99={:.3e}, max={:.3e}",
687 n,
688 a_mean,
689 a_median,
690 a_p95,
691 a_p99,
692 a_max
693 );
694 log::info!(
695 "[GAM ALO] high-leverage: a>0.90: {:.2}%, a>0.95: {:.2}%, a>0.99: {:.2}%, dispersion phi={:.3e}",
696 100.0 * (threshold_counts[0] as f64) / n as f64,
697 100.0 * (threshold_counts[1] as f64) / n as f64,
698 100.0 * (threshold_counts[2] as f64) / n as f64,
699 phi
700 );
701}
702
703pub struct AloInput<'a> {
710 pub design: &'a Array2<f64>,
712 pub penalized_hessian: &'a Array2<f64>,
714 pub hessian_weights: SignedWeightsView<'a>,
721 pub score_weights: PsdWeightsView<'a>,
724 pub working_response: &'a Array1<f64>,
726 pub eta: &'a Array1<f64>,
728 pub offset: &'a Array1<f64>,
730 pub link: LinkFunction,
732 pub phi: f64,
734 pub penalty_root: Option<&'a Array2<f64>>,
737 pub ridge: f64,
739 pub score_curvature: Option<&'a AloScalarScoreCurvature<'a>>,
752}
753
754impl<'a> AloInput<'a> {
755 pub fn from_geometry(
757 geom: &'a FitGeometry,
758 design: &'a Array2<f64>,
759 eta: &'a Array1<f64>,
760 offset: &'a Array1<f64>,
761 link: LinkFunction,
762 phi: f64,
763 ) -> Self {
764 let psd_w = PsdWeightsView::from_view_unchecked(geom.working_weights.view());
771 Self {
772 design,
773 penalized_hessian: &geom.penalized_hessian,
774 hessian_weights: psd_w.as_signed(),
775 score_weights: psd_w,
776 working_response: &geom.working_response,
777 eta,
778 offset,
779 link,
780 phi,
781 penalty_root: None,
782 ridge: 0.0,
783 score_curvature: None,
784 }
785 }
786
787 pub fn from_geometry_with_working_state(
807 geom: &'a FitGeometry,
808 design: &'a Array2<f64>,
809 eta: &'a Array1<f64>,
810 offset: &'a Array1<f64>,
811 link: LinkFunction,
812 phi: f64,
813 working_weights: &'a Array1<f64>,
814 working_response: &'a Array1<f64>,
815 ) -> Self {
816 let psd_w = PsdWeightsView::from_view_unchecked(working_weights.view());
817 Self {
818 design,
819 penalized_hessian: &geom.penalized_hessian,
820 hessian_weights: psd_w.as_signed(),
821 score_weights: psd_w,
822 working_response,
823 eta,
824 offset,
825 link,
826 phi,
827 penalty_root: None,
828 ridge: 0.0,
829 score_curvature: None,
830 }
831 }
832}
833
834pub fn compute_alo_from_input(input: &AloInput) -> Result<AloDiagnostics, EstimationError> {
840 compute_alo_from_input_inner(input).map_err(EstimationError::from)
841}
842
843fn compute_alo_from_input_inner(input: &AloInput) -> Result<AloDiagnostics, AloError> {
844 let x_dense = input.design;
845 let n = x_dense.nrows();
846 let p = x_dense.ncols();
847 let w_h = input.hessian_weights.view();
851 let w_s = input.score_weights.view();
852
853 validate_alo_solve_setup(input, n, p)?;
854
855 let factor = StableSolver::new("alo penalized hessian")
856 .factorize(input.penalized_hessian)
857 .map_err(|_| AloError::InfluenceMatrixFailed {
858 condition_number: f64::INFINITY,
859 })?;
860
861 let xt = x_dense.t();
862 let phi = input.phi;
863
864 let mut aii = Array1::<f64>::zeros(n);
865 let mut x_hinv_x_diag = Array1::<f64>::zeros(n);
866 let mut se_bayes = Array1::<f64>::zeros(n);
867 let mut se_sandwich = Array1::<f64>::zeros(n);
868
869 let block_cols = ALO_RHS_BLOCK_COLS;
870 let mut rhs_chunk_buf = Array2::<f64>::zeros((p, block_cols).f());
875 let mut xs_chunk_storage = FaerMat::<f64>::zeros(n, block_cols);
882 let x_dense_view = FaerArrayView::new(x_dense);
883
884 for chunk_start in (0..n).step_by(block_cols) {
885 let chunk_end = (chunk_start + block_cols).min(n);
886 let width = chunk_end - chunk_start;
887
888 rhs_chunk_buf
889 .slice_mut(s![.., ..width])
890 .assign(&xt.slice(s![.., chunk_start..chunk_end]));
891
892 let rhs_chunkview = rhs_chunk_buf.slice(s![.., ..width]);
893 let rhs_chunk = FaerArrayView::new(&rhs_chunkview);
894 let s_chunk = factor.solve(rhs_chunk.as_ref());
898
899 let mut xs_target = xs_chunk_storage.as_mut().subcols_mut(0, width);
900 matmul(
901 xs_target.rb_mut(),
902 Accum::Replace,
903 x_dense_view.as_ref(),
904 s_chunk.as_ref(),
905 1.0,
906 Par::Seq,
907 );
908
909 let rhs_view = rhs_chunk_buf.slice(s![.., ..width]);
910
911 for local_col in 0..width {
912 let obs = chunk_start + local_col;
913 let rhs_col = rhs_view.column(local_col);
917 let rhs_slice = rhs_col.as_slice().expect("column-major col contiguous");
918 let s_slice = s_chunk.col_as_slice(local_col);
919
920 let mut x_hinv_x = 0.0f64;
921 for k in 0..p {
923 let sval = s_slice[k];
924 let xval = rhs_slice[k];
925 x_hinv_x = sval.mul_add(xval, x_hinv_x);
926 }
927 let ai = w_h[obs].max(0.0) * x_hinv_x;
928 aii[obs] = ai;
929 x_hinv_x_diag[obs] = x_hinv_x;
930
931 let var_bayes = bayesvar_eta(phi, x_hinv_x);
932 let xs_slice = xs_chunk_storage.col_as_slice(local_col);
933 let mut meat_quad = 0.0f64;
934 for row in 0..n {
935 let xs = xs_slice[row];
936 meat_quad += w_s[row] * xs * xs;
943 }
944 let var_sandwich = sandwichvar_eta_from_meat(phi, meat_quad);
945
946 if !var_bayes.is_finite() || !var_sandwich.is_finite() {
947 return Err(AloError::LooComputationFailed {
948 reason: format!(
949 "ALO variance is not finite at row {obs}: bayes={var_bayes:.6e}, sandwich={var_sandwich:.6e}"
950 ),
951 });
952 }
953 let bayes_tol = variance_negative_tolerance(phi * x_hinv_x.abs());
954 if var_bayes < -bayes_tol {
955 return Err(AloError::LooComputationFailed {
956 reason: format!(
957 "ALO Bayesian variance is materially negative at row {obs}: var={var_bayes:.6e}, tol={bayes_tol:.6e}"
958 ),
959 });
960 }
961 let sandwich_scale = phi * meat_quad.abs().max(x_hinv_x.abs());
962 let sandwich_tol = variance_negative_tolerance(sandwich_scale);
963 if var_sandwich < -sandwich_tol {
964 return Err(AloError::LooComputationFailed {
965 reason: format!(
966 "ALO sandwich variance is materially negative at row {obs}: var={var_sandwich:.6e}, tol={sandwich_tol:.6e}"
967 ),
968 });
969 }
970
971 se_bayes[obs] = var_bayes.max(0.0).sqrt();
972 se_sandwich[obs] = var_sandwich.max(0.0).sqrt();
973 }
974 }
975
976 let eta_hat = input.eta;
977 let z = input.working_response;
978 let offset = input.offset;
979
980 use rayon::prelude::*;
981 let eta_tilde_vec: Vec<f64> = (0..n)
982 .into_par_iter()
983 .map(|i| {
984 let denom_raw = 1.0 - aii[i];
985 if denom_raw <= ALO_DENOMINATOR_MIN || !denom_raw.is_finite() {
986 return Err(AloError::LooComputationFailed {
987 reason: format!(
988 "ALO denominator is too small at row {i}: a_ii={:.6e}, 1-a_ii={:.6e}, min={:.1e}",
989 aii[i], denom_raw, ALO_DENOMINATOR_MIN
990 ),
991 });
992 }
993 let one_step = alo_eta_updatewith_offset(
994 eta_hat[i],
995 z[i],
996 offset[i],
997 x_hinv_x_diag[i],
998 w_s[i],
999 denom_raw,
1000 );
1001 let v = if let Some(score_curvature) = input.score_curvature {
1009 alo_eta_exact_frozen_curvature(
1010 eta_hat[i],
1011 x_hinv_x_diag[i],
1012 &|eta| score_curvature(i, eta),
1013 )
1014 .map_err(|err| AloError::LooComputationFailed {
1015 reason: format!(
1016 "ALO exact frozen-curvature solve failed at row {i}: {err}"
1017 ),
1018 })?
1019 } else {
1020 one_step
1021 };
1022 if !v.is_finite() {
1023 return Err(AloError::LooComputationFailed {
1024 reason: format!("ALO eta_tilde is not finite at row {i}: eta_tilde={v}"),
1025 });
1026 }
1027 Ok(v)
1028 })
1029 .collect::<Result<_, _>>()?;
1030 let eta_tilde = Array1::from(eta_tilde_vec);
1031
1032 Ok(AloDiagnostics {
1033 eta_tilde,
1034 se_bayes,
1035 se_sandwich,
1036 pred_identity: eta_hat.clone(),
1037 leverage: aii,
1038 fisherweights: w_h.to_owned(),
1039 })
1040}
1041
1042fn validate_alo_solve_setup(input: &AloInput, n: usize, p: usize) -> Result<(), AloError> {
1043 let h = input.penalized_hessian;
1044 if h.nrows() != p || h.ncols() != p {
1045 return Err(AloError::InvalidInput {
1046 reason: format!(
1047 "ALO diagnostics require a dense exact penalized Hessian with shape {p}x{p}; got {}x{}",
1048 h.nrows(),
1049 h.ncols()
1050 ),
1051 });
1052 }
1053 if h.iter().any(|v| !v.is_finite()) {
1054 return Err(AloError::InvalidInput {
1055 reason: "ALO diagnostics require a finite dense exact penalized Hessian".to_string(),
1056 });
1057 }
1058 for i in 0..p {
1059 for j in 0..i {
1060 let a = h[[i, j]];
1061 let b = h[[j, i]];
1062 let scale = a.abs().max(b.abs()).max(1.0);
1063 if (a - b).abs() > HESSIAN_SYMMETRY_REL_TOL * scale {
1064 return Err(AloError::InvalidInput {
1065 reason: format!(
1066 "ALO diagnostics require a symmetric dense exact penalized Hessian; entries ({i},{j}) and ({j},{i}) differ by {:.3e}",
1067 (a - b).abs()
1068 ),
1069 });
1070 }
1071 }
1072 }
1073
1074 let vector_lengths = [
1075 ("hessian_weights", input.hessian_weights.len()),
1076 ("score_weights", input.score_weights.len()),
1077 ("working_response", input.working_response.len()),
1078 ("eta", input.eta.len()),
1079 ("offset", input.offset.len()),
1080 ];
1081 for (name, len) in vector_lengths {
1082 if len != n {
1083 return Err(AloError::InvalidInput {
1084 reason: format!("ALO diagnostics require {name} length {n}; got {len}"),
1085 });
1086 }
1087 }
1088 if input.hessian_weights.view().iter().any(|v| !v.is_finite()) {
1089 return Err(AloError::WeightInvalid {
1090 reason: "ALO diagnostics require finite Hessian-side weights".to_string(),
1091 });
1092 }
1093 if input.score_weights.view().iter().any(|v| !v.is_finite()) {
1094 return Err(AloError::WeightInvalid {
1095 reason: "ALO diagnostics require finite score-side weights".to_string(),
1096 });
1097 }
1098 if input.working_response.iter().any(|v| !v.is_finite()) {
1099 return Err(AloError::WeightInvalid {
1100 reason: "ALO diagnostics require finite working responses".to_string(),
1101 });
1102 }
1103 if input.eta.iter().any(|v| !v.is_finite()) || input.offset.iter().any(|v| !v.is_finite()) {
1104 return Err(AloError::InvalidInput {
1105 reason: "ALO diagnostics require finite linear predictors and offsets".to_string(),
1106 });
1107 }
1108 if !input.phi.is_finite() || input.phi <= 0.0 {
1109 return Err(AloError::InvalidInput {
1110 reason: format!(
1111 "ALO diagnostics require positive finite dispersion phi; got {}",
1112 input.phi
1113 ),
1114 });
1115 }
1116 if !input.ridge.is_finite() || input.ridge < 0.0 {
1117 return Err(AloError::InvalidInput {
1118 reason: format!(
1119 "ALO diagnostics require a finite non-negative Hessian ridge; got {}",
1120 input.ridge
1121 ),
1122 });
1123 }
1124 if let Some(e) = input.penalty_root {
1125 if e.ncols() != p {
1126 return Err(AloError::InvalidInput {
1127 reason: format!(
1128 "ALO diagnostics require penalty root to have {p} columns; got {}",
1129 e.ncols()
1130 ),
1131 });
1132 }
1133 if e.iter().any(|v| !v.is_finite()) {
1134 return Err(AloError::InvalidInput {
1135 reason: "ALO diagnostics require finite penalty-root entries".to_string(),
1136 });
1137 }
1138 }
1139 Ok(())
1140}
1141
1142pub fn compute_alo_diagnostics_from_fit(
1144 fit: &UnifiedFitResult,
1145 y: ArrayView1<f64>,
1146 link: LinkFunction,
1147) -> Result<AloDiagnostics, EstimationError> {
1148 let pirls = fit
1149 .artifacts
1150 .pirls
1151 .as_ref()
1152 .ok_or_else(|| AloError::InvalidInput {
1153 reason:
1154 "ALO diagnostics require a PIRLS-backed fit; this fit does not expose PIRLS geometry"
1155 .to_string(),
1156 })
1157 .map_err(EstimationError::from)?;
1158 compute_alo_diagnostics_from_pirls_impl(pirls, y, link)
1159}
1160
1161pub fn compute_alo_diagnostics_from_unified(
1167 unified: &UnifiedFitResult,
1168 design: &Array2<f64>,
1169 eta: &Array1<f64>,
1170 offset: &Array1<f64>,
1171 link: LinkFunction,
1172 phi: f64,
1173) -> Result<AloDiagnostics, EstimationError> {
1174 let geom = unified
1175 .geometry
1176 .as_ref()
1177 .ok_or_else(|| AloError::InvalidInput {
1178 reason: "UnifiedFitResult does not contain working-set geometry; \
1179 ALO diagnostics require geometry at convergence"
1180 .to_string(),
1181 })
1182 .map_err(EstimationError::from)?;
1183 let input = AloInput::from_geometry(geom, design, eta, offset, link, phi);
1184 compute_alo_from_input(&input)
1185}
1186
1187pub fn compute_alo_diagnostics_from_pirls(
1189 base: &pirls::PirlsResult,
1190 y: ArrayView1<f64>,
1191 link: LinkFunction,
1192) -> Result<AloDiagnostics, EstimationError> {
1193 compute_alo_diagnostics_from_pirls_impl(base, y, link)
1194}
1195
1196pub fn compute_case_deletion_from_pirls(
1215 base: &pirls::PirlsResult,
1216 y: ArrayView1<f64>,
1217 link: LinkFunction,
1218) -> Result<Option<crate::sensitivity::CaseDeletionInfluence>, EstimationError> {
1219 let x_dense_arc = base
1220 .x_transformed
1221 .try_to_dense_arc("case-deletion diagnostics require dense transformed design")
1222 .map_err(|reason| EstimationError::InvalidInput(reason))?;
1223 let x_dense = x_dense_arc.as_ref();
1224 let n = x_dense.nrows();
1225 let p = x_dense.ncols();
1226 if n == 0 || p == 0 {
1227 return Ok(None);
1228 }
1229
1230 let phi = match link {
1235 LinkFunction::Identity => {
1236 use rayon::iter::{IntoParallelIterator, ParallelIterator};
1237 let rss: f64 = (0..n)
1238 .into_par_iter()
1239 .map(|i| {
1240 let r = y[i] - base.finalmu[i];
1241 base.finalweights[i] * r * r
1242 })
1243 .sum();
1244 let n_pos = (0..n).filter(|&i| base.finalweights[i] > 0.0).count();
1245 let dof = (n_pos as f64) - base.edf;
1246 rss / dof.max(1.0)
1247 }
1248 _ => 1.0,
1249 };
1250 if !(phi.is_finite() && phi > 0.0) {
1251 return Ok(None);
1252 }
1253
1254 let h_dense = base
1257 .dense_stabilizedhessian_transformed(
1258 "case-deletion diagnostics require exact dense stabilized penalized Hessian",
1259 )
1260 .map_err(|e| match e {
1261 EstimationError::InvalidInput(reason) => EstimationError::InvalidInput(reason),
1262 other => EstimationError::InvalidInput(format!("{other:?}")),
1263 })?;
1264
1265 let factor = match h_dense.cholesky(faer::Side::Lower) {
1266 Ok(f) => f,
1267 Err(_) => return Ok(None),
1271 };
1272
1273 let working_weights = base.finalweights.clone();
1277 let working_residual = &base.solveworking_response - &base.final_eta;
1278
1279 let sensitivity = crate::sensitivity::FitSensitivity::from_faer_cholesky(&factor, p);
1280 Ok(sensitivity.case_deletion(
1281 x_dense,
1282 working_weights.view(),
1283 working_residual.view(),
1284 phi,
1285 ))
1286}
1287
1288#[derive(Debug, Clone)]
1292pub struct MultiBlockAloDiagnostics {
1293 pub eta_tilde: Vec<Array1<f64>>,
1296 pub leverage: Array1<f64>,
1298 pub alo_variance: Vec<Array1<f64>>,
1303 pub cook_distance: Array1<f64>,
1306}
1307
1308pub struct MultiBlockAloInput<'a> {
1338 pub n_obs: usize,
1340 pub n_blocks: usize,
1342 pub block_designs: &'a [Array2<f64>],
1345 pub penalized_hessian_inv: &'a Array2<f64>,
1347 pub block_weights: Vec<Array2<f64>>,
1349 pub scores: Vec<Array1<f64>>,
1352 pub eta_hat: Vec<Array1<f64>>,
1355}
1356
1357pub fn compute_multiblock_alo(
1376 input: &MultiBlockAloInput,
1377) -> Result<MultiBlockAloDiagnostics, EstimationError> {
1378 compute_multiblock_alo_inner(input).map_err(EstimationError::from)
1379}
1380
1381fn compute_multiblock_alo_inner(
1382 input: &MultiBlockAloInput,
1383) -> Result<MultiBlockAloDiagnostics, AloError> {
1384 use rayon::prelude::*;
1385
1386 let n = input.n_obs;
1387 let b = input.n_blocks;
1388 let p_tot = input.penalized_hessian_inv.nrows();
1389
1390 if input.block_designs.len() != b {
1392 return Err(AloError::InvalidInput {
1393 reason: format!(
1394 "MultiBlockAloInput: expected {} block designs, got {}",
1395 b,
1396 input.block_designs.len()
1397 ),
1398 });
1399 }
1400
1401 let col_sum: usize = input.block_designs.iter().map(|d| d.ncols()).sum();
1403 if col_sum != p_tot {
1404 return Err(AloError::InvalidInput {
1405 reason: format!(
1406 "MultiBlockAloInput: total design columns ({}) != penalized_hessian_inv size ({})",
1407 col_sum, p_tot
1408 ),
1409 });
1410 }
1411
1412 let col_offsets = multiblock_col_offsets(input.block_designs);
1413 let (chunk_size, max_concurrent_chunks) = multiblock_alo_parallel_plan(p_tot, b, n);
1414 let chunk_starts: Vec<usize> = (0..n).step_by(chunk_size).collect();
1415
1416 let mut chunk_results: Vec<Result<MultiBlockAloChunkDiagnostics, AloError>> =
1422 Vec::with_capacity(chunk_starts.len());
1423 for chunk_wave in chunk_starts.chunks(max_concurrent_chunks) {
1424 let mut wave_results: Vec<Result<MultiBlockAloChunkDiagnostics, AloError>> = chunk_wave
1425 .par_iter()
1426 .map_init(
1427 || MultiBlockAloScratch::new(b),
1428 |scratch, &chunk_start| {
1429 let chunk_end = (chunk_start + chunk_size).min(n);
1430 compute_multiblock_alo_chunk(
1431 input,
1432 &col_offsets,
1433 chunk_start,
1434 chunk_end,
1435 scratch,
1436 )
1437 },
1438 )
1439 .collect();
1440 chunk_results.append(&mut wave_results);
1441 }
1442
1443 let mut eta_tilde = Vec::with_capacity(n);
1444 let mut leverage = Array1::<f64>::zeros(n);
1445 let mut alo_variance = Vec::with_capacity(n);
1446 let mut cook_distance = Array1::<f64>::zeros(n);
1447
1448 let mut chunks = Vec::with_capacity(chunk_results.len());
1449 for result in chunk_results {
1450 chunks.push(result?);
1451 }
1452 chunks.sort_unstable_by_key(|chunk| chunk.chunk_start);
1453
1454 for chunk in chunks {
1455 let chunk_start = chunk.chunk_start;
1456 eta_tilde.extend(chunk.eta_tilde);
1457 alo_variance.extend(chunk.alo_variance);
1458 for (local_i, lev) in chunk.leverage.into_iter().enumerate() {
1459 leverage[chunk_start + local_i] = lev;
1460 }
1461 for (local_i, cook) in chunk.cook_distance.into_iter().enumerate() {
1462 cook_distance[chunk_start + local_i] = cook;
1463 }
1464 }
1465
1466 Ok(MultiBlockAloDiagnostics {
1467 eta_tilde,
1468 leverage,
1469 alo_variance,
1470 cook_distance,
1471 })
1472}
1473
1474#[inline]
1475fn multiblock_alo_parallel_plan(p_tot: usize, n_blocks: usize, n_obs: usize) -> (usize, usize) {
1476 if p_tot == 0 || n_blocks == 0 || n_obs == 0 {
1477 return (1, 1);
1478 }
1479 let bytes_per_obs = (p_tot * n_blocks * std::mem::size_of::<f64>()).max(1);
1480 let workers = rayon::current_num_threads().max(1);
1481 let max_concurrent_chunks = (MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES / bytes_per_obs)
1482 .max(1)
1483 .min(workers);
1484 let per_worker_budget =
1485 (MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES / max_concurrent_chunks).max(bytes_per_obs);
1486 let budget_obs = (per_worker_budget / bytes_per_obs).max(1);
1487 (budget_obs.min(n_obs), max_concurrent_chunks)
1488}
1489
1490struct MultiBlockAloScratch {
1491 a_i: Vec<f64>,
1492 wa: Vec<f64>,
1493 aw: Vec<f64>,
1494 imwa: Vec<f64>,
1495 imaw: Vec<f64>,
1496 perm_imwa: Vec<usize>,
1497 perm_imaw: Vec<usize>,
1498 delta_eta: Vec<f64>,
1499 rhs_buf: Vec<f64>,
1500 w_u: Vec<f64>,
1501 var_diag_buf: Vec<f64>,
1502 w_flat: Vec<f64>,
1503 lu_scratch: Vec<f64>,
1504}
1505
1506impl MultiBlockAloScratch {
1507 fn new(b: usize) -> Self {
1508 let bb_sz = b * b;
1509 Self {
1510 a_i: vec![0.0f64; bb_sz],
1511 wa: vec![0.0f64; bb_sz],
1512 aw: vec![0.0f64; bb_sz],
1513 imwa: vec![0.0f64; bb_sz],
1514 imaw: vec![0.0f64; bb_sz],
1515 perm_imwa: vec![0usize; b],
1516 perm_imaw: vec![0usize; b],
1517 delta_eta: vec![0.0f64; b],
1518 rhs_buf: vec![0.0f64; b],
1519 w_u: vec![0.0f64; b],
1520 var_diag_buf: vec![0.0f64; b],
1521 w_flat: vec![0.0f64; bb_sz],
1522 lu_scratch: vec![0.0f64; b],
1523 }
1524 }
1525}
1526
1527struct MultiBlockAloChunkDiagnostics {
1528 chunk_start: usize,
1529 eta_tilde: Vec<Array1<f64>>,
1530 leverage: Vec<f64>,
1531 alo_variance: Vec<Array1<f64>>,
1532 cook_distance: Vec<f64>,
1533}
1534
1535fn compute_multiblock_alo_chunk(
1536 input: &MultiBlockAloInput,
1537 col_offsets: &[usize],
1538 chunk_start: usize,
1539 chunk_end: usize,
1540 scratch: &mut MultiBlockAloScratch,
1541) -> Result<MultiBlockAloChunkDiagnostics, AloError> {
1542 let b = input.n_blocks;
1543 let chunk_len = chunk_end - chunk_start;
1544
1545 let mut q_blocks = Vec::with_capacity(b);
1546 for blk in 0..b {
1547 let x_chunk_t = input.block_designs[blk]
1548 .slice(s![chunk_start..chunk_end, ..])
1549 .t()
1550 .to_owned();
1551 let off_b = col_offsets[blk];
1552 let h_slice = input
1553 .penalized_hessian_inv
1554 .slice(s![.., off_b..off_b + x_chunk_t.nrows()])
1555 .to_owned();
1556 q_blocks.push(h_slice.dot(&x_chunk_t));
1557 }
1558
1559 let mut eta_tilde = Vec::with_capacity(chunk_len);
1560 let mut leverage = vec![0.0f64; chunk_len];
1561 let mut alo_variance = Vec::with_capacity(chunk_len);
1562 let mut cook_distance = vec![0.0f64; chunk_len];
1563
1564 for local_i in 0..chunk_len {
1565 let i = chunk_start + local_i;
1566 let w_i = &input.block_weights[i];
1567
1568 for r in 0..b {
1570 for c in 0..b {
1571 scratch.w_flat[r * b + c] = w_i[(r, c)];
1572 }
1573 }
1574
1575 for a in 0..b {
1577 let x_a = &input.block_designs[a];
1578 let p_a = x_a.ncols();
1579 let off_a = col_offsets[a];
1580 let xa_row = x_a.row(i);
1581 for bb in 0..b {
1582 let q_bb = &q_blocks[bb];
1583 let mut dot = 0.0f64;
1584 for k in 0..p_a {
1585 dot += xa_row[k] * q_bb[(off_a + k, local_i)];
1586 }
1587 scratch.a_i[a * b + bb] = dot;
1588 }
1589 }
1590
1591 mat_mul_flat(&scratch.w_flat, &scratch.a_i, &mut scratch.wa, b);
1593 mat_mul_flat(&scratch.a_i, &scratch.w_flat, &mut scratch.aw, b);
1595
1596 let mut tr = 0.0f64;
1599 for d in 0..b {
1600 tr += scratch.aw[d * b + d];
1601 }
1602 leverage[local_i] = tr;
1603
1604 for r in 0..b {
1606 for c in 0..b {
1607 let idx = r * b + c;
1608 let id = if r == c { 1.0 } else { 0.0 };
1609 scratch.imwa[idx] = id - scratch.wa[idx];
1610 scratch.imaw[idx] = id - scratch.aw[idx];
1611 }
1612 }
1613
1614 if !lu_factor_in_place(&mut scratch.imwa, &mut scratch.perm_imwa, b) {
1620 for r in 0..b {
1621 for c in 0..b {
1622 let idx = r * b + c;
1623 let id = if r == c { 1.0 } else { 0.0 };
1624 scratch.imwa[idx] = id - scratch.wa[idx];
1625 }
1626 }
1627 for d in 0..b {
1628 scratch.imwa[d * b + d] += ALO_LOCAL_BLOCK_RIDGE;
1629 }
1630 let refactored = lu_factor_in_place(&mut scratch.imwa, &mut scratch.perm_imwa, b);
1631 assert!(
1632 refactored,
1633 "ALO local block remained singular after ridge regularization"
1634 );
1635 }
1636 if !lu_factor_in_place(&mut scratch.imaw, &mut scratch.perm_imaw, b) {
1637 for r in 0..b {
1638 for c in 0..b {
1639 let idx = r * b + c;
1640 let id = if r == c { 1.0 } else { 0.0 };
1641 scratch.imaw[idx] = id - scratch.aw[idx];
1642 }
1643 }
1644 for d in 0..b {
1645 scratch.imaw[d * b + d] += ALO_LOCAL_BLOCK_RIDGE;
1646 }
1647 let refactored = lu_factor_in_place(&mut scratch.imaw, &mut scratch.perm_imaw, b);
1648 assert!(
1649 refactored,
1650 "ALO local variance block remained singular after ridge regularization"
1651 );
1652 }
1653
1654 let s_i = &input.scores[i];
1656 for k in 0..b {
1657 scratch.rhs_buf[k] = s_i[k];
1658 }
1659 lu_solve_in_place(
1660 &scratch.imwa,
1661 &scratch.perm_imwa,
1662 &mut scratch.rhs_buf,
1663 &mut scratch.lu_scratch,
1664 b,
1665 );
1666 for r in 0..b {
1668 let mut acc = 0.0f64;
1669 let row_off = r * b;
1670 for k in 0..b {
1671 acc += scratch.a_i[row_off + k] * scratch.rhs_buf[k];
1672 }
1673 scratch.delta_eta[r] = acc;
1674 }
1675
1676 let eta_i = &input.eta_hat[i];
1677 let mut corrected = Array1::<f64>::zeros(b);
1678 for d in 0..b {
1679 corrected[d] = eta_i[d] + scratch.delta_eta[d];
1680 }
1681 eta_tilde.push(corrected);
1682
1683 let mut cook = 0.0f64;
1685 for r in 0..b {
1686 let mut w_delta_r = 0.0f64;
1687 let row_off = r * b;
1688 for k in 0..b {
1689 w_delta_r += scratch.w_flat[row_off + k] * scratch.delta_eta[k];
1690 }
1691 cook += scratch.delta_eta[r] * w_delta_r;
1692 }
1693 cook_distance[local_i] = cook;
1694
1695 for d in 0..b {
1701 let row_off = d * b;
1702 for k in 0..b {
1704 scratch.rhs_buf[k] = scratch.a_i[row_off + k];
1705 }
1706 lu_solve_in_place(
1707 &scratch.imaw,
1708 &scratch.perm_imaw,
1709 &mut scratch.rhs_buf,
1710 &mut scratch.lu_scratch,
1711 b,
1712 );
1713 for r in 0..b {
1715 let mut acc = 0.0f64;
1716 let wr = r * b;
1717 for k in 0..b {
1718 acc += scratch.w_flat[wr + k] * scratch.rhs_buf[k];
1719 }
1720 scratch.w_u[r] = acc;
1721 }
1722 lu_solve_in_place(
1724 &scratch.imwa,
1725 &scratch.perm_imwa,
1726 &mut scratch.w_u,
1727 &mut scratch.lu_scratch,
1728 b,
1729 );
1730 let mut v_dd = 0.0f64;
1732 for k in 0..b {
1733 v_dd += scratch.a_i[row_off + k] * scratch.w_u[k];
1734 }
1735 scratch.var_diag_buf[d] = v_dd.max(0.0);
1736 }
1737 let mut var_diag = Array1::<f64>::zeros(b);
1738 for d in 0..b {
1739 var_diag[d] = scratch.var_diag_buf[d];
1740 }
1741 alo_variance.push(var_diag);
1742 }
1743
1744 Ok(MultiBlockAloChunkDiagnostics {
1745 chunk_start,
1746 eta_tilde,
1747 leverage,
1748 alo_variance,
1749 cook_distance,
1750 })
1751}
1752
1753#[inline]
1755fn mat_mul_flat(a: &[f64], b_mat: &[f64], out: &mut [f64], b: usize) {
1756 for r in 0..b {
1757 let ar = r * b;
1758 let or = r * b;
1759 for c in 0..b {
1760 let mut acc = 0.0f64;
1761 for k in 0..b {
1762 acc += a[ar + k] * b_mat[k * b + c];
1763 }
1764 out[or + c] = acc;
1765 }
1766 }
1767}
1768
1769fn lu_factor_in_place(m: &mut [f64], perm: &mut [usize], b: usize) -> bool {
1776 for i in 0..b {
1777 perm[i] = i;
1778 }
1779 for col in 0..b {
1780 let mut max_val = m[col * b + col].abs();
1782 let mut max_idx = col;
1783 for row in (col + 1)..b {
1784 let v = m[row * b + col].abs();
1785 if v > max_val {
1786 max_val = v;
1787 max_idx = row;
1788 }
1789 }
1790 if max_val < LU_PIVOT_SINGULAR_TOL {
1791 return false;
1792 }
1793 if max_idx != col {
1794 for k in 0..b {
1796 m.swap(col * b + k, max_idx * b + k);
1797 }
1798 perm.swap(col, max_idx);
1799 }
1800 let pivot = m[col * b + col];
1801 for row in (col + 1)..b {
1802 let factor = m[row * b + col] / pivot;
1803 m[row * b + col] = factor; for k in (col + 1)..b {
1805 let upd = factor * m[col * b + k];
1806 m[row * b + k] -= upd;
1807 }
1808 }
1809 }
1810 true
1811}
1812
1813fn lu_solve_in_place(m: &[f64], perm: &[usize], rhs: &mut [f64], scratch: &mut [f64], b: usize) {
1816 let y = &mut scratch[..b];
1818 for row in 0..b {
1819 let mut s = rhs[perm[row]];
1820 for k in 0..row {
1821 s -= m[row * b + k] * y[k];
1822 }
1823 y[row] = s;
1824 }
1825 for row in (0..b).rev() {
1827 let mut s = y[row];
1828 for k in (row + 1)..b {
1829 s -= m[row * b + k] * rhs[k];
1830 }
1831 rhs[row] = s / m[row * b + row];
1832 }
1833}
1834
1835pub fn compute_multiblock_alo_leverages(
1843 n_obs: usize,
1844 n_blocks: usize,
1845 block_designs: &[Array2<f64>],
1846 penalized_hessian_inv: &Array2<f64>,
1847 block_weights: &[Array2<f64>],
1848) -> Result<Array1<f64>, EstimationError> {
1849 use rayon::prelude::*;
1850
1851 let n = n_obs;
1852 let b = n_blocks;
1853 let p_tot = penalized_hessian_inv.nrows();
1854
1855 let col_offsets = multiblock_col_offsets(block_designs);
1856 let max_workers = rayon::current_num_threads();
1857 let chunk_size = multiblock_alo_parallel_leverage_chunk_size(p_tot, b, n, max_workers);
1858
1859 let mut leverage = Array1::<f64>::zeros(n);
1860
1861 let block_widths: Vec<usize> = block_designs.iter().map(|d| d.ncols()).collect();
1865 let mut h_stripes: Vec<FaerMat<f64>> = block_widths
1866 .iter()
1867 .map(|&p_blk| FaerMat::<f64>::zeros(p_tot, p_blk))
1868 .collect();
1869 for blk in 0..b {
1872 let off_b = col_offsets[blk];
1873 let p_blk = block_widths[blk];
1874 let stripe = &mut h_stripes[blk];
1875 for c in 0..p_blk {
1876 for r in 0..p_tot {
1877 stripe[(r, c)] = penalized_hessian_inv[(r, off_b + c)];
1878 }
1879 }
1880 }
1881
1882 leverage
1883 .as_slice_mut()
1884 .expect("newly allocated Array1 is contiguous")
1885 .par_chunks_mut(chunk_size)
1886 .enumerate()
1887 .for_each(|(chunk_idx, leverage_chunk)| {
1888 let chunk_start = chunk_idx * chunk_size;
1889 let chunk_len = leverage_chunk.len();
1890 let chunk_end = chunk_start + chunk_len;
1891
1892 let bb_sz = b * b;
1896 let mut a_i = vec![0.0f64; bb_sz];
1897 let mut aw = vec![0.0f64; bb_sz];
1898 let mut w_flat = vec![0.0f64; bb_sz];
1899
1900 let mut q_storage: Vec<FaerMat<f64>> = block_widths
1904 .iter()
1905 .map(|_| FaerMat::<f64>::zeros(p_tot, chunk_len))
1906 .collect();
1907
1908 let mut xt_storage: Vec<FaerMat<f64>> = block_widths
1912 .iter()
1913 .map(|&p_blk| FaerMat::<f64>::zeros(p_blk, chunk_len))
1914 .collect();
1915
1916 for blk in 0..b {
1921 let p_blk = block_widths[blk];
1922
1923 let x_chunk = block_designs[blk].slice(s![chunk_start..chunk_end, ..]);
1924 let xt = &mut xt_storage[blk];
1925 for local_i in 0..chunk_len {
1926 let row = x_chunk.row(local_i);
1927 for j in 0..p_blk {
1928 xt[(j, local_i)] = row[j];
1929 }
1930 }
1931
1932 matmul(
1933 q_storage[blk].as_mut(),
1934 Accum::Replace,
1935 h_stripes[blk].as_ref(),
1936 xt_storage[blk].as_ref(),
1937 1.0,
1938 Par::Seq,
1939 );
1940 }
1941
1942 for local_i in 0..chunk_len {
1943 let i = chunk_start + local_i;
1944 let w_i = &block_weights[i];
1945
1946 for r in 0..b {
1948 for c in 0..b {
1949 w_flat[r * b + c] = w_i[(r, c)];
1950 }
1951 }
1952
1953 for r in 0..bb_sz {
1957 a_i[r] = 0.0;
1958 }
1959 for k in 0..b {
1960 let q_k = &q_storage[k];
1961 let q_col = q_k.col_as_slice(local_i);
1962 for a in 0..b {
1963 let p_a = block_widths[a];
1964 let off_a = col_offsets[a];
1965 let xa_row = block_designs[a].row(i);
1966 let mut dot = 0.0f64;
1967 for j in 0..p_a {
1968 dot = xa_row[j].mul_add(q_col[off_a + j], dot);
1969 }
1970 a_i[a * b + k] = dot;
1971 }
1972 }
1973
1974 mat_mul_flat(&a_i, &w_flat, &mut aw, b);
1976 let mut tr = 0.0f64;
1977 for d in 0..b {
1978 tr += aw[d * b + d];
1979 }
1980 leverage_chunk[local_i] = tr;
1981 }
1982 });
1983
1984 Ok(leverage)
1985}
1986
1987#[cfg(test)]
1991mod tests {
1992 use super::{
1993 ALO_EXACT_SCALAR_MAX_ITERS, AloExactScalarError, AloInput, alo_eta_exact_frozen_curvature,
1994 alo_eta_updatewith_offset, bayesvar_eta, compute_alo_from_input_inner,
1995 percentile_from_sorted, percentile_index, sandwichvar_eta_from_meat,
1996 };
1997 use gam_linalg::matrix::{PsdWeightsView, SignedWeightsView};
1998 use gam_problem::LinkFunction;
1999
2000 #[test]
2001 fn alo_offset_update_matches_centered_algebra() {
2002 let eta_hat = 11.0;
2003 let z = 13.0;
2004 let offset = 10.0;
2005 let x_hinv_x = 0.2;
2006 let hessian_weight = 1.0;
2007 let score_weight = 1.0;
2008 let leverage = hessian_weight * x_hinv_x;
2010 let expected = offset + ((eta_hat - offset) - leverage * (z - offset)) / (1.0 - leverage);
2011 let got =
2012 alo_eta_updatewith_offset(eta_hat, z, offset, x_hinv_x, score_weight, 1.0 - leverage);
2013 assert!((got - expected).abs() < 1e-12);
2014 }
2015
2016 #[test]
2017 fn alo_offset_update_reduces_to_classicwhen_offsetzero() {
2018 let eta_hat = 1.25;
2019 let z = -0.5;
2020 let x_hinv_x = 0.35;
2021 let hessian_weight = 1.0;
2022 let score_weight = 1.0;
2023 let leverage = hessian_weight * x_hinv_x;
2024 let expected = (eta_hat - leverage * z) / (1.0 - leverage);
2025 let got =
2026 alo_eta_updatewith_offset(eta_hat, z, 0.0, x_hinv_x, score_weight, 1.0 - leverage);
2027 assert!((got - expected).abs() < 1e-12);
2028 }
2029
2030 #[test]
2031 fn alo_offset_update_uses_distinct_score_and_hessian_weights() {
2032 let eta_hat = 1.7;
2033 let z = 0.4;
2034 let offset = -0.2;
2035 let x_hinv_x = 0.15;
2036 let hessian_weight = 3.0;
2037 let score_weight = 5.0;
2038 let expected = offset
2039 + (eta_hat - offset)
2040 + x_hinv_x * score_weight * ((eta_hat - offset) - (z - offset))
2041 / (1.0 - hessian_weight * x_hinv_x);
2042 let got = alo_eta_updatewith_offset(
2043 eta_hat,
2044 z,
2045 offset,
2046 x_hinv_x,
2047 score_weight,
2048 1.0 - hessian_weight * x_hinv_x,
2049 );
2050 assert!((got - expected).abs() < 1e-12);
2051 }
2052
2053 #[test]
2054 fn alo_offset_update_handles_zero_hessian_weight() {
2055 let eta_hat = 0.8;
2056 let z = -0.3;
2057 let offset = 0.1;
2058 let x_hinv_x = 0.4;
2059 let hessian_weight = 0.0;
2060 let score_weight = 2.5;
2061 let expected = offset
2062 + (eta_hat - offset)
2063 + x_hinv_x * score_weight * ((eta_hat - offset) - (z - offset));
2064 let got = alo_eta_updatewith_offset(
2065 eta_hat,
2066 z,
2067 offset,
2068 x_hinv_x,
2069 score_weight,
2070 1.0 - hessian_weight * x_hinv_x,
2071 );
2072 assert!((got - expected).abs() < 1e-12);
2073 }
2074
2075 #[test]
2076 fn alo_exact_frozen_curvature_converges_to_fixed_point() {
2077 let eta_hat = 1.0;
2078 let a_ii = 0.4;
2079 let got = alo_eta_exact_frozen_curvature(eta_hat, a_ii, &|eta| (0.5 * (eta - 2.0), 0.5))
2080 .expect("linear scalar fixed point should converge in one Newton step");
2081 assert!((got - 0.75).abs() < 1e-12);
2082 }
2083
2084 #[test]
2085 fn alo_exact_frozen_curvature_reports_nonconvergence() {
2086 let err = alo_eta_exact_frozen_curvature(0.0, 1.0, &|eta| (eta + 1.0, 0.0))
2087 .expect_err("constant residual should exhaust the scalar iteration budget");
2088 let AloExactScalarError::MaxIterations { iterations, .. } = err else {
2089 panic!("constant residual must report MaxIterations, got {err:?}");
2090 };
2091 assert_eq!(
2092 iterations, ALO_EXACT_SCALAR_MAX_ITERS,
2093 "non-convergence must report the full scalar iteration budget"
2094 );
2095 }
2096
2097 #[test]
2098 fn alo_input_reports_exact_scalar_nonconvergence_with_row_context() {
2099 let design = Array2::from_elem((1, 1), 1.0);
2100 let penalized_hessian = Array2::from_elem((1, 1), 1.0);
2101 let hessian_weights = Array1::from_vec(vec![0.0]);
2102 let score_weights = Array1::from_vec(vec![0.0]);
2103 let working_response = Array1::from_vec(vec![0.0]);
2104 let eta = Array1::from_vec(vec![0.0]);
2105 let offset = Array1::from_vec(vec![0.0]);
2106 let score_curvature = |_: usize, eta: f64| (eta + 1.0, 0.0);
2107 let input = AloInput {
2108 design: &design,
2109 penalized_hessian: &penalized_hessian,
2110 hessian_weights: SignedWeightsView::from_array(&hessian_weights),
2111 score_weights: PsdWeightsView::try_from_array(&score_weights).expect("psd weights"),
2112 working_response: &working_response,
2113 eta: &eta,
2114 offset: &offset,
2115 link: LinkFunction::Logit,
2116 phi: 1.0,
2117 penalty_root: None,
2118 ridge: 0.0,
2119 score_curvature: Some(&score_curvature),
2120 };
2121
2122 let err =
2123 compute_alo_from_input_inner(&input).expect_err("non-converged exact ALO must error");
2124 let msg = err.to_string();
2125 assert!(
2126 msg.contains("ALO exact frozen-curvature solve failed at row 0"),
2127 "missing row context in exact ALO error: {msg}"
2128 );
2129 assert!(
2130 msg.contains("did not converge within"),
2131 "missing non-convergence cause in exact ALO error: {msg}"
2132 );
2133 }
2134
2135 #[test]
2136 fn gaussian_unpenalized_direct_sandwich_equals_bayes() {
2137 let phi = 2.5;
2140 let x_hinv_x = 0.3;
2141 let vb = bayesvar_eta(phi, x_hinv_x);
2142 let vs = sandwichvar_eta_from_meat(phi, x_hinv_x);
2143 assert!((vb - vs).abs() < 1e-12);
2144 }
2145
2146 #[test]
2147 fn sandwich_from_direct_meat_scales_by_phi() {
2148 let phi = 1.7;
2149 let meat_quad = 0.358;
2150 let got = sandwichvar_eta_from_meat(phi, meat_quad);
2151 let expected = phi * meat_quad;
2152 assert!((got - expected).abs() < 1e-12);
2153 }
2154
2155 #[test]
2156 fn sandwich_meat_uses_score_weights_not_hessian_weights_noncanonical() {
2157 let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 1.0, 2.0, 1.0]).unwrap();
2166 let w_h_vec = Array1::from_vec(vec![1.0, -1.0, 1.0, -1.0, 0.5]);
2169 let w_s_vec = Array1::from_vec(vec![1.0, 0.8, 1.2, 0.6, 0.9]);
2171 let phi = 1.3;
2172
2173 let n = x.nrows();
2174 let sum_wh_x2: f64 = (0..n).map(|i| w_h_vec[i] * x[[i, 0]] * x[[i, 0]]).sum();
2175 let sum_ws_x2: f64 = (0..n).map(|i| w_s_vec[i] * x[[i, 0]] * x[[i, 0]]).sum();
2176 assert!(sum_wh_x2 < 0.0, "fixture must exercise a negative W_H meat");
2180 assert!(sum_ws_x2 > 0.0);
2181
2182 let s0 = 8.0_f64;
2184 let h = s0 + sum_wh_x2; assert!(h > 0.0, "penalized Hessian must stay PD");
2186 let penalized_hessian = Array2::from_elem((1, 1), h);
2187
2188 let old_meat_obs1 = x[[1, 0]] * x[[1, 0]] / (h * h) * sum_wh_x2;
2191 assert!(
2192 phi * old_meat_obs1 < -super::variance_negative_tolerance(phi * old_meat_obs1.abs()),
2193 "the pre-fix W_H meat must be materially negative (guard would trip)"
2194 );
2195
2196 let working_response = Array1::from_vec(vec![0.3, -0.2, 0.5, 0.1, -0.4]);
2197 let eta = Array1::from_vec(vec![0.2, 0.1, 0.4, -0.1, 0.05]);
2198 let offset = Array1::zeros(n);
2199 let input = AloInput {
2200 design: &x,
2201 penalized_hessian: &penalized_hessian,
2202 hessian_weights: SignedWeightsView::from_array(&w_h_vec),
2203 score_weights: PsdWeightsView::try_from_array(&w_s_vec).expect("psd weights"),
2204 working_response: &working_response,
2205 eta: &eta,
2206 offset: &offset,
2207 link: LinkFunction::Probit,
2208 phi,
2209 penalty_root: None,
2210 ridge: 0.0,
2211 score_curvature: None,
2212 };
2213
2214 let diag = compute_alo_from_input_inner(&input)
2216 .expect("fixed sandwich meat (W_S) must not trip the negative-variance guard");
2217
2218 for obs in 0..n {
2220 let expected =
2221 (phi * x[[obs, 0]] * x[[obs, 0]] / (h * h) * sum_ws_x2).sqrt();
2222 assert!(
2223 (diag.se_sandwich[obs] - expected).abs() <= 1e-10 * expected.max(1.0),
2224 "row {obs}: se_sandwich={} expected={expected}",
2225 diag.se_sandwich[obs]
2226 );
2227 }
2228 }
2229
2230 #[test]
2231 fn percentile_index_matches_expected_rounding() {
2232 assert_eq!(percentile_index(0, 0.95), 0);
2233 assert_eq!(percentile_index(1, 0.95), 0);
2234 assert_eq!(percentile_index(10, 0.50), 5);
2235 assert_eq!(percentile_index(10, 0.95), 9);
2236 }
2237
2238 #[test]
2239 fn percentile_from_sorted_returns_order_statistic() {
2240 let values = [1.0, 2.0, 3.0, 4.0, 5.0];
2241 assert_eq!(percentile_from_sorted(&values, 0.50), 3.0);
2242 assert_eq!(percentile_from_sorted(&values, 0.95), 5.0);
2243 assert_eq!(percentile_from_sorted(&[], 0.95), 0.0);
2244 }
2245
2246 use super::{MultiBlockAloInput, compute_multiblock_alo, compute_multiblock_alo_leverages};
2249 use ndarray::{Array1, Array2};
2250
2251 #[test]
2252 fn multiblock_b1_matches_scalar_leverage() {
2253 let n = 3;
2256 let p = 2;
2257 let x = Array2::from_shape_vec((n, p), vec![1.0, 0.5, 0.8, -0.3, 0.2, 1.1]).unwrap();
2258 let w = [1.0, 2.0, 0.5];
2260 let mut h = Array2::<f64>::eye(p);
2261 for i in 0..n {
2262 for r in 0..p {
2263 for c in 0..p {
2264 h[(r, c)] += w[i] * x[(i, r)] * x[(i, c)];
2265 }
2266 }
2267 }
2268 let det = h[(0, 0)] * h[(1, 1)] - h[(0, 1)] * h[(1, 0)];
2270 let mut h_inv = Array2::<f64>::zeros((p, p));
2271 h_inv[(0, 0)] = h[(1, 1)] / det;
2272 h_inv[(1, 1)] = h[(0, 0)] / det;
2273 h_inv[(0, 1)] = -h[(0, 1)] / det;
2274 h_inv[(1, 0)] = -h[(1, 0)] / det;
2275
2276 let mut scalar_lev = vec![0.0f64; n];
2278 for i in 0..n {
2279 let mut xhx = 0.0;
2280 for r in 0..p {
2281 for c in 0..p {
2282 xhx += x[(i, r)] * h_inv[(r, c)] * x[(i, c)];
2283 }
2284 }
2285 scalar_lev[i] = w[i] * xhx;
2286 }
2287
2288 let block_designs = vec![x.clone()];
2290 let block_weights: Vec<Array2<f64>> =
2291 w.iter().map(|&wi| Array2::from_elem((1, 1), wi)).collect();
2292 let scores: Vec<Array1<f64>> = (0..n).map(|_| Array1::from_vec(vec![0.1])).collect();
2293 let eta_hat: Vec<Array1<f64>> = (0..n).map(|i| Array1::from_vec(vec![i as f64])).collect();
2294
2295 let input = MultiBlockAloInput {
2296 n_obs: n,
2297 n_blocks: 1,
2298 block_designs: &block_designs,
2299 penalized_hessian_inv: &h_inv,
2300 block_weights,
2301 scores,
2302 eta_hat,
2303 };
2304
2305 let result = compute_multiblock_alo(&input).unwrap();
2306 for i in 0..n {
2307 assert!(
2308 (result.leverage[i] - scalar_lev[i]).abs() < 1e-10,
2309 "leverage mismatch at i={}: got {}, expected {}",
2310 i,
2311 result.leverage[i],
2312 scalar_lev[i]
2313 );
2314 }
2315 }
2316
2317 #[test]
2318 fn multiblock_leverage_only_matches_full() {
2319 let n = 4;
2322 let p1 = 2;
2323 let p2 = 3;
2324 let x1 = Array2::from_shape_fn((n, p1), |(i, j)| (i + j + 1) as f64 * 0.3);
2325 let x2 = Array2::from_shape_fn((n, p2), |(i, j)| (i * 2 + j) as f64 * 0.2 - 0.1);
2326 let p_tot = p1 + p2;
2327 let h_inv = Array2::<f64>::eye(p_tot); let block_weights: Vec<Array2<f64>> = (0..n)
2329 .map(|i| {
2330 let v = (i + 1) as f64;
2331 Array2::from_shape_vec((2, 2), vec![v, 0.1, 0.1, v * 0.5]).unwrap()
2332 })
2333 .collect();
2334 let scores: Vec<Array1<f64>> = (0..n).map(|_| Array1::from_vec(vec![0.0, 0.0])).collect();
2335 let eta_hat: Vec<Array1<f64>> = (0..n).map(|_| Array1::from_vec(vec![0.0, 0.0])).collect();
2336 let block_designs = vec![x1.clone(), x2.clone()];
2337
2338 let input = MultiBlockAloInput {
2339 n_obs: n,
2340 n_blocks: 2,
2341 block_designs: &block_designs,
2342 penalized_hessian_inv: &h_inv,
2343 block_weights: block_weights.clone(),
2344 scores,
2345 eta_hat,
2346 };
2347 let full = compute_multiblock_alo(&input).unwrap();
2348 let lev_only =
2349 compute_multiblock_alo_leverages(n, 2, &block_designs, &h_inv, &block_weights).unwrap();
2350
2351 for i in 0..n {
2352 assert!(
2353 (full.leverage[i] - lev_only[i]).abs() < 1e-12,
2354 "leverage mismatch at i={}: full={}, lev_only={}",
2355 i,
2356 full.leverage[i],
2357 lev_only[i]
2358 );
2359 }
2360 }
2361
2362 #[test]
2363 fn multiblock_singular_weight_still_corrects() {
2364 let n = 1;
2368 let p = 2;
2369 let x = Array2::from_shape_vec((1, p), vec![1.0, 0.5]).unwrap();
2370 let h_inv = Array2::eye(p);
2371 let block_designs = vec![x.clone()];
2372 let block_weights = vec![Array2::from_elem((1, 1), 0.0)]; let scores = vec![Array1::from_vec(vec![1.0])];
2374 let eta_hat = vec![Array1::from_vec(vec![std::f64::consts::PI])];
2375
2376 let input = MultiBlockAloInput {
2377 n_obs: n,
2378 n_blocks: 1,
2379 block_designs: &block_designs,
2380 penalized_hessian_inv: &h_inv,
2381 block_weights,
2382 scores,
2383 eta_hat,
2384 };
2385 let result = compute_multiblock_alo(&input).unwrap();
2386 let expected = std::f64::consts::PI + 1.25;
2388 assert!(
2389 (result.eta_tilde[0][0] - expected).abs() < 1e-12,
2390 "expected {}, got {}",
2391 expected,
2392 result.eta_tilde[0][0]
2393 );
2394 assert!(result.cook_distance[0].abs() < 1e-14);
2396 assert!(result.alo_variance[0][0].abs() < 1e-14);
2398 }
2399
2400 #[test]
2401 fn multiblock_cook_and_variance_basic() {
2402 let n = 1;
2404 let x = Array2::from_elem((1, 1), 1.0);
2405 let h_inv = Array2::from_elem((1, 1), 0.5);
2407 let block_designs = vec![x.clone()];
2408 let w_val = 2.0;
2409 let s_val = 0.4;
2410 let block_weights = vec![Array2::from_elem((1, 1), w_val)];
2411 let scores = vec![Array1::from_vec(vec![s_val])];
2412 let eta_hat = vec![Array1::from_vec(vec![1.0])];
2413
2414 let input = MultiBlockAloInput {
2415 n_obs: n,
2416 n_blocks: 1,
2417 block_designs: &block_designs,
2418 penalized_hessian_inv: &h_inv,
2419 block_weights,
2420 scores,
2421 eta_hat,
2422 };
2423 let result = compute_multiblock_alo(&input).unwrap();
2424
2425 assert!(result.eta_tilde[0][0].is_finite());
2432 assert!(result.cook_distance[0].is_finite());
2433 assert!(result.alo_variance[0][0].is_finite());
2434 }
2435}