1use super::*;
2use crate::mixture_link::fisher_weight_jet5_for_inverse_link;
3use gam_linalg::matrix::symmetrize_in_place;
4use gam_problem::InverseLink;
5
6pub(crate) const FIRTH_DERIVATIVE_PARALLEL_MIN_N: usize = 16_384;
7
8pub(crate) const FIRTH_REDUCED_FISHER_RCOND_WARN: f64 = 1e-10;
14
15struct FirthReducedCore {
21 w: Array1<f64>,
22 w1: Array1<f64>,
23 w2: Array1<f64>,
24 w3: Array1<f64>,
25 w4: Array1<f64>,
26 k_reduced: Array2<f64>,
27 half_log_det: f64,
28 h_diag: Array1<f64>,
29}
30
31pub(crate) struct FirthSecondDirEyeCache {
36 eye: Array2<f64>,
39 eta_rhs: Array2<f64>,
41 p_b_rhs: Array2<f64>,
43 p_bx: Vec<Array2<f64>>,
45 pu_qv: Vec<Array2<f64>>,
47}
48
49impl<'a> RemlState<'a> {
50 pub(crate) fn xt_diag_x_dense_into(
51 x: &Array2<f64>,
52 diag: &Array1<f64>,
53 weighted: &mut Array2<f64>,
54 ) -> Array2<f64> {
55 super::assembly::xt_diag_x_dense_into(x, diag, weighted)
56 }
57
58 #[inline]
59 pub(crate) fn parallelize_firth_derivative_rows(n: usize) -> bool {
60 n >= FIRTH_DERIVATIVE_PARALLEL_MIN_N && rayon::current_num_threads() > 1
61 }
62
63 pub(crate) fn row_scale(x: &Array2<f64>, scale: &Array1<f64>) -> Array2<f64> {
64 let mut out = Array2::<f64>::zeros(x.raw_dim());
65 super::assembly::row_scale_dense_into(x, scale, &mut out);
66 out
67 }
68
69 #[inline]
70 pub(crate) fn dense_product_likely_uses_inner_parallelism(
71 m: usize,
72 n: usize,
73 k: usize,
74 ) -> bool {
75 const PAR_MIN_FLOP_SCALE: usize = 2_000_000;
80 const PAR_MIN_LONG_DIM: usize = 256;
81 let flop_scale = m.saturating_mul(n).saturating_mul(k);
82 let long_dim = m.max(n).max(k);
83 flop_scale >= PAR_MIN_FLOP_SCALE && long_dim >= PAR_MIN_LONG_DIM
84 }
85
86 #[inline]
87 pub(crate) fn should_join_independent_dense_products(
88 products: &[(usize, usize, usize)],
89 ) -> bool {
90 const JOIN_MIN_TOTAL_FLOP_SCALE: usize = 128 * 1024;
91 if rayon::current_num_threads() <= 1 {
92 return false;
93 }
94 let mut total_flop_scale = 0usize;
95 for &(m, n, k) in products {
96 if Self::dense_product_likely_uses_inner_parallelism(m, n, k) {
97 return false;
98 }
99 total_flop_scale =
100 total_flop_scale.saturating_add(m.saturating_mul(n).saturating_mul(k));
101 }
102 total_flop_scale >= JOIN_MIN_TOTAL_FLOP_SCALE
103 }
104
105 #[inline]
112 pub(crate) fn scale_rows_by_inverse_observation_weight_sqrt(
113 out: &mut Array2<f64>,
114 observation_weight_sqrt: Option<&Array1<f64>>,
115 ) {
116 let Some(scale) = observation_weight_sqrt else {
117 return;
118 };
119 super::assembly::row_scale_dense_in_place_by_inverse_positive_or_zero(out, scale);
120 }
121
122 #[inline]
127 pub(crate) fn fisher_weight_derivatives(
128 link: &InverseLink,
129 eta: f64,
130 ) -> Result<(f64, f64, f64, f64, f64), EstimationError> {
131 fisher_weight_jet5_for_inverse_link(link, eta)
132 }
133
134 #[inline]
135 pub(crate) fn cholesky_pivots_are_numerically_resolved(chol_diag: &Array1<f64>) -> bool {
136 let mut min_pivot_sq = f64::INFINITY;
137 let mut max_pivot_sq = 0.0_f64;
138 for &pivot in chol_diag {
139 if !pivot.is_finite() || pivot <= 0.0 {
140 return false;
141 }
142 let pivot_sq = pivot * pivot;
143 min_pivot_sq = min_pivot_sq.min(pivot_sq);
144 max_pivot_sq = max_pivot_sq.max(pivot_sq);
145 }
146 if !min_pivot_sq.is_finite() {
147 return false;
148 }
149 let scale = max_pivot_sq.max(1.0);
150 let floor = (chol_diag.len().max(1) as f64) * f64::EPSILON * scale;
151 min_pivot_sq > floor
152 }
153
154 pub(crate) fn reduced_fisher_inverse_and_half_logdet(
155 fisher_reduced: &Array2<f64>,
156 ) -> Result<(Array2<f64>, f64), EstimationError> {
157 let r = fisher_reduced.nrows();
158 assert_eq!(r, fisher_reduced.ncols());
159 let mut k_reduced = Array2::<f64>::zeros((r, r));
160 if r == 0 {
161 return Ok((k_reduced, 0.0));
162 }
163
164 if let Ok(chol) = fisher_reduced.cholesky(Side::Lower) {
165 let chol_diag = chol.diag();
166 if Self::cholesky_pivots_are_numerically_resolved(&chol_diag) {
167 let half_log_det = chol_diag.iter().map(|d| d.ln()).sum::<f64>();
168 for col in 0..r {
169 let mut e_col = Array1::<f64>::zeros(r);
170 e_col[col] = 1.0;
171 let solved = chol.solvevec(&e_col);
172 k_reduced.column_mut(col).assign(&solved);
173 }
174 return Ok((k_reduced, half_log_det));
175 }
176 }
177
178 let (evals_ir, evecs_ir) = fisher_reduced
179 .eigh(Side::Lower)
180 .map_err(EstimationError::EigendecompositionFailed)?;
181 let max_eval = evals_ir.iter().copied().fold(0.0_f64, f64::max).max(1.0);
182 let tol = (r.max(1) as f64) * f64::EPSILON * max_eval;
183 let mut kept_positive_direction = false;
184 let mut half_log_det = 0.0_f64;
185 for (eig_idx, &eig) in evals_ir.iter().enumerate() {
186 if eig > tol {
187 kept_positive_direction = true;
188 half_log_det += 0.5 * eig.ln();
189 let inv = eig.recip();
190 let vec = evecs_ir.column(eig_idx).to_owned();
191 for row in 0..r {
192 for col in 0..r {
193 k_reduced[[row, col]] += inv * vec[row] * vec[col];
194 }
195 }
196 }
197 }
198 if !kept_positive_direction {
199 return Err(EstimationError::ModelIsIllConditioned {
200 condition_number: f64::INFINITY,
201 });
202 }
203 Ok((k_reduced, half_log_det))
204 }
205
206 pub(crate) fn fill_fisher_weight_derivative_arrays(
207 link: &InverseLink,
208 eta: &Array1<f64>,
209 w: &mut Array1<f64>,
210 w1: &mut Array1<f64>,
211 w2: &mut Array1<f64>,
212 w3: &mut Array1<f64>,
213 w4: &mut Array1<f64>,
214 ) -> Result<(), EstimationError> {
215 assert_eq!(eta.len(), w.len());
216 assert_eq!(eta.len(), w1.len());
217 assert_eq!(eta.len(), w2.len());
218 assert_eq!(eta.len(), w3.len());
219 assert_eq!(eta.len(), w4.len());
220
221 if Self::parallelize_firth_derivative_rows(eta.len()) {
222 let values: Result<Vec<_>, EstimationError> = eta
223 .par_iter()
224 .map(|&ei| Self::fisher_weight_derivatives(link, ei))
225 .collect();
226 for (i, (value, first, second, third, fourth)) in values?.into_iter().enumerate() {
227 w[i] = value;
228 w1[i] = first;
229 w2[i] = second;
230 w3[i] = third;
231 w4[i] = fourth;
232 }
233 return Ok(());
234 }
235 for i in 0..eta.len() {
236 let (value, first, second, third, fourth) =
237 Self::fisher_weight_derivatives(link, eta[i])?;
238 w[i] = value;
239 w1[i] = first;
240 w2[i] = second;
241 w3[i] = third;
242 w4[i] = fourth;
243 }
244 Ok(())
245 }
246
247 pub(crate) fn weighted_cross(
248 left: &Array2<f64>,
249 right: &Array2<f64>,
250 weights: &Array1<f64>,
251 ) -> Array2<f64> {
252 assert_eq!(left.nrows(), right.nrows());
253 assert_eq!(left.nrows(), weights.len());
254 super::assembly::weighted_cross_dense(left, right, weights)
255 }
256
257 pub(crate) fn trace_product(a: &Array2<f64>, b: &Array2<f64>) -> f64 {
258 assert_eq!(a.nrows(), b.ncols());
259 assert_eq!(a.ncols(), b.nrows());
260 let elems = a.nrows().saturating_mul(a.ncols());
261 if elems >= 32 * 32 {
262 let aview = FaerArrayView::new(a);
263 let bview = FaerArrayView::new(b);
264 return faer_frob_inner(aview.as_ref(), bview.as_ref().transpose());
265 }
266 let m = a.nrows();
267 let n = a.ncols();
268 kahan_sum((0..m).map(|i| {
269 let mut acc = 0.0_f64;
270 for j in 0..n {
271 acc += a[[i, j]] * b[[j, i]];
272 }
273 acc
274 }))
275 }
276
277 pub(crate) fn reducedweighted_gram(z: &Array2<f64>, weights: &Array1<f64>) -> Array2<f64> {
278 let weighted = Self::row_scale(z, weights);
285 fast_atb(z, &weighted)
286 }
287
288 pub(crate) fn reduced_crossweighted_gram(
289 z_left: &Array2<f64>,
290 z_right: &Array2<f64>,
291 weights: &Array1<f64>,
292 ) -> Array2<f64> {
293 let weighted = Self::row_scale(z_right, weights);
298 fast_atb(z_left, &weighted)
299 }
300
301 pub(crate) fn reduced_diag_gram(z: &Array2<f64>, a: &Array2<f64>) -> Array1<f64> {
302 let za = fast_ab(z, a);
308 (z * &za).sum_axis(ndarray::Axis(1))
309 }
310
311 pub(crate) fn apply_hadamard_gram(
312 z: &Array2<f64>,
313 a_left: &Array2<f64>,
314 a_right: &Array2<f64>,
315 vec: &Array1<f64>,
316 ) -> Array1<f64> {
317 let s = Self::reducedweighted_gram(z, vec);
326 let left_s = a_left.dot(&s);
327 let t = left_s.dot(a_right);
328 Self::reduced_diag_gram(z, &t)
329 }
330
331 pub(crate) fn apply_hadamard_gram_to_matrix(
332 z: &Array2<f64>,
333 a_left: &Array2<f64>,
334 a_right: &Array2<f64>,
335 mat: &Array2<f64>,
336 ) -> Array2<f64> {
337 let mut out = Array2::<f64>::zeros(mat.raw_dim());
345 for col in 0..mat.ncols() {
346 let v = mat.column(col).to_owned();
347 let y = Self::apply_hadamard_gram(z, a_left, a_right, &v);
348 out.column_mut(col).assign(&y);
349 }
350 out
351 }
352
353 pub(super) fn build_firth_dense_operator_for_link(
358 link: &InverseLink,
359 x_dense: &Array2<f64>,
360 eta: &Array1<f64>,
361 observation_weights: ndarray::ArrayView1<'_, f64>,
362 ) -> Result<FirthDenseOperator, EstimationError> {
363 FirthDenseOperator::build_with_observation_weights_impl(
364 link,
365 x_dense,
366 eta,
367 Some(observation_weights),
368 )
369 }
370
371 pub(super) fn firth_exact_tau_kernel(
372 op: &FirthDenseOperator,
373 x_tau: &Array2<f64>,
374 beta: &Array1<f64>,
375 include_hphi_tau_kernel: bool,
376 ) -> FirthTauExactKernel {
377 op.exact_tau_kernel(x_tau, beta, include_hphi_tau_kernel)
378 }
379
380 pub(super) fn firth_hphi_tau_partial_apply(
381 op: &FirthDenseOperator,
382 x_tau: &Array2<f64>,
383 kernel: &FirthTauPartialKernel,
384 rhs: &Array2<f64>,
385 ) -> Array2<f64> {
386 op.hphi_tau_partial_apply(x_tau, kernel, rhs)
387 }
388}
389
390impl FirthDenseOperator {
391 pub(crate) fn canonicalize_basis_column_signs(q_basis: &mut Array2<f64>) {
392 for col in 0..q_basis.ncols() {
393 let mut pivot_row = 0usize;
394 let mut pivot_abs = 0.0_f64;
395 for row in 0..q_basis.nrows() {
396 let value = q_basis[[row, col]];
397 let abs_value = value.abs();
398 if abs_value > pivot_abs {
399 pivot_abs = abs_value;
400 pivot_row = row;
401 }
402 }
403 if pivot_abs > 0.0 && q_basis[[pivot_row, col]] < 0.0 {
404 q_basis.column_mut(col).mapv_inplace(|v| -v);
405 }
406 }
407 }
408
409 pub(crate) fn identifiable_subspace_basis_from_gram(
410 gram: &Array2<f64>,
411 ) -> Result<(Array2<f64>, Array1<f64>), EstimationError> {
412 let p = gram.nrows();
413 assert_eq!(p, gram.ncols());
414 if p == 0 {
415 return Ok((Array2::<f64>::eye(0), Array1::<f64>::zeros(0)));
416 }
417
418 let (evals, evecs) = gram
419 .eigh(Side::Lower)
420 .map_err(EstimationError::EigendecompositionFailed)?;
421 let max_eval = evals.iter().copied().fold(0.0_f64, f64::max).max(1.0);
422 let tol = (p.max(1) as f64) * f64::EPSILON * max_eval;
423 let mut keep: Vec<usize> = evals
424 .iter()
425 .enumerate()
426 .filter_map(|(i, &value)| if value > tol { Some(i) } else { None })
427 .collect();
428 if keep.is_empty() {
429 return Err(EstimationError::ModelIsIllConditioned {
430 condition_number: f64::INFINITY,
431 });
432 }
433
434 keep.sort_by(|&lhs, &rhs| evals[rhs].total_cmp(&evals[lhs]));
440 let r = keep.len();
441 let mut q_basis = Array2::<f64>::zeros((p, r));
442 let mut metric_spectrum = Array1::<f64>::zeros(r);
443 for (col_idx, eig_idx) in keep.into_iter().enumerate() {
444 q_basis.column_mut(col_idx).assign(&evecs.column(eig_idx));
445 metric_spectrum[col_idx] = evals[eig_idx];
446 }
447 Self::canonicalize_basis_column_signs(&mut q_basis);
448 Ok((q_basis, metric_spectrum))
449 }
450
451 #[inline]
452 pub(crate) fn trace_diag_product(diag: &Array1<f64>, matrix: &Array2<f64>) -> f64 {
453 assert_eq!(diag.len(), matrix.nrows());
454 assert_eq!(matrix.nrows(), matrix.ncols());
455 kahan_sum((0..diag.len()).map(|i| diag[i] * matrix[[i, i]]))
456 }
457
458 pub fn build_for_link(
459 link: &InverseLink,
460 x_dense: &Array2<f64>,
461 eta: &Array1<f64>,
462 ) -> Result<FirthDenseOperator, EstimationError> {
463 Self::build_with_observation_weights_impl(link, x_dense, eta, None)
464 }
465
466 pub fn build_with_observation_weights_for_link(
467 link: &InverseLink,
468 x_dense: &Array2<f64>,
469 eta: &Array1<f64>,
470 observation_weights: ndarray::ArrayView1<'_, f64>,
471 ) -> Result<FirthDenseOperator, EstimationError> {
472 Self::build_with_observation_weights_impl(link, x_dense, eta, Some(observation_weights))
473 }
474
475 pub(crate) fn build_design_factor_with_observation_weights(
485 x_dense: &Array2<f64>,
486 observation_weights: Option<ndarray::ArrayView1<'_, f64>>,
487 ) -> Result<FirthDesignFactor, EstimationError> {
488 let n = x_dense.nrows();
489 let observation_weight_sqrt = if let Some(weights) = observation_weights {
490 if weights.len() != n {
491 crate::bail_invalid_estim!(
492 "Firth operator observation weight length {} != number of rows {}",
493 weights.len(),
494 n
495 );
496 }
497 let mut sqrt = Array1::<f64>::zeros(n);
498 for i in 0..n {
499 let weight = weights[i];
500 if !weight.is_finite() || weight < 0.0 {
501 crate::bail_invalid_estim!(
502 "Firth operator requires finite nonnegative observation weights, got {} at row {}",
503 weight,
504 i
505 );
506 }
507 sqrt[i] = weight.sqrt();
508 }
509 Some(sqrt)
510 } else {
511 None
512 };
513 let basis_design = if let Some(scale) = observation_weight_sqrt.as_ref() {
514 RemlState::row_scale(x_dense, scale)
515 } else {
516 x_dense.clone()
517 };
518 let gram = fast_atb(&basis_design, &basis_design);
520 let (q_basis, metric_spectrum) = Self::identifiable_subspace_basis_from_gram(&gram)?;
521 let x_reduced = fast_ab(&basis_design, &q_basis);
522 let r = q_basis.ncols();
523 let mut x_metric_reduced_inv_diag = Array1::<f64>::zeros(r);
524 for col in 0..r {
525 x_metric_reduced_inv_diag[col] = metric_spectrum[col].recip();
526 }
527 let x_dense_t = x_dense.t().to_owned();
528 Ok(FirthDesignFactor {
529 x_dense: x_dense.clone(),
530 x_dense_t,
531 q_basis,
532 x_reduced,
533 observation_weight_sqrt,
534 metric_spectrum,
535 x_metric_reduced_inv_diag,
536 r,
537 n,
538 })
539 }
540
541 fn firth_reduced_core(
548 factor: &FirthDesignFactor,
549 link: &InverseLink,
550 eta: &Array1<f64>,
551 ) -> Result<FirthReducedCore, EstimationError> {
552 let n = factor.n;
553 if eta.len() != n {
554 crate::bail_invalid_estim!(
555 "Firth operator shape mismatch: nrows={}, eta_len={}",
556 n,
557 eta.len()
558 );
559 }
560 let r = factor.r;
561 let mut w = Array1::<f64>::zeros(n);
562 let mut w1 = Array1::<f64>::zeros(n);
563 let mut w2 = Array1::<f64>::zeros(n);
564 let mut w3 = Array1::<f64>::zeros(n);
565 let mut w4 = Array1::<f64>::zeros(n);
566 RemlState::fill_fisher_weight_derivative_arrays(
567 link, eta, &mut w, &mut w1, &mut w2, &mut w3, &mut w4,
568 )?;
569
570 let fisher_reduced = gam_linalg::faer_ndarray::fast_xt_diag_x(&factor.x_reduced, &w);
572 if let Ok((eigvals_ir, _)) = fisher_reduced.eigh(Side::Lower) {
573 let max_ev = eigvals_ir.iter().copied().fold(0.0_f64, f64::max).max(1.0);
574 let min_ev = eigvals_ir
575 .iter()
576 .copied()
577 .filter(|v| v.is_finite() && *v > 0.0)
578 .fold(f64::INFINITY, f64::min);
579 if min_ev.is_finite() {
580 let rel = min_ev / max_ev;
581 if rel < FIRTH_REDUCED_FISHER_RCOND_WARN {
582 log::warn!(
583 "[REML/Firth] reduced Fisher I_r is near-singular (min/max={:.3e}/{:.3e}, rel={:.3e}); exact derivatives may be ill-conditioned near active-subspace boundaries.",
584 min_ev,
585 max_ev,
586 rel
587 );
588 }
589 }
590 }
591
592 let (k_reduced, mut half_log_det) = if r > 0 {
593 RemlState::reduced_fisher_inverse_and_half_logdet(&fisher_reduced)?
594 } else {
595 (Array2::<f64>::zeros((r, r)), 0.0)
596 };
597 if r > 0 {
598 for col in 0..r {
599 let metric_eig = factor.metric_spectrum[col];
600 half_log_det -= 0.5 * metric_eig.ln();
601 }
602 }
603 let h_diag = if r > 0 {
604 RemlState::reduced_diag_gram(&factor.x_reduced, &k_reduced)
605 } else {
606 Array1::<f64>::zeros(n)
607 };
608 Ok(FirthReducedCore {
609 w,
610 w1,
611 w2,
612 w3,
613 w4,
614 k_reduced,
615 half_log_det,
616 h_diag,
617 })
618 }
619
620 pub(crate) fn build_from_design_factor(
624 factor: &FirthDesignFactor,
625 link: &InverseLink,
626 eta: &Array1<f64>,
627 ) -> Result<FirthDenseOperator, EstimationError> {
628 let FirthReducedCore {
629 w,
630 w1,
631 w2,
632 w3,
633 w4,
634 k_reduced,
635 half_log_det,
636 h_diag,
637 } = Self::firth_reduced_core(factor, link, eta)?;
638 let b_base = RemlState::row_scale(&factor.x_dense, &w1);
639 let p_b_base = RemlState::apply_hadamard_gram_to_matrix(
640 &factor.x_reduced,
641 &k_reduced,
642 &k_reduced,
643 &b_base,
644 );
645 Ok(FirthDenseOperator {
646 x_dense: factor.x_dense.clone(),
647 x_dense_t: factor.x_dense_t.clone(),
648 q_basis: factor.q_basis.clone(),
649 x_reduced: factor.x_reduced.clone(),
650 observation_weight_sqrt: factor.observation_weight_sqrt.clone(),
651 k_reduced,
652 x_metric_reduced_inv_diag: factor.x_metric_reduced_inv_diag.clone(),
653 half_log_det,
654 h_diag,
655 w,
656 w1,
657 w2,
658 w3,
659 w4,
660 b_base,
661 p_b_base,
662 })
663 }
664
665 pub(crate) fn build_with_observation_weights_impl(
666 link: &InverseLink,
667 x_dense: &Array2<f64>,
668 eta: &Array1<f64>,
669 observation_weights: Option<ndarray::ArrayView1<'_, f64>>,
670 ) -> Result<FirthDenseOperator, EstimationError> {
671 let n = x_dense.nrows();
732 if eta.len() != n {
733 crate::bail_invalid_estim!(
734 "Firth operator shape mismatch: nrows={}, eta_len={}",
735 n,
736 eta.len()
737 );
738 }
739 let factor =
740 Self::build_design_factor_with_observation_weights(x_dense, observation_weights)?;
741 Self::build_from_design_factor(&factor, link, eta)
742 }
743
744 #[inline]
745 pub(crate) fn jeffreys_logdet(&self) -> f64 {
746 self.half_log_det
747 }
748
749 pub(crate) fn jeffreys_logdet_projected(&self, z: ndarray::ArrayView2<'_, f64>) -> f64 {
762 use gam_linalg::faer_ndarray::{fast_ab, fast_xt_diag_x};
763 let p = self.x_dense.ncols();
764 assert_eq!(
765 z.nrows(),
766 p,
767 "jeffreys_logdet_projected: Z must have {} rows (β-space dim), got {}",
768 p,
769 z.nrows()
770 );
771 let m = z.ncols();
772 if m == 0 {
773 return 0.0;
774 }
775 let z_owned = z.to_owned();
777 let xz = fast_ab(&self.x_dense, &z_owned);
778 let xtz = if let Some(scale) = self.observation_weight_sqrt.as_ref() {
779 RemlState::row_scale(&xz, scale)
780 } else {
781 xz
782 };
783 let mut j_t = fast_xt_diag_x(&xtz, &self.w);
785 symmetrize_in_place(&mut j_t);
786 let (evals, _) = match j_t.eigh(Side::Lower) {
787 Ok(pair) => pair,
788 Err(_) => return f64::NEG_INFINITY,
789 };
790 let Some(evals_slice) = evals.as_slice() else {
791 return f64::NEG_INFINITY;
792 };
793 let threshold = super::reml_outer_engine::positive_eigenvalue_threshold(evals_slice);
794 0.5 * super::reml_outer_engine::exact_pseudo_logdet(evals_slice, threshold)
795 }
796
797 #[inline]
798 pub(crate) fn jeffreys_beta_gradient(&self) -> Array1<f64> {
799 0.5 * gam_linalg::faer_ndarray::fast_av(&self.x_dense_t, &(&self.w1 * &self.h_diag))
804 }
805
806 #[inline]
807 pub fn jeffreys_logdet_and_beta_gradient(&self) -> (f64, Array1<f64>) {
808 (self.jeffreys_logdet(), self.jeffreys_beta_gradient())
809 }
810
811 #[inline]
812 pub(crate) fn reduce_explicit_design(&self, x: &Array2<f64>) -> Array2<f64> {
813 let mut reduced = fast_ab(x, &self.q_basis);
814 if let Some(scale) = self.observation_weight_sqrt.as_ref() {
815 reduced = RemlState::row_scale(&reduced, scale);
816 }
817 reduced
818 }
819
820 pub(crate) fn direction_from_deta(&self, deta: Array1<f64>) -> FirthDirection {
821 let s_u = &self.w1 * &deta;
837 let g_u_reduced = RemlState::reducedweighted_gram(&self.x_reduced, &s_u);
842 let k_g_u = self.k_reduced.dot(&g_u_reduced);
843 let a_u_reduced = k_g_u.dot(&self.k_reduced);
844 let dh = -RemlState::reduced_diag_gram(&self.x_reduced, &a_u_reduced);
847 let b_uvec = &self.w2 * &deta;
848 FirthDirection {
849 deta,
850 g_u_reduced,
851 a_u_reduced,
852 dh,
853 b_uvec,
854 }
855 }
856
857 #[inline]
858 pub(crate) fn left_scaled_xt(&self, scale: &Array1<f64>, mat: &Array2<f64>) -> Array2<f64> {
859 fast_ab(&self.x_dense_t, &(mat * &scale.view().insert_axis(Axis(1))))
860 }
861
862 #[inline]
863 pub(crate) fn apply_p_u_to_matrix(
864 &self,
865 a_u_reduced: &Array2<f64>,
866 mat: &Array2<f64>,
867 ) -> Array2<f64> {
868 let mut out = RemlState::apply_hadamard_gram_to_matrix(
869 &self.x_reduced,
870 &self.k_reduced,
871 a_u_reduced,
872 mat,
873 );
874 out.mapv_inplace(|v| -2.0 * v);
875 out
876 }
877
878 pub(crate) fn hphi_direction_apply(
879 &self,
880 dir: &FirthDirection,
881 rhs: &Array2<f64>,
882 ) -> Array2<f64> {
883 let p = self.x_dense.ncols();
884 if rhs.nrows() != p {
885 return Array2::<f64>::zeros((p, rhs.ncols()));
886 }
887 if rhs.ncols() == 0 || p == 0 {
888 return Array2::<f64>::zeros((p, rhs.ncols()));
889 }
890 let etav = fast_ab(&self.x_dense, rhs);
897 let qv = &etav * &self.w1.view().insert_axis(Axis(1));
898 let m_qv = RemlState::apply_hadamard_gram_to_matrix(
899 &self.x_reduced,
900 &self.k_reduced,
901 &self.k_reduced,
902 &qv,
903 );
904 let buvec = &dir.b_uvec;
905 let m_buv = RemlState::apply_hadamard_gram_to_matrix(
906 &self.x_reduced,
907 &self.k_reduced,
908 &self.k_reduced,
909 &(&etav * &buvec.view().insert_axis(Axis(1))),
910 );
911 let p_u_qv = self.apply_p_u_to_matrix(&dir.a_u_reduced, &qv);
912 let c_u = &(&self.w3 * &dir.deta) * &self.h_diag + &(&self.w2 * &dir.dh);
913 let diag_term = self
914 .x_dense_t
915 .dot(&(&etav * &c_u.view().insert_axis(Axis(1))));
916 let term1 = self.left_scaled_xt(buvec, &m_qv);
917 let term2 = self.left_scaled_xt(&self.w1, &m_buv);
918 let term3 = self.left_scaled_xt(&self.w1, &p_u_qv);
919 0.5 * (diag_term - (term1 + term2 + term3))
920 }
921
922 pub(crate) fn hphi_direction(&self, dir: &FirthDirection) -> Array2<f64> {
923 let p = self.x_dense.ncols();
924 let eye = Array2::<f64>::eye(p);
925 let mut out = self.hphi_direction_apply(dir, &eye);
926 symmetrize_in_place(&mut out);
939 out
940 }
941
942 pub(crate) fn hphisecond_direction_apply(
943 &self,
944 u: &FirthDirection,
945 v: &FirthDirection,
946 rhs: &Array2<f64>,
947 ) -> Array2<f64> {
948 let p = self.x_dense.ncols();
949 if rhs.nrows() != p {
950 return Array2::<f64>::zeros((p, rhs.ncols()));
951 }
952 if rhs.ncols() == 0 || p == 0 {
953 return Array2::<f64>::zeros((p, rhs.ncols()));
954 }
955 let deta_uv = &u.deta * &v.deta;
967 let s_uv = &self.w2 * &deta_uv;
970 let g_uv_reduced = RemlState::reducedweighted_gram(&self.x_reduced, &s_uv);
971 let k_g_uv = self.k_reduced.dot(&g_uv_reduced);
972 let k_gv = self.k_reduced.dot(&v.g_u_reduced);
973 let k_g_u = self.k_reduced.dot(&u.g_u_reduced);
974 let a_uv_reduced = k_g_uv.dot(&self.k_reduced)
977 - k_gv.dot(&k_g_u).dot(&self.k_reduced)
978 - k_g_u.dot(&k_gv).dot(&self.k_reduced);
979 let d2h = -RemlState::reduced_diag_gram(&self.x_reduced, &a_uv_reduced);
980 let c_uv = &(&(&self.w4 * &deta_uv) * &self.h_diag)
985 + &(&self.w3 * &(&u.deta * &v.dh))
986 + &(&self.w3 * &(&v.deta * &u.dh))
987 + &(&self.w2 * &d2h);
988
989 let eta_rhs = fast_ab(&self.x_dense, rhs);
990 let diag_term = fast_ab(
991 &self.x_dense_t,
992 &(&eta_rhs * &c_uv.view().insert_axis(Axis(1))),
993 );
994
995 let b_uvvec = &self.w3 * &deta_uv;
996 let b_uv_base = &self.x_dense * &b_uvvec.view().insert_axis(Axis(1));
997 let qv = &eta_rhs * &self.w1.view().insert_axis(Axis(1));
998
999 let p_b_rhs = fast_ab(&self.p_b_base, rhs);
1004 let p_bu_rhs = RemlState::apply_hadamard_gram_to_matrix(
1005 &self.x_reduced,
1006 &self.k_reduced,
1007 &self.k_reduced,
1008 &(&eta_rhs * &u.b_uvec.view().insert_axis(Axis(1))),
1009 );
1010 let p_bv_rhs = RemlState::apply_hadamard_gram_to_matrix(
1011 &self.x_reduced,
1012 &self.k_reduced,
1013 &self.k_reduced,
1014 &(&eta_rhs * &v.b_uvec.view().insert_axis(Axis(1))),
1015 );
1016 let p_buv_base = RemlState::apply_hadamard_gram_to_matrix(
1017 &self.x_reduced,
1018 &self.k_reduced,
1019 &self.k_reduced,
1020 &b_uv_base,
1021 );
1022 let p_buv_rhs = fast_ab(&p_buv_base, rhs);
1023
1024 let pv_b_rhs = self.apply_p_u_to_matrix(&v.a_u_reduced, &qv);
1025 let pv_bu_rhs = self.apply_p_u_to_matrix(
1026 &v.a_u_reduced,
1027 &(&eta_rhs * &u.b_uvec.view().insert_axis(Axis(1))),
1028 );
1029 let p_u_b_rhs = self.apply_p_u_to_matrix(&u.a_u_reduced, &qv);
1030 let p_u_bv_rhs = self.apply_p_u_to_matrix(
1031 &u.a_u_reduced,
1032 &(&eta_rhs * &v.b_uvec.view().insert_axis(Axis(1))),
1033 );
1034
1035 let p_nu_nv_base = RemlState::apply_hadamard_gram_to_matrix(
1036 &self.x_reduced,
1037 &u.a_u_reduced,
1038 &v.a_u_reduced,
1039 &self.b_base,
1040 );
1041 let p_hw_nuv_base = RemlState::apply_hadamard_gram_to_matrix(
1042 &self.x_reduced,
1043 &self.k_reduced,
1044 &a_uv_reduced,
1045 &self.b_base,
1046 );
1047 let p_uv_base = 2.0 * p_nu_nv_base - 2.0 * p_hw_nuv_base;
1048 let p_uv_rhs = fast_ab(&p_uv_base, rhs);
1049
1050 let d2_terms = [
1052 self.left_scaled_xt(&b_uvvec, &p_b_rhs),
1053 self.left_scaled_xt(&self.w1, &p_buv_rhs),
1054 self.left_scaled_xt(&u.b_uvec, &p_bv_rhs),
1055 self.left_scaled_xt(&v.b_uvec, &p_bu_rhs),
1056 self.left_scaled_xt(&u.b_uvec, &pv_b_rhs),
1057 self.left_scaled_xt(&self.w1, &pv_bu_rhs),
1058 self.left_scaled_xt(&v.b_uvec, &p_u_b_rhs),
1059 self.left_scaled_xt(&self.w1, &p_u_bv_rhs),
1060 self.left_scaled_xt(&self.w1, &p_uv_rhs),
1061 ];
1062 let mut d2_j2 = Array2::<f64>::zeros((p, rhs.ncols()));
1063 for term in d2_terms {
1064 d2_j2 += &term;
1065 }
1066
1067 0.5 * (diag_term - d2_j2)
1068 }
1069
1070 pub(crate) fn tk_second_direction_eye_cache(
1084 &self,
1085 dirs: &[FirthDirection],
1086 ) -> FirthSecondDirEyeCache {
1087 let p = self.x_dense.ncols();
1088 let eye = Array2::<f64>::eye(p);
1089 let eta_rhs = fast_ab(&self.x_dense, &eye);
1091 let qv = &eta_rhs * &self.w1.view().insert_axis(Axis(1));
1092 let p_b_rhs = fast_ab(&self.p_b_base, &eye);
1094 let compute_blocks = |d: &FirthDirection| -> (Array2<f64>, Array2<f64>) {
1104 let p_b = RemlState::apply_hadamard_gram_to_matrix(
1106 &self.x_reduced,
1107 &self.k_reduced,
1108 &self.k_reduced,
1109 &(&eta_rhs * &d.b_uvec.view().insert_axis(Axis(1))),
1110 );
1111 let pu = self.apply_p_u_to_matrix(&d.a_u_reduced, &qv);
1113 (p_b, pu)
1114 };
1115 let (p_bx, pu_qv): (Vec<Array2<f64>>, Vec<Array2<f64>>) =
1116 if dirs.len() > 1 && rayon::current_num_threads() > 1 {
1117 use rayon::prelude::*;
1118 dirs.par_iter()
1119 .map(|d| gam_problem::with_nested_parallel(|| compute_blocks(d)))
1120 .unzip()
1121 } else {
1122 dirs.iter().map(compute_blocks).unzip()
1123 };
1124 FirthSecondDirEyeCache {
1125 eye,
1126 eta_rhs,
1127 p_b_rhs,
1128 p_bx,
1129 pu_qv,
1130 }
1131 }
1132
1133 pub(crate) fn hphisecond_direction_apply_eye_cached(
1140 &self,
1141 cache: &FirthSecondDirEyeCache,
1142 dirs: &[FirthDirection],
1143 i: usize,
1144 j: usize,
1145 ) -> Array2<f64> {
1146 let u = &dirs[i];
1147 let v = &dirs[j];
1148 let p = self.x_dense.ncols();
1149 let cols = cache.eta_rhs.ncols();
1150 if p == 0 || cols == 0 {
1151 return Array2::<f64>::zeros((p, cols));
1152 }
1153 let deta_uv = &u.deta * &v.deta;
1154 let s_uv = &self.w2 * &deta_uv;
1155 let g_uv_reduced = RemlState::reducedweighted_gram(&self.x_reduced, &s_uv);
1156 let k_g_uv = self.k_reduced.dot(&g_uv_reduced);
1157 let k_gv = self.k_reduced.dot(&v.g_u_reduced);
1158 let k_g_u = self.k_reduced.dot(&u.g_u_reduced);
1159 let a_uv_reduced = k_g_uv.dot(&self.k_reduced)
1160 - k_gv.dot(&k_g_u).dot(&self.k_reduced)
1161 - k_g_u.dot(&k_gv).dot(&self.k_reduced);
1162 let d2h = -RemlState::reduced_diag_gram(&self.x_reduced, &a_uv_reduced);
1163 let c_uv = &(&(&self.w4 * &deta_uv) * &self.h_diag)
1164 + &(&self.w3 * &(&u.deta * &v.dh))
1165 + &(&self.w3 * &(&v.deta * &u.dh))
1166 + &(&self.w2 * &d2h);
1167
1168 let eta_rhs = &cache.eta_rhs;
1169 let diag_term = fast_ab(
1170 &self.x_dense_t,
1171 &(eta_rhs * &c_uv.view().insert_axis(Axis(1))),
1172 );
1173
1174 let b_uvvec = &self.w3 * &deta_uv;
1175 let b_uv_base = &self.x_dense * &b_uvvec.view().insert_axis(Axis(1));
1176
1177 let p_b_rhs = &cache.p_b_rhs;
1179 let p_bu_rhs = &cache.p_bx[i];
1180 let p_bv_rhs = &cache.p_bx[j];
1181 let p_u_b_rhs = &cache.pu_qv[i];
1182 let pv_b_rhs = &cache.pu_qv[j];
1183
1184 let p_buv_base = RemlState::apply_hadamard_gram_to_matrix(
1186 &self.x_reduced,
1187 &self.k_reduced,
1188 &self.k_reduced,
1189 &b_uv_base,
1190 );
1191 let p_buv_rhs = fast_ab(&p_buv_base, &cache.eye);
1192
1193 let pv_bu_rhs = self.apply_p_u_to_matrix(
1194 &v.a_u_reduced,
1195 &(eta_rhs * &u.b_uvec.view().insert_axis(Axis(1))),
1196 );
1197 let p_u_bv_rhs = self.apply_p_u_to_matrix(
1198 &u.a_u_reduced,
1199 &(eta_rhs * &v.b_uvec.view().insert_axis(Axis(1))),
1200 );
1201
1202 let p_nu_nv_base = RemlState::apply_hadamard_gram_to_matrix(
1203 &self.x_reduced,
1204 &u.a_u_reduced,
1205 &v.a_u_reduced,
1206 &self.b_base,
1207 );
1208 let p_hw_nuv_base = RemlState::apply_hadamard_gram_to_matrix(
1209 &self.x_reduced,
1210 &self.k_reduced,
1211 &a_uv_reduced,
1212 &self.b_base,
1213 );
1214 let p_uv_base = 2.0 * p_nu_nv_base - 2.0 * p_hw_nuv_base;
1215 let p_uv_rhs = fast_ab(&p_uv_base, &cache.eye);
1216
1217 let d2_terms = [
1218 self.left_scaled_xt(&b_uvvec, p_b_rhs),
1219 self.left_scaled_xt(&self.w1, &p_buv_rhs),
1220 self.left_scaled_xt(&u.b_uvec, p_bv_rhs),
1221 self.left_scaled_xt(&v.b_uvec, p_bu_rhs),
1222 self.left_scaled_xt(&u.b_uvec, pv_b_rhs),
1223 self.left_scaled_xt(&self.w1, &pv_bu_rhs),
1224 self.left_scaled_xt(&v.b_uvec, p_u_b_rhs),
1225 self.left_scaled_xt(&self.w1, &p_u_bv_rhs),
1226 self.left_scaled_xt(&self.w1, &p_uv_rhs),
1227 ];
1228 let mut d2_j2 = Array2::<f64>::zeros((p, cols));
1229 for term in d2_terms {
1230 d2_j2 += &term;
1231 }
1232
1233 0.5 * (diag_term - d2_j2)
1234 }
1235
1236 pub(super) fn rowwise_dot(a: &Array2<f64>, b: &Array2<f64>) -> Array1<f64> {
1237 assert_eq!(a.nrows(), b.nrows());
1238 assert_eq!(a.ncols(), b.ncols());
1239 let mut out = Array1::<f64>::zeros(a.nrows());
1240 for i in 0..a.nrows() {
1241 let mut acc = 0.0_f64;
1242 for j in 0..a.ncols() {
1243 acc += a[[i, j]] * b[[i, j]];
1244 }
1245 out[i] = acc;
1246 }
1247 out
1248 }
1249
1250 pub(super) fn rowwise_bilinear(
1251 a: &Array2<f64>,
1252 m: &Array2<f64>,
1253 b: &Array2<f64>,
1254 ) -> Array1<f64> {
1255 assert_eq!(a.nrows(), b.nrows());
1257 assert_eq!(a.ncols(), m.nrows());
1258 assert_eq!(b.ncols(), m.ncols());
1259 let am = fast_ab(a, m);
1260 Self::rowwise_dot(&am, b)
1261 }
1262
1263 pub(crate) fn dot_i_and_h_from_reduced(
1264 &self,
1265 x_tau_reduced: &Array2<f64>,
1266 deta: &Array1<f64>,
1267 ) -> (Array2<f64>, Array1<f64>) {
1268 let dw = &self.w1 * deta;
1288 let dot_i = RemlState::weighted_cross(x_tau_reduced, &self.x_reduced, &self.w)
1289 + RemlState::weighted_cross(&self.x_reduced, x_tau_reduced, &self.w)
1290 + gam_linalg::faer_ndarray::fast_xt_diag_x(&self.x_reduced, &dw);
1291
1292 let dot_k = -self.k_reduced.dot(&dot_i).dot(&self.k_reduced);
1293 let x_tauk = fast_ab(x_tau_reduced, &self.k_reduced);
1294 let dot_h_explicit = 2.0 * Self::rowwise_dot(&x_tauk, &self.x_reduced);
1295 let dot_h_implicit = Self::rowwise_dot(&fast_ab(&self.x_reduced, &dot_k), &self.x_reduced);
1296 let dot_h = dot_h_explicit + dot_h_implicit;
1297 (dot_i, dot_h)
1298 }
1299
1300 pub(crate) fn exact_tau_kernel(
1301 &self,
1302 x_tau: &Array2<f64>,
1303 beta: &Array1<f64>,
1304 include_hphi_tau_kernel: bool,
1305 ) -> FirthTauExactKernel {
1306 let deta_partial = gam_linalg::faer_ndarray::fast_av(x_tau, beta);
1330 let x_tau_reduced = self.reduce_explicit_design(x_tau);
1331 let (dot_i_partial, dot_h_partial) =
1332 self.dot_i_and_h_from_reduced(&x_tau_reduced, &deta_partial);
1333 let dot_s_partial =
1334 fast_atb(&x_tau_reduced, &self.x_reduced) + fast_atb(&self.x_reduced, &x_tau_reduced);
1335
1336 let first = 0.5 * gam_linalg::faer_ndarray::fast_atv(x_tau, &(&self.w1 * &self.h_diag));
1337 let secondvec =
1338 &(&(&self.w2 * &deta_partial) * &self.h_diag) + &(&self.w1 * &dot_h_partial);
1339 let second = 0.5 * gam_linalg::faer_ndarray::fast_atv(&self.x_dense, &secondvec);
1340 let gphi_tau = first + second;
1341 let phi_tau_partial = 0.5 * RemlState::trace_product(&self.k_reduced, &dot_i_partial)
1342 - 0.5 * Self::trace_diag_product(&self.x_metric_reduced_inv_diag, &dot_s_partial);
1343
1344 let tau_kernel = if include_hphi_tau_kernel {
1345 Some(self.hphi_tau_partial_prepare_from_partials(
1346 x_tau_reduced,
1347 &deta_partial,
1348 dot_h_partial,
1349 dot_i_partial,
1350 ))
1351 } else {
1352 None
1353 };
1354 FirthTauExactKernel {
1355 gphi_tau,
1356 phi_tau_partial,
1357 tau_kernel,
1358 }
1359 }
1360
1361 pub(crate) fn hphi_tau_partial_prepare_from_partials(
1362 &self,
1363 x_tau_reduced: Array2<f64>,
1364 deta_partial: &Array1<f64>,
1365 dot_h_partial: Array1<f64>,
1366 dot_i_partial: Array2<f64>,
1367 ) -> FirthTauPartialKernel {
1368 let dotw1 = &self.w2 * deta_partial;
1369 let dotw2 = &self.w3 * deta_partial;
1370 let dot_k = -self.k_reduced.dot(&dot_i_partial).dot(&self.k_reduced);
1371 FirthTauPartialKernel {
1372 deta_partial: deta_partial.clone(),
1373 dotw1,
1374 dotw2,
1375 dot_h_partial,
1376 x_tau_reduced,
1377 dot_i_partial,
1378 dot_k_reduced: dot_k,
1379 }
1380 }
1381
1382 pub(crate) fn d_beta_hphi_tau_partial_dense(
1383 &self,
1384 x_tau: &Array2<f64>,
1385 beta: &Array1<f64>,
1386 beta_direction: &Array1<f64>,
1387 ) -> Option<Array2<f64>> {
1388 if x_tau.nrows() != self.x_dense.nrows() || x_tau.ncols() != beta.len() {
1389 return None;
1390 }
1391 if !x_tau.iter().any(|value| *value != 0.0) {
1392 return None;
1393 }
1394 let tau_bundle = self.exact_tau_kernel(x_tau, beta, true);
1395 let tau_kernel = tau_bundle.tau_kernel?;
1396 let firth_direction = self.direction_from_deta(gam_linalg::faer_ndarray::fast_av(
1397 &self.x_dense,
1398 beta_direction,
1399 ));
1400 let x_tau_v = gam_linalg::faer_ndarray::fast_av(x_tau, beta_direction);
1401 let kernel = self.d_beta_hphi_tau_partial_prepare_from_partials(
1402 &tau_kernel,
1403 &tau_kernel.deta_partial,
1404 &tau_kernel.dot_i_partial,
1405 &firth_direction,
1406 &x_tau_v,
1407 );
1408 let eye = Array2::<f64>::eye(beta_direction.len());
1409 Some(self.d_beta_hphi_tau_partial_apply(x_tau, &kernel, &eye))
1410 }
1411
1412 pub(crate) fn apply_pbar_to_matrix(&self, mat: &Array2<f64>) -> Array2<f64> {
1413 RemlState::apply_hadamard_gram_to_matrix(
1415 &self.x_reduced,
1416 &self.k_reduced,
1417 &self.k_reduced,
1418 mat,
1419 )
1420 }
1421
1422 pub(crate) fn apply_mtau_to_matrix(
1423 &self,
1424 kernel: &FirthTauPartialKernel,
1425 mat: &Array2<f64>,
1426 ) -> Array2<f64> {
1427 if mat.nrows() != self.x_dense.nrows() || mat.ncols() == 0 {
1439 return Array2::<f64>::zeros(mat.raw_dim());
1440 }
1441 let mut out = Array2::<f64>::zeros(mat.raw_dim());
1442 for col in 0..mat.ncols() {
1443 let v = mat.column(col).to_owned();
1444 let szz = RemlState::reducedweighted_gram(&self.x_reduced, &v);
1445 let mzz = self.k_reduced.dot(&szz).dot(&self.k_reduced);
1446 let t1 = Self::rowwise_bilinear(&self.x_reduced, &mzz, &kernel.x_tau_reduced);
1447
1448 let szt =
1449 RemlState::reduced_crossweighted_gram(&self.x_reduced, &kernel.x_tau_reduced, &v);
1450 let mzt = self.k_reduced.dot(&szt).dot(&self.k_reduced);
1451 let t2 = RemlState::reduced_diag_gram(&self.x_reduced, &mzt);
1452
1453 let t3 = RemlState::apply_hadamard_gram(
1454 &self.x_reduced,
1455 &self.k_reduced,
1456 &kernel.dot_k_reduced,
1457 &v,
1458 );
1459
1460 let y = 2.0 * (t1 + t2 + t3);
1461 out.column_mut(col).assign(&y);
1462 }
1463 out
1464 }
1465
1466 pub(crate) fn hphi_tau_partial_apply(
1467 &self,
1468 x_tau: &Array2<f64>,
1469 kernel: &FirthTauPartialKernel,
1470 rhs: &Array2<f64>,
1471 ) -> Array2<f64> {
1472 let p = self.x_dense.ncols();
1473 if rhs.nrows() != p {
1474 return Array2::<f64>::zeros((p, rhs.ncols()));
1475 }
1476 if rhs.ncols() == 0 || p == 0 {
1477 return Array2::<f64>::zeros((p, rhs.ncols()));
1478 }
1479 let etav = fast_ab(&self.x_dense, rhs);
1496 let etav_tau = fast_ab(x_tau, rhs);
1497 let qv = &etav * &self.w1.view().insert_axis(Axis(1));
1498 let qv_tau = &etav * &kernel.dotw1.view().insert_axis(Axis(1))
1499 + &etav_tau * &self.w1.view().insert_axis(Axis(1));
1500 let m_qv = self.apply_pbar_to_matrix(&qv);
1501 let m_qv_tau = self.apply_mtau_to_matrix(kernel, &qv) + self.apply_pbar_to_matrix(&qv_tau);
1502 let rv = &(&etav * &self.w2.view().insert_axis(Axis(1)))
1503 * &self.h_diag.view().insert_axis(Axis(1))
1504 - &(&m_qv * &self.w1.view().insert_axis(Axis(1)));
1505 let rv_tau = (&(&etav * &kernel.dotw2.view().insert_axis(Axis(1)))
1506 + &(&etav_tau * &self.w2.view().insert_axis(Axis(1))))
1507 * self.h_diag.view().insert_axis(Axis(1))
1508 + &(&etav * &self.w2.view().insert_axis(Axis(1)))
1509 * &kernel.dot_h_partial.view().insert_axis(Axis(1))
1510 - &(&m_qv * &kernel.dotw1.view().insert_axis(Axis(1))
1511 + &m_qv_tau * &self.w1.view().insert_axis(Axis(1)));
1512 0.5 * (fast_atb(x_tau, &rv) + fast_atb(&self.x_dense, &rv_tau))
1513 }
1514
1515 pub(crate) fn hphi_tau_tau_partial_prepare_from_partials(
1855 &self,
1856 x_tau_i_reduced: Array2<f64>,
1857 x_tau_j_reduced: Array2<f64>,
1858 deta_i_partial: &Array1<f64>,
1859 deta_j_partial: &Array1<f64>,
1860 dot_h_i_partial: Array1<f64>,
1861 dot_h_j_partial: Array1<f64>,
1862 dot_i_i_partial: Array2<f64>,
1863 dot_i_j_partial: Array2<f64>,
1864 x_tau_tau_reduced: Option<Array2<f64>>,
1865 deta_ij_partial: Option<Array1<f64>>,
1866 ) -> FirthTauTauPartialKernel {
1867 let dot_k_i_reduced = -self.k_reduced.dot(&dot_i_i_partial).dot(&self.k_reduced);
1869 let dot_k_j_reduced = -self.k_reduced.dot(&dot_i_j_partial).dot(&self.k_reduced);
1870 FirthTauTauPartialKernel {
1871 x_tau_i_reduced,
1872 x_tau_j_reduced,
1873 deta_i_partial: deta_i_partial.clone(),
1874 deta_j_partial: deta_j_partial.clone(),
1875 dot_h_i_partial,
1876 dot_h_j_partial,
1877 dot_k_i_reduced,
1878 dot_k_j_reduced,
1879 dot_i_i_partial,
1880 dot_i_j_partial,
1881 x_tau_tau_reduced,
1882 deta_ij_partial,
1883 }
1884 }
1885
1886 pub(crate) fn hphi_tau_tau_partial_apply(
1894 &self,
1895 x_tau_i: &Array2<f64>,
1896 x_tau_j: &Array2<f64>,
1897 kernel: &FirthTauTauPartialKernel,
1898 rhs: &Array2<f64>,
1899 ) -> Array2<f64> {
1900 let p = self.x_dense.ncols();
1901 if rhs.nrows() != p {
1902 return Array2::<f64>::zeros((p, rhs.ncols()));
1903 }
1904 if rhs.ncols() == 0 || p == 0 {
1905 return Array2::<f64>::zeros((p, rhs.ncols()));
1906 }
1907 let n = self.x_dense.nrows();
1908 let m = rhs.ncols();
1909
1910 let z = &self.x_reduced;
1912 let x_r = &self.x_reduced;
1913 let k = &self.k_reduced;
1914 let x_ri = &kernel.x_tau_i_reduced;
1915 let x_rj = &kernel.x_tau_j_reduced;
1916 let deta_i = &kernel.deta_i_partial;
1917 let deta_j = &kernel.deta_j_partial;
1918 let dh_i = &kernel.dot_h_i_partial;
1919 let dh_j = &kernel.dot_h_j_partial;
1920 let dot_k_i = &kernel.dot_k_i_reduced;
1921 let dot_k_j = &kernel.dot_k_j_reduced;
1922 let dot_i_i = &kernel.dot_i_i_partial;
1923 let dot_i_j = &kernel.dot_i_j_partial;
1924
1925 let x_tau_tau_is_some = kernel.x_tau_tau_reduced.is_some();
1928 let x_rij_zero = Array2::<f64>::zeros(x_r.raw_dim());
1929 let x_rij: &Array2<f64> = kernel.x_tau_tau_reduced.as_ref().unwrap_or(&x_rij_zero);
1930 let zeros_n = Array1::<f64>::zeros(n);
1931 let deta_ij = kernel.deta_ij_partial.as_ref().unwrap_or(&zeros_n);
1932
1933 let (eta_v, eta_i_v, eta_j_v) = if RemlState::should_join_independent_dense_products(&[
1937 (n, m, p),
1938 (n, m, p),
1939 (n, m, p),
1940 ]) {
1941 let (eta_v, (eta_i_v, eta_j_v)) = rayon::join(
1942 || fast_ab(&self.x_dense, rhs),
1943 || rayon::join(|| fast_ab(x_tau_i, rhs), || fast_ab(x_tau_j, rhs)),
1944 );
1945 (eta_v, eta_i_v, eta_j_v)
1946 } else {
1947 (
1948 fast_ab(&self.x_dense, rhs),
1949 fast_ab(x_tau_i, rhs),
1950 fast_ab(x_tau_j, rhs),
1951 )
1952 }; let eta_ij_v: Array2<f64> = if x_tau_tau_is_some {
1957 let qt_v = fast_atb(&self.q_basis, rhs); let mut out = fast_ab(x_rij, &qt_v); RemlState::scale_rows_by_inverse_observation_weight_sqrt(
1960 &mut out,
1961 self.observation_weight_sqrt.as_ref(),
1962 );
1963 out
1964 } else {
1965 Array2::<f64>::zeros((n, m))
1966 };
1967
1968 let a_i_reduced = -dot_k_i; let a_j_reduced = -dot_k_j;
1975
1976 let dw_i = &self.w1 * deta_i;
1986 let dw_j = &self.w1 * deta_j;
1987 let ddw_ij = &(&self.w2 * &(deta_i * deta_j)) + &(&self.w1 * deta_ij);
1988 let mut i_ddot = Array2::<f64>::zeros(k.raw_dim());
1989 if x_tau_tau_is_some {
1990 i_ddot = i_ddot + RemlState::weighted_cross(x_rij, x_r, &self.w);
1991 i_ddot = i_ddot + RemlState::weighted_cross(x_r, x_rij, &self.w);
1992 }
1993 i_ddot = i_ddot + RemlState::weighted_cross(x_ri, x_rj, &self.w);
1994 i_ddot = i_ddot + RemlState::weighted_cross(x_rj, x_ri, &self.w);
1995 i_ddot = i_ddot + RemlState::weighted_cross(x_ri, x_r, &dw_j);
1996 i_ddot = i_ddot + RemlState::weighted_cross(x_r, x_ri, &dw_j);
1997 i_ddot = i_ddot + RemlState::weighted_cross(x_rj, x_r, &dw_i);
1998 i_ddot = i_ddot + RemlState::weighted_cross(x_r, x_rj, &dw_i);
1999 i_ddot = i_ddot + gam_linalg::faer_ndarray::fast_xt_diag_x(x_r, &ddw_ij);
2000
2001 let k_ddot: Array2<f64> = -k.dot(&i_ddot).dot(k)
2006 + a_i_reduced.dot(dot_i_j).dot(k)
2007 + a_j_reduced.dot(dot_i_i).dot(k);
2008
2009 let dh_ij: Array1<f64> = {
2019 let r = k.ncols();
2020 let can_join = RemlState::should_join_independent_dense_products(&[
2021 (n, r, r),
2022 (n, r, r),
2023 (n, r, r),
2024 (n, r, r),
2025 ]);
2026 let (xr_kddot, ri_kdot_j, rj_kdot_i, ri_k) = if can_join {
2027 let ((xr_kddot, ri_kdot_j), (rj_kdot_i, ri_k)) = rayon::join(
2028 || rayon::join(|| fast_ab(x_r, &k_ddot), || fast_ab(x_ri, dot_k_j)),
2029 || rayon::join(|| fast_ab(x_rj, dot_k_i), || fast_ab(x_ri, k)),
2030 );
2031 (xr_kddot, ri_kdot_j, rj_kdot_i, ri_k)
2032 } else {
2033 (
2034 fast_ab(x_r, &k_ddot),
2035 fast_ab(x_ri, dot_k_j),
2036 fast_ab(x_rj, dot_k_i),
2037 fast_ab(x_ri, k),
2038 )
2039 };
2040
2041 let mut acc = Self::rowwise_dot(&xr_kddot, x_r);
2042 acc = acc + 2.0 * Self::rowwise_dot(&ri_kdot_j, x_r);
2043 acc = acc + 2.0 * Self::rowwise_dot(&rj_kdot_i, x_r);
2044 acc = acc + 2.0 * Self::rowwise_dot(&ri_k, x_rj);
2045 if x_tau_tau_is_some {
2046 let rij_k = fast_ab(x_rij, k);
2047 acc = acc + 2.0 * Self::rowwise_dot(&rij_k, x_r);
2048 }
2049 acc
2050 };
2051
2052 let gamma = &self.w2 * &self.h_diag;
2063 let gamma_dot_i = &(&(&self.w3 * deta_i) * &self.h_diag) + &(&self.w2 * dh_i);
2064 let gamma_dot_j = &(&(&self.w3 * deta_j) * &self.h_diag) + &(&self.w2 * dh_j);
2065 let gamma_ddot = &(&(&(&self.w4 * deta_i) * deta_j) * &self.h_diag)
2066 + &(&(&(&self.w3 * deta_ij) * &self.h_diag)
2067 + &(&(&self.w3 * deta_i) * dh_j)
2068 + &(&(&self.w3 * deta_j) * dh_i)
2069 + &(&self.w2 * &dh_ij));
2070
2071 let mut diag_term = Array2::<f64>::zeros((p, m));
2081 let gamma_col = gamma.view().insert_axis(Axis(1));
2082 let gamma_i_col = gamma_dot_i.view().insert_axis(Axis(1));
2083 let gamma_j_col = gamma_dot_j.view().insert_axis(Axis(1));
2084 let gamma_ij_col = gamma_ddot.view().insert_axis(Axis(1));
2085
2086 diag_term = diag_term + fast_atb(x_tau_i, &(&eta_j_v * &gamma_col));
2088 diag_term = diag_term + fast_atb(x_tau_j, &(&eta_i_v * &gamma_col));
2089 diag_term = diag_term + fast_atb(x_tau_i, &(&eta_v * &gamma_j_col));
2091 diag_term = diag_term + fast_atb(x_tau_j, &(&eta_v * &gamma_i_col));
2092 diag_term = diag_term + fast_ab(&self.x_dense_t, &(&eta_i_v * &gamma_j_col));
2094 diag_term = diag_term + fast_ab(&self.x_dense_t, &(&eta_j_v * &gamma_i_col));
2095 diag_term = diag_term + fast_ab(&self.x_dense_t, &(&eta_v * &gamma_ij_col));
2097 if x_tau_tau_is_some {
2099 let y: Array2<f64> = &eta_v * &gamma_col;
2105 let xt_ij_y: Array2<f64> = if self.observation_weight_sqrt.is_some() {
2106 let mut y_scaled = y.clone();
2107 RemlState::scale_rows_by_inverse_observation_weight_sqrt(
2108 &mut y_scaled,
2109 self.observation_weight_sqrt.as_ref(),
2110 );
2111 self.q_basis.dot(&x_rij.t().dot(&y_scaled))
2112 } else {
2113 self.q_basis.dot(&x_rij.t().dot(&y))
2114 };
2115 diag_term = diag_term + xt_ij_y;
2116 diag_term = diag_term + self.x_dense_t.dot(&(&eta_ij_v * &gamma_col));
2117 }
2118
2119 let w1_col = self.w1.view().insert_axis(Axis(1));
2131 let b_v = &eta_v * &w1_col;
2132
2133 let w2_deta_i = &self.w2 * deta_i;
2135 let w2_deta_j = &self.w2 * deta_j;
2136 let w2_deta_i_col = w2_deta_i.view().insert_axis(Axis(1));
2137 let w2_deta_j_col = w2_deta_j.view().insert_axis(Axis(1));
2138 let bdot_i_v = &(&eta_v * &w2_deta_i_col) + &(&eta_i_v * &w1_col);
2139 let bdot_j_v = &(&eta_v * &w2_deta_j_col) + &(&eta_j_v * &w1_col);
2140
2141 let w3_didj = &(&self.w3 * deta_i) * deta_j;
2147 let w2_dij = &self.w2 * deta_ij;
2148 let bddot_scale = &w3_didj + &w2_dij;
2149 let bddot_scale_col = bddot_scale.view().insert_axis(Axis(1));
2150 let mut bddot_ij_v = &eta_v * &bddot_scale_col;
2151 bddot_ij_v += &(&eta_j_v * &w2_deta_i_col);
2152 bddot_ij_v += &(&eta_i_v * &w2_deta_j_col);
2153 bddot_ij_v += &(&eta_ij_v * &w1_col);
2154
2155 let p_bv = RemlState::apply_hadamard_gram_to_matrix(z, k, k, &b_v);
2157 let p_bddot_ij_v = RemlState::apply_hadamard_gram_to_matrix(z, k, k, &bddot_ij_v);
2158
2159 let pdot_i_bv = self.apply_mtau_from_reduced(x_ri, dot_k_i, &b_v);
2166 let pdot_j_bv = self.apply_mtau_from_reduced(x_rj, dot_k_j, &b_v);
2167 let pdot_i_bdot_j_v = self.apply_mtau_from_reduced(x_ri, dot_k_i, &bdot_j_v);
2168 let pdot_j_bdot_i_v = self.apply_mtau_from_reduced(x_rj, dot_k_j, &bdot_i_v);
2169
2170 let p_bdot_j_v = RemlState::apply_hadamard_gram_to_matrix(z, k, k, &bdot_j_v);
2172 let p_bdot_i_v = RemlState::apply_hadamard_gram_to_matrix(z, k, k, &bdot_i_v);
2173
2174 let p_ddot_b_v = self.apply_p_ddot_ij(
2176 x_r,
2177 x_ri,
2178 x_rj,
2179 x_rij,
2180 k,
2181 dot_k_i,
2182 dot_k_j,
2183 &k_ddot,
2184 x_tau_tau_is_some,
2185 &b_v,
2186 );
2187
2188 let apply_bdot_tau_t =
2202 |scale_deta: &Array1<f64>, x_tau_mat: &Array2<f64>, q_v: &Array2<f64>| {
2203 let scale_col = scale_deta.view().insert_axis(Axis(1));
2204 self.x_dense_t.dot(&(q_v * &scale_col)) + x_tau_mat.t().dot(&(q_v * &w1_col))
2205 };
2206
2207 let apply_bddot_ij_t = |q_v: &Array2<f64>| -> Array2<f64> {
2208 let scale_col_full = bddot_scale.view().insert_axis(Axis(1));
2209 let mut out = self.x_dense_t.dot(&(q_v * &scale_col_full));
2210 out = out + x_tau_j.t().dot(&(q_v * &w2_deta_i_col));
2211 out = out + x_tau_i.t().dot(&(q_v * &w2_deta_j_col));
2212 if x_tau_tau_is_some {
2213 let y = q_v * &w1_col;
2215 let contrib: Array2<f64> = if self.observation_weight_sqrt.is_some() {
2216 let mut y_scaled = y.clone();
2217 RemlState::scale_rows_by_inverse_observation_weight_sqrt(
2218 &mut y_scaled,
2219 self.observation_weight_sqrt.as_ref(),
2220 );
2221 self.q_basis.dot(&x_rij.t().dot(&y_scaled))
2222 } else {
2223 self.q_basis.dot(&x_rij.t().dot(&y))
2224 };
2225 out = out + contrib;
2226 }
2227 out
2228 };
2229
2230 let t1a = apply_bddot_ij_t(&p_bv);
2232 let t1b = self.left_scaled_xt(&self.w1, &p_bddot_ij_v);
2233 let t2a = apply_bdot_tau_t(&w2_deta_i, x_tau_i, &p_bdot_j_v);
2235 let t2b = apply_bdot_tau_t(&w2_deta_j, x_tau_j, &p_bdot_i_v);
2236 let t3a = apply_bdot_tau_t(&w2_deta_i, x_tau_i, &pdot_j_bv);
2238 let t3b = self.left_scaled_xt(&self.w1, &pdot_j_bdot_i_v);
2239 let t4a = apply_bdot_tau_t(&w2_deta_j, x_tau_j, &pdot_i_bv);
2241 let t4b = self.left_scaled_xt(&self.w1, &pdot_i_bdot_j_v);
2242 let t5 = self.left_scaled_xt(&self.w1, &p_ddot_b_v);
2244
2245 let d2_bpb = t1a + t1b + t2a + t2b + t3a + t3b + t4a + t4b + t5;
2246
2247 0.5 * (diag_term - d2_bpb)
2248 }
2249
2250 pub(crate) fn exact_tau_tau_kernel(
2313 &self,
2314 x_tau_i: &Array2<f64>,
2315 x_tau_j: &Array2<f64>,
2316 x_tau_tau: Option<&Array2<f64>>,
2317 beta: &Array1<f64>,
2318 include_hphi_tau_tau_kernel: bool,
2319 ) -> FirthTauTauExactKernel {
2320 let deta_i = x_tau_i.dot(beta);
2321 let deta_j = x_tau_j.dot(beta);
2322 let deta_ij = x_tau_tau.as_ref().map(|xij| xij.dot(beta));
2323
2324 let x_tau_i_reduced = self.reduce_explicit_design(x_tau_i);
2325 let x_tau_j_reduced = self.reduce_explicit_design(x_tau_j);
2326 let x_tau_tau_reduced = x_tau_tau.map(|xij| self.reduce_explicit_design(xij));
2327
2328 let (dot_i_i, dot_h_i) = self.dot_i_and_h_from_reduced(&x_tau_i_reduced, &deta_i);
2329 let (dot_i_j, dot_h_j) = self.dot_i_and_h_from_reduced(&x_tau_j_reduced, &deta_j);
2330
2331 let zeros_n = Array1::<f64>::zeros(self.x_dense.nrows());
2338 let deta_ij_ref: &Array1<f64> = deta_ij.as_ref().unwrap_or(&zeros_n);
2339 let dw_i = &self.w1 * &deta_i;
2340 let dw_j = &self.w1 * &deta_j;
2341 let ddw_ij = &(&self.w2 * &(&deta_i * &deta_j)) + &(&self.w1 * deta_ij_ref);
2342
2343 let x_r = &self.x_reduced;
2344 let mut i_ddot = Array2::<f64>::zeros(self.k_reduced.raw_dim());
2345 if let Some(x_rij) = x_tau_tau_reduced.as_ref() {
2346 i_ddot = i_ddot + RemlState::weighted_cross(x_rij, x_r, &self.w);
2347 i_ddot = i_ddot + RemlState::weighted_cross(x_r, x_rij, &self.w);
2348 }
2349 i_ddot = i_ddot + RemlState::weighted_cross(&x_tau_i_reduced, &x_tau_j_reduced, &self.w);
2350 i_ddot = i_ddot + RemlState::weighted_cross(&x_tau_j_reduced, &x_tau_i_reduced, &self.w);
2351 i_ddot = i_ddot + RemlState::weighted_cross(&x_tau_i_reduced, x_r, &dw_j);
2352 i_ddot = i_ddot + RemlState::weighted_cross(x_r, &x_tau_i_reduced, &dw_j);
2353 i_ddot = i_ddot + RemlState::weighted_cross(&x_tau_j_reduced, x_r, &dw_i);
2354 i_ddot = i_ddot + RemlState::weighted_cross(x_r, &x_tau_j_reduced, &dw_i);
2355 i_ddot = i_ddot + gam_linalg::faer_ndarray::fast_xt_diag_x(x_r, &ddw_ij);
2356
2357 let k = &self.k_reduced;
2361 let k_dot_i_i = k.dot(&dot_i_i);
2362 let k_dot_i_j = k.dot(&dot_i_j);
2363 let a_lik = 0.5 * RemlState::trace_product(k, &i_ddot)
2364 - 0.5 * RemlState::trace_product(&k_dot_i_j, &k_dot_i_i);
2365
2366 let dot_s_i = fast_atb(&x_tau_i_reduced, x_r) + fast_atb(x_r, &x_tau_i_reduced);
2373 let dot_s_j = fast_atb(&x_tau_j_reduced, x_r) + fast_atb(x_r, &x_tau_j_reduced);
2374 let mut s_ddot = Array2::<f64>::zeros(k.raw_dim());
2375 if let Some(x_rij) = x_tau_tau_reduced.as_ref() {
2376 s_ddot = s_ddot + fast_atb(x_rij, x_r) + fast_atb(x_r, x_rij);
2377 }
2378 s_ddot = s_ddot
2379 + fast_atb(&x_tau_i_reduced, &x_tau_j_reduced)
2380 + fast_atb(&x_tau_j_reduced, &x_tau_i_reduced);
2381 let g_inv = &self.x_metric_reduced_inv_diag;
2390 let rdim = k.nrows();
2391 let mut a_pen = 0.0_f64;
2392 for kk in 0..rdim {
2393 for ll in 0..rdim {
2394 a_pen += 0.5 * g_inv[kk] * g_inv[ll] * dot_s_j[[kk, ll]] * dot_s_i[[kk, ll]];
2395 }
2396 a_pen -= 0.5 * g_inv[kk] * s_ddot[[kk, kk]];
2397 }
2398 let phi_tau_tau_partial = a_lik + a_pen;
2399
2400 let dot_k_i = -k.dot(&dot_i_i).dot(k);
2407 let dot_k_j = -k.dot(&dot_i_j).dot(k);
2408 let a_i_red = -&dot_k_i; let a_j_red = -&dot_k_j; let k_ddot: Array2<f64> =
2411 -k.dot(&i_ddot).dot(k) + a_i_red.dot(&dot_i_j).dot(k) + a_j_red.dot(&dot_i_i).dot(k);
2412
2413 let n = self.x_dense.nrows();
2419 let mut dh_ij = Array1::<f64>::zeros(n);
2420 if let Some(x_rij) = x_tau_tau_reduced.as_ref() {
2421 let rij_k = x_rij.dot(k);
2422 dh_ij = dh_ij + 2.0 * Self::rowwise_dot(&rij_k, x_r);
2423 }
2424 let xr_kddot = x_r.dot(&k_ddot);
2425 dh_ij = dh_ij + Self::rowwise_dot(&xr_kddot, x_r);
2426 let ri_kdot_j = x_tau_i_reduced.dot(&dot_k_j);
2427 dh_ij = dh_ij + 2.0 * Self::rowwise_dot(&ri_kdot_j, x_r);
2428 let rj_kdot_i = x_tau_j_reduced.dot(&dot_k_i);
2429 dh_ij = dh_ij + 2.0 * Self::rowwise_dot(&rj_kdot_i, x_r);
2430 let ri_k = x_tau_i_reduced.dot(k);
2431 dh_ij = dh_ij + 2.0 * Self::rowwise_dot(&ri_k, &x_tau_j_reduced);
2432
2433 let w1_h = &self.w1 * &self.h_diag;
2436 let mut gphi_tau_tau = Array1::<f64>::zeros(self.x_dense.ncols());
2437 if let Some(x_ij) = x_tau_tau.as_ref() {
2438 gphi_tau_tau = gphi_tau_tau + 0.5 * x_ij.t().dot(&w1_h);
2439 }
2440 let inner_j = &(&(&self.w2 * &deta_j) * &self.h_diag) + &(&self.w1 * &dot_h_j);
2441 gphi_tau_tau = gphi_tau_tau + 0.5 * x_tau_i.t().dot(&inner_j);
2442
2443 let v_tau_i = &(&(&self.w2 * &deta_i) * &self.h_diag) + &(&self.w1 * &dot_h_i);
2445 gphi_tau_tau = gphi_tau_tau + 0.5 * x_tau_j.t().dot(&v_tau_i);
2446
2447 let mut v_dot_ij = &(&(&self.w3 * &deta_j) * &deta_i) * &self.h_diag;
2454 v_dot_ij += &(&(&self.w2 * deta_ij_ref) * &self.h_diag);
2455 v_dot_ij += &(&(&self.w2 * &deta_i) * &dot_h_j);
2456 v_dot_ij += &(&(&self.w2 * &deta_j) * &dot_h_i);
2457 v_dot_ij += &(&self.w1 * &dh_ij);
2458 gphi_tau_tau = gphi_tau_tau + 0.5 * self.x_dense.t().dot(&v_dot_ij);
2459
2460 let tau_tau_kernel = if include_hphi_tau_tau_kernel {
2461 Some(self.hphi_tau_tau_partial_prepare_from_partials(
2462 x_tau_i_reduced,
2463 x_tau_j_reduced,
2464 &deta_i,
2465 &deta_j,
2466 dot_h_i,
2467 dot_h_j,
2468 dot_i_i,
2469 dot_i_j,
2470 x_tau_tau_reduced,
2471 deta_ij,
2472 ))
2473 } else {
2474 None
2475 };
2476
2477 FirthTauTauExactKernel {
2478 phi_tau_tau_partial,
2479 gphi_tau_tau,
2480 tau_tau_kernel,
2481 }
2482 }
2483
2484 pub(crate) fn apply_mtau_from_reduced(
2491 &self,
2492 x_tau_reduced: &Array2<f64>,
2493 dot_k_reduced: &Array2<f64>,
2494 mat: &Array2<f64>,
2495 ) -> Array2<f64> {
2496 if mat.nrows() != self.x_dense.nrows() || mat.ncols() == 0 {
2497 return Array2::<f64>::zeros(mat.raw_dim());
2498 }
2499 let mut out = Array2::<f64>::zeros(mat.raw_dim());
2500 for col in 0..mat.ncols() {
2501 let v = mat.column(col).to_owned();
2502 let szz = RemlState::reducedweighted_gram(&self.x_reduced, &v);
2503 let mzz = self.k_reduced.dot(&szz).dot(&self.k_reduced);
2504 let t1 = Self::rowwise_bilinear(&self.x_reduced, &mzz, x_tau_reduced);
2505
2506 let szt = RemlState::reduced_crossweighted_gram(&self.x_reduced, x_tau_reduced, &v);
2507 let mzt = self.k_reduced.dot(&szt).dot(&self.k_reduced);
2508 let t2 = RemlState::reduced_diag_gram(&self.x_reduced, &mzt);
2509
2510 let t3 =
2511 RemlState::apply_hadamard_gram(&self.x_reduced, &self.k_reduced, dot_k_reduced, &v);
2512
2513 let y = 2.0 * (t1 + t2 + t3);
2514 out.column_mut(col).assign(&y);
2515 }
2516 out
2517 }
2518
2519 pub(crate) fn apply_p_ddot_ij(
2530 &self,
2531 x_r: &Array2<f64>,
2532 x_ri: &Array2<f64>,
2533 x_rj: &Array2<f64>,
2534 x_rij: &Array2<f64>,
2535 k: &Array2<f64>,
2536 dot_k_i: &Array2<f64>,
2537 dot_k_j: &Array2<f64>,
2538 k_ddot: &Array2<f64>,
2539 x_tau_tau_is_some: bool,
2540 mat: &Array2<f64>,
2541 ) -> Array2<f64> {
2542 let n = self.x_dense.nrows();
2543 let m = mat.ncols();
2544 if mat.nrows() != n || m == 0 {
2545 return Array2::<f64>::zeros(mat.raw_dim());
2546 }
2547 let mut out = Array2::<f64>::zeros((n, m));
2548 for col in 0..m {
2549 let v = mat.column(col).to_owned();
2550 let s_zz = RemlState::reducedweighted_gram(x_r, &v); let s_zj = RemlState::reduced_crossweighted_gram(x_r, x_rj, &v); let s_iz = RemlState::reduced_crossweighted_gram(x_ri, x_r, &v); let s_jz = RemlState::reduced_crossweighted_gram(x_rj, x_r, &v); let s_ij = RemlState::reduced_crossweighted_gram(x_ri, x_rj, &v); let mut mdot_mdot = Array1::<f64>::zeros(n);
2570 {
2572 let core = k.dot(&s_zz).dot(&k.t());
2573 mdot_mdot = mdot_mdot + Self::rowwise_bilinear(x_ri, &core, x_rj);
2574 }
2575 {
2577 let core = k.dot(&s_zz).dot(&dot_k_j.t());
2578 mdot_mdot = mdot_mdot + Self::rowwise_bilinear(x_ri, &core, x_r);
2579 }
2580 {
2582 let core = k.dot(&s_zj).dot(&k.t());
2583 mdot_mdot = mdot_mdot + Self::rowwise_bilinear(x_ri, &core, x_r);
2584 }
2585 {
2587 let core = dot_k_i.dot(&s_zz).dot(&k.t());
2588 mdot_mdot = mdot_mdot + Self::rowwise_bilinear(x_r, &core, x_rj);
2589 }
2590 {
2592 let core = dot_k_i.dot(&s_zz).dot(&dot_k_j.t());
2593 mdot_mdot = mdot_mdot + Self::rowwise_bilinear(x_r, &core, x_r);
2594 }
2595 {
2597 let core = dot_k_i.dot(&s_zj).dot(&k.t());
2598 mdot_mdot = mdot_mdot + Self::rowwise_bilinear(x_r, &core, x_r);
2599 }
2600 {
2603 let core = k.dot(&s_iz).dot(&k.t());
2604 mdot_mdot = mdot_mdot + Self::rowwise_bilinear(x_r, &core, x_rj);
2605 }
2606 {
2609 let core = k.dot(&s_iz).dot(&dot_k_j.t());
2610 mdot_mdot = mdot_mdot + Self::rowwise_bilinear(x_r, &core, x_r);
2611 }
2612 {
2615 let core = k.dot(&s_ij).dot(&k.t());
2616 mdot_mdot = mdot_mdot + Self::rowwise_bilinear(x_r, &core, x_r);
2617 }
2618
2619 let mut m_mddot = Array1::<f64>::zeros(n);
2624 if x_tau_tau_is_some {
2626 let core = k.dot(&s_zz).dot(k);
2627 m_mddot = m_mddot + Self::rowwise_bilinear(x_rij, &core, x_r);
2628 }
2629 if x_tau_tau_is_some {
2631 let s_ijz = RemlState::reduced_crossweighted_gram(x_rij, x_r, &v);
2632 let core = k.dot(&s_ijz).dot(k);
2633 m_mddot = m_mddot + Self::rowwise_bilinear(x_r, &core, x_r);
2634 }
2635 {
2637 let core = dot_k_j.dot(&s_zz).dot(k);
2638 m_mddot = m_mddot + Self::rowwise_bilinear(x_ri, &core, x_r);
2639 }
2640 {
2642 let core = dot_k_j.dot(&s_iz).dot(k);
2643 m_mddot = m_mddot + Self::rowwise_bilinear(x_r, &core, x_r);
2644 }
2645 {
2647 let core = dot_k_i.dot(&s_zz).dot(k);
2648 m_mddot = m_mddot + Self::rowwise_bilinear(x_rj, &core, x_r);
2649 }
2650 {
2652 let core = dot_k_i.dot(&s_jz).dot(k);
2653 m_mddot = m_mddot + Self::rowwise_bilinear(x_r, &core, x_r);
2654 }
2655 {
2657 let core = k.dot(&s_jz).dot(k);
2658 m_mddot = m_mddot + Self::rowwise_bilinear(x_ri, &core, x_r);
2659 }
2660 {
2662 let core = k.dot(&s_iz).dot(k);
2663 m_mddot = m_mddot + Self::rowwise_bilinear(x_rj, &core, x_r);
2664 }
2665 {
2667 let core = k_ddot.dot(&s_zz).dot(k);
2668 m_mddot = m_mddot + Self::rowwise_bilinear(x_r, &core, x_r);
2669 }
2670
2671 let col_out = 2.0 * mdot_mdot + 2.0 * m_mddot;
2676 out.column_mut(col).assign(&col_out);
2677 }
2678 out
2679 }
2680
2681 pub(crate) fn d_beta_hphi_tau_partial_prepare_from_partials(
2692 &self,
2693 tau_kernel: &FirthTauPartialKernel,
2694 deta_partial: &Array1<f64>,
2695 dot_i_partial: &Array2<f64>,
2696 beta_direction: &FirthDirection,
2697 x_tau_v: &Array1<f64>,
2698 ) -> FirthTauBetaPartialKernel {
2699 let s_v = &self.w1 * &beta_direction.deta;
2711 let mixed_diag_weight = &(&tau_kernel.dotw1 * &beta_direction.deta) + &(&self.w1 * x_tau_v);
2712 let cross1 =
2713 RemlState::reduced_crossweighted_gram(&tau_kernel.x_tau_reduced, &self.x_reduced, &s_v);
2714 let cross2 =
2715 RemlState::reduced_crossweighted_gram(&self.x_reduced, &tau_kernel.x_tau_reduced, &s_v);
2716 let diag_piece = RemlState::reducedweighted_gram(&self.x_reduced, &mixed_diag_weight);
2717 let d_beta_dot_i = &cross1 + &cross2 + &diag_piece;
2718
2719 let term_a = beta_direction
2728 .a_u_reduced
2729 .dot(dot_i_partial)
2730 .dot(&self.k_reduced);
2731 let term_b = self.k_reduced.dot(&d_beta_dot_i).dot(&self.k_reduced);
2732 let term_c = self
2733 .k_reduced
2734 .dot(dot_i_partial)
2735 .dot(&beta_direction.a_u_reduced);
2736 let d_beta_dot_k = &term_a - &term_b + &term_c;
2737
2738 let cross_diag = Self::rowwise_bilinear(
2744 &tau_kernel.x_tau_reduced,
2745 &beta_direction.a_u_reduced,
2746 &self.x_reduced,
2747 );
2748 let inner_diag = RemlState::reduced_diag_gram(&self.x_reduced, &d_beta_dot_k);
2749 let d_beta_dot_h = -2.0 * &cross_diag + &inner_diag;
2750
2751 FirthTauBetaPartialKernel {
2752 x_tau_reduced: tau_kernel.x_tau_reduced.clone(),
2753 deta_partial: deta_partial.clone(),
2754 dot_h_partial: tau_kernel.dot_h_partial.clone(),
2755 dot_i_partial: dot_i_partial.clone(),
2756 dot_k_reduced: tau_kernel.dot_k_reduced.clone(),
2757 deta_v: beta_direction.deta.clone(),
2758 deta_tau_v: x_tau_v.clone(),
2759 a_v_reduced: beta_direction.a_u_reduced.clone(),
2760 dh_v: beta_direction.dh.clone(),
2761 b_vvec: beta_direction.b_uvec.clone(),
2762 d_beta_dot_k,
2763 d_beta_dot_h,
2764 }
2765 }
2766
2767 pub(crate) fn apply_p_tau_v_to_matrix(
2778 &self,
2779 kernel: &FirthTauBetaPartialKernel,
2780 mat: &Array2<f64>,
2781 ) -> Array2<f64> {
2782 let n = self.x_dense.nrows();
2783 if mat.nrows() != n || mat.ncols() == 0 {
2784 return Array2::<f64>::zeros(mat.raw_dim());
2785 }
2786 let z = &self.x_reduced;
2787 let z_tau = &kernel.x_tau_reduced;
2788 let k_r = &self.k_reduced;
2789 let a_v = &kernel.a_v_reduced; let dot_k_tau = &kernel.dot_k_reduced; let d_beta_dot_k = &kernel.d_beta_dot_k; let mut out = Array2::<f64>::zeros(mat.raw_dim());
2793 for col in 0..mat.ncols() {
2794 let v = mat.column(col).to_owned();
2795 let s_zz = RemlState::reducedweighted_gram(z, &v);
2796 let s_z_ztau = RemlState::reduced_crossweighted_gram(z, z_tau, &v);
2797
2798 let mid_1 = a_v.dot(&s_zz).dot(k_r);
2801 let t1 = -Self::rowwise_bilinear(z, &mid_1, z_tau);
2802 let mid_2 = a_v.dot(&s_z_ztau).dot(k_r);
2805 let t2 = -RemlState::reduced_diag_gram(z, &mid_2);
2806 let mid_3 = a_v.dot(&s_zz).dot(dot_k_tau);
2809 let t3 = -RemlState::reduced_diag_gram(z, &mid_3);
2810 let mid_4 = k_r.dot(&s_zz).dot(a_v);
2813 let t4 = -Self::rowwise_bilinear(z, &mid_4, z_tau);
2814 let mid_5 = k_r.dot(&s_z_ztau).dot(a_v);
2817 let t5 = -RemlState::reduced_diag_gram(z, &mid_5);
2818 let t6 = RemlState::apply_hadamard_gram(z, k_r, d_beta_dot_k, &v);
2820
2821 let y = 2.0 * (t1 + t2 + t3 + t4 + t5 + t6);
2824 out.column_mut(col).assign(&y);
2825 }
2826 out
2827 }
2828
2829 pub(crate) fn d_beta_hphi_tau_partial_apply(
2830 &self,
2831 x_tau: &Array2<f64>,
2832 kernel: &FirthTauBetaPartialKernel,
2833 rhs: &Array2<f64>,
2834 ) -> Array2<f64> {
2835 let p = self.x_dense.ncols();
2836 if rhs.nrows() != p {
2837 return Array2::<f64>::zeros((p, rhs.ncols()));
2838 }
2839 if rhs.ncols() == 0 || p == 0 {
2840 return Array2::<f64>::zeros((p, rhs.ncols()));
2841 }
2842 let etav = fast_ab(&self.x_dense, rhs);
2851 let etav_tau = fast_ab(x_tau, rhs);
2852 let deta_v = &kernel.deta_v;
2853 let deta_tau_v = &kernel.deta_tau_v;
2854 let eta_tau = &kernel.deta_partial;
2855 let dot_h = &kernel.dot_h_partial;
2856
2857 let dotw1 = &self.w2 * eta_tau;
2859 let dotw2 = &self.w3 * eta_tau;
2860
2861 let c_v = &(&(&self.w3 * deta_v) * &self.h_diag) + &(&self.w2 * &kernel.dh_v);
2867 let b_vvec = &kernel.b_vvec;
2868 let d_beta_dotw1_vec = &(&(&self.w3 * deta_v) * eta_tau) + &(&self.w2 * deta_tau_v);
2869 let d_beta_dotw2_vec = &(&(&self.w4 * deta_v) * eta_tau) + &(&self.w3 * deta_tau_v);
2870
2871 let qv = &etav * &self.w1.view().insert_axis(Axis(1));
2873 let qv_tau = &etav * &dotw1.view().insert_axis(Axis(1))
2874 + &etav_tau * &self.w1.view().insert_axis(Axis(1));
2875 let m_qv = self.apply_pbar_to_matrix(&qv);
2876 let tau_kernel_view = FirthTauPartialKernel {
2879 deta_partial: eta_tau.clone(),
2880 dotw1: dotw1.clone(),
2881 dotw2: dotw2.clone(),
2882 dot_h_partial: dot_h.clone(),
2883 x_tau_reduced: kernel.x_tau_reduced.clone(),
2884 dot_i_partial: kernel.dot_i_partial.clone(),
2885 dot_k_reduced: kernel.dot_k_reduced.clone(),
2886 };
2887 let m_qv_tau =
2888 self.apply_mtau_to_matrix(&tau_kernel_view, &qv) + self.apply_pbar_to_matrix(&qv_tau);
2889
2890 let d_beta_qv = &etav * &b_vvec.view().insert_axis(Axis(1));
2894 let d_beta_qv_tau = &etav * &d_beta_dotw1_vec.view().insert_axis(Axis(1))
2895 + &etav_tau * &b_vvec.view().insert_axis(Axis(1));
2896
2897 let d_beta_m_qv = self.apply_p_u_to_matrix(&kernel.a_v_reduced, &qv)
2899 + self.apply_pbar_to_matrix(&d_beta_qv);
2900
2901 let d_beta_m_qv_tau = self.apply_p_tau_v_to_matrix(kernel, &qv)
2903 + self.apply_mtau_to_matrix(&tau_kernel_view, &d_beta_qv)
2904 + self.apply_p_u_to_matrix(&kernel.a_v_reduced, &qv_tau)
2905 + self.apply_pbar_to_matrix(&d_beta_qv_tau);
2906
2907 let d_beta_rv = &etav * &c_v.view().insert_axis(Axis(1))
2910 - &m_qv * &b_vvec.view().insert_axis(Axis(1))
2911 - &d_beta_m_qv * &self.w1.view().insert_axis(Axis(1));
2912
2913 let d_beta_dotw2_h = &(&d_beta_dotw2_vec * &self.h_diag) + &(&dotw2 * &kernel.dh_v);
2924 let d_beta_w2_doth = &(&(&self.w3 * deta_v) * dot_h) + &(&self.w2 * &kernel.d_beta_dot_h);
2925
2926 let d_beta_rv_tau = &etav * &d_beta_dotw2_h.view().insert_axis(Axis(1))
2927 + &etav_tau * &c_v.view().insert_axis(Axis(1))
2928 + &etav * &d_beta_w2_doth.view().insert_axis(Axis(1))
2929 - &d_beta_m_qv * &dotw1.view().insert_axis(Axis(1))
2930 - &m_qv * &d_beta_dotw1_vec.view().insert_axis(Axis(1))
2931 - &d_beta_m_qv_tau * &self.w1.view().insert_axis(Axis(1))
2932 - &m_qv_tau * &b_vvec.view().insert_axis(Axis(1));
2933
2934 0.5 * (x_tau.t().dot(&d_beta_rv) + self.x_dense.t().dot(&d_beta_rv_tau))
2935 }
2936}
2937
2938#[cfg(test)]
2939mod tests {
2940 use super::*;
2941 use crate::mixture_link::logit_inverse_link_jet5;
2942 use gam_problem::StandardLink;
2943 use ndarray::{Array1, Array2, array};
2944
2945 impl FirthDenseOperator {
2951 pub(crate) fn pirls_hat_diag(&self) -> Array1<f64> {
2952 &self.w * &self.h_diag
2953 }
2954
2955 pub(crate) fn pirls_firth_score_shift(&self) -> Array1<f64> {
2959 let mut shift = Array1::<f64>::zeros(self.w.len());
2960 for i in 0..self.w.len() {
2961 let wi = self.w[i];
2962 if wi > 0.0 {
2963 shift[i] = 0.5 * (self.w1[i] / wi) * self.h_diag[i];
2964 }
2965 }
2966 shift
2967 }
2968 }
2969
2970 pub(crate) fn build_logit_firth_dense_operator(
2971 x_dense: &Array2<f64>,
2972 eta: &Array1<f64>,
2973 ) -> Result<FirthDenseOperator, EstimationError> {
2974 FirthDenseOperator::build_with_observation_weights_impl(
2975 &InverseLink::Standard(StandardLink::Logit),
2976 x_dense,
2977 eta,
2978 None,
2979 )
2980 }
2981
2982 pub(crate) fn build_weighted_logit_firth_dense_operator(
2983 x_dense: &Array2<f64>,
2984 eta: &Array1<f64>,
2985 observation_weights: ndarray::ArrayView1<'_, f64>,
2986 ) -> Result<FirthDenseOperator, EstimationError> {
2987 FirthDenseOperator::build_with_observation_weights_impl(
2988 &InverseLink::Standard(StandardLink::Logit),
2989 x_dense,
2990 eta,
2991 Some(observation_weights),
2992 )
2993 }
2994
2995 pub(crate) fn logisticweight(eta: f64) -> f64 {
2996 logit_inverse_link_jet5(eta).d1
2997 }
2998
2999 pub(crate) fn firthphivalue(x: &Array2<f64>, beta: &Array1<f64>) -> f64 {
3000 let eta = x.dot(beta);
3001 let op = build_logit_firth_dense_operator(x, &eta).expect("firth operator");
3002 op.jeffreys_logdet()
3003 }
3004
3005 pub(crate) fn firthgradphi(x: &Array2<f64>, beta: &Array1<f64>) -> Array1<f64> {
3006 let eta = x.dot(beta);
3007 let op = build_logit_firth_dense_operator(x, &eta).expect("firth operator");
3008 op.jeffreys_beta_gradient()
3009 }
3010
3011 pub(crate) fn weighted_firthphivalue(
3012 x: &Array2<f64>,
3013 beta: &Array1<f64>,
3014 observation_weights: &Array1<f64>,
3015 ) -> f64 {
3016 let eta = x.dot(beta);
3017 let op = build_weighted_logit_firth_dense_operator(x, &eta, observation_weights.view())
3018 .expect("weighted firth operator");
3019 op.jeffreys_logdet()
3020 }
3021
3022 #[test]
3023 pub(crate) fn firth_reduced_fisher_logdet_is_finite_for_barely_pd_matrix() {
3024 let fisher = array![[16.0, 0.0], [0.0, 1e-15]];
3025 let (k_reduced, half_log_det) = RemlState::reduced_fisher_inverse_and_half_logdet(&fisher)
3026 .expect("barely positive-definite reduced fisher");
3027 let expected = 0.5 * 16.0_f64.ln();
3028
3029 assert!(
3030 half_log_det.is_finite(),
3031 "barely positive-definite reduced fisher produced non-finite half logdet: {half_log_det}"
3032 );
3033 assert!(
3034 (half_log_det - expected).abs() < 1e-12,
3035 "near-null Fisher direction should be excluded from pseudo-logdet: got {half_log_det}, expected {expected}"
3036 );
3037 assert!(
3038 k_reduced.iter().all(|value| value.is_finite()),
3039 "barely positive-definite reduced fisher produced non-finite inverse entries: {k_reduced:?}"
3040 );
3041 assert!(
3042 k_reduced[[1, 1]].abs() < f64::EPSILON,
3043 "near-null Fisher direction should be excluded from pseudo-inverse: {k_reduced:?}"
3044 );
3045 }
3046
3047 #[test]
3048 pub(crate) fn firth_logisticweight_derivatives_match_finite_difference() {
3049 let x = array![
3068 [1.0, -1.1, 0.2],
3069 [1.0, -0.5, -0.6],
3070 [1.0, 0.0, 0.3],
3071 [1.0, 0.8, -0.4],
3072 [1.0, 1.2, 0.7],
3073 ];
3074 let beta = array![0.15, -0.6, 0.35];
3075 let eta = x.dot(&beta);
3076 let op = build_logit_firth_dense_operator(&x, &eta).expect("firth operator");
3077
3078 let h = 1e-2_f64;
3079 let w = |z: f64| logisticweight(z);
3080 let d1direct = |z: f64| (w(z + h) - w(z - h)) / (2.0 * h);
3081 let d2direct = |z: f64| (w(z + h) - 2.0 * w(z) + w(z - h)) / (h * h);
3082 let d3direct = |z: f64| {
3083 (-w(z - 2.0 * h) + 2.0 * w(z - h) - 2.0 * w(z + h) + w(z + 2.0 * h)) / (2.0 * h.powi(3))
3084 };
3085 let d4direct = |z: f64| {
3086 (w(z - 2.0 * h) - 4.0 * w(z - h) + 6.0 * w(z) - 4.0 * w(z + h) + w(z + 2.0 * h))
3087 / h.powi(4)
3088 };
3089 for i in 0..eta.len() {
3090 let z = eta[i];
3091 let wfd = w(z);
3092 let w1fd = d1direct(z);
3093 let w2fd = d2direct(z);
3094 let w3fd = d3direct(z);
3095 let w4fd = d4direct(z);
3096
3097 assert!((op.w[i] - wfd).abs() < 1e-12);
3098 assert_eq!(op.w1[i].signum(), w1fd.signum());
3099 assert_eq!(op.w2[i].signum(), w2fd.signum());
3100 assert_eq!(op.w3[i].signum(), w3fd.signum());
3101 assert_eq!(op.w4[i].signum(), w4fd.signum());
3102 assert!((op.w1[i] - w1fd).abs() < 1e-5);
3103 assert!((op.w2[i] - w2fd).abs() < 1e-4);
3104 assert!((op.w3[i] - w3fd).abs() < 1e-4);
3105 assert!((op.w4[i] - w4fd).abs() < 1e-3);
3106 }
3107 }
3108
3109 #[test]
3110 pub(crate) fn weighted_firth_jeffreys_gradient_matches_finite_difference() {
3111 let x = array![
3112 [1.0, -0.7, 0.3],
3113 [1.0, -0.2, -0.4],
3114 [1.0, 0.5, 0.1],
3115 [1.0, 1.1, -0.6],
3116 [1.0, 1.6, 0.8],
3117 ];
3118 let beta = array![0.2, -0.45, 0.25];
3119 let observation_weights = array![1.0, 0.5, 2.0, 1.5, 0.75];
3120 let eta = x.dot(&beta);
3121 let op = build_weighted_logit_firth_dense_operator(&x, &eta, observation_weights.view())
3122 .expect("weighted firth operator");
3123 let grad = op.jeffreys_beta_gradient();
3124 let h = 1e-6;
3125
3126 for j in 0..beta.len() {
3127 let mut beta_plus = beta.clone();
3128 beta_plus[j] += h;
3129 let mut beta_minus = beta.clone();
3130 beta_minus[j] -= h;
3131 let fd = (weighted_firthphivalue(&x, &beta_plus, &observation_weights)
3132 - weighted_firthphivalue(&x, &beta_minus, &observation_weights))
3133 / (2.0 * h);
3134 assert!(
3135 (grad[j] - fd).abs() < 1e-5,
3136 "weighted Firth gradient mismatch at {}: analytic={}, fd={}",
3137 j,
3138 grad[j],
3139 fd
3140 );
3141 }
3142 }
3143
3144 pub(crate) fn build_link_firth_op(
3152 link: StandardLink,
3153 x: &Array2<f64>,
3154 beta: &Array1<f64>,
3155 ) -> FirthDenseOperator {
3156 let eta = x.dot(beta);
3157 FirthDenseOperator::build_with_observation_weights_impl(
3158 &InverseLink::Standard(link),
3159 x,
3160 &eta,
3161 None,
3162 )
3163 .expect("link-general firth operator")
3164 }
3165
3166 pub(crate) fn link_firth_phi(link: StandardLink, x: &Array2<f64>, beta: &Array1<f64>) -> f64 {
3167 build_link_firth_op(link, x, beta).jeffreys_logdet()
3168 }
3169
3170 pub(crate) fn link_firth_grad(
3171 link: StandardLink,
3172 x: &Array2<f64>,
3173 beta: &Array1<f64>,
3174 ) -> Array1<f64> {
3175 build_link_firth_op(link, x, beta).jeffreys_beta_gradient()
3176 }
3177
3178 pub(crate) fn numeric_firth_hessian(
3183 link: StandardLink,
3184 x: &Array2<f64>,
3185 beta: &Array1<f64>,
3186 h: f64,
3187 ) -> Array2<f64> {
3188 let p = beta.len();
3189 let mut hess = Array2::<f64>::zeros((p, p));
3190 for j in 0..p {
3191 let mut bp = beta.clone();
3192 bp[j] += h;
3193 let mut bm = beta.clone();
3194 bm[j] -= h;
3195 let gp = link_firth_grad(link, x, &bp);
3196 let gm = link_firth_grad(link, x, &bm);
3197 let col = (&gp - &gm) / (2.0 * h);
3198 hess.column_mut(j).assign(&col);
3199 }
3200 hess
3201 }
3202
3203 #[test]
3210 fn hphisecond_eye_cached_matches_per_pair_bit_identical_1575() {
3211 let x = array![
3214 [1.0, -1.10, 0.35],
3215 [1.0, -0.40, -0.65],
3216 [1.0, 0.15, 0.20],
3217 [1.0, 0.80, -0.45],
3218 [1.0, 1.25, 0.70],
3219 [1.0, -0.55, 0.95],
3220 ];
3221 let beta = array![0.20, -0.55, 0.30];
3222 let op = build_link_firth_op(StandardLink::Logit, &x, &beta);
3223 let p = x.ncols();
3224
3225 let deta_list = [
3227 x.dot(&array![0.9, -0.3, 0.2]),
3228 x.dot(&array![-0.4, 0.7, 0.1]),
3229 x.dot(&array![0.1, 0.2, -0.8]),
3230 ];
3231 let dirs: Vec<FirthDirection> = deta_list
3232 .iter()
3233 .map(|d| op.direction_from_deta(d.clone()))
3234 .collect();
3235
3236 let eye = Array2::<f64>::eye(p);
3237 let cache = op.tk_second_direction_eye_cache(&dirs);
3238 for i in 0..dirs.len() {
3239 for j in 0..=i {
3240 let reference = op.hphisecond_direction_apply(&dirs[i], &dirs[j], &eye);
3241 let cached = op.hphisecond_direction_apply_eye_cached(&cache, &dirs, i, j);
3242 assert_eq!(
3243 reference.dim(),
3244 cached.dim(),
3245 "shape mismatch at pair ({i},{j})"
3246 );
3247 for (a, b) in reference.iter().zip(cached.iter()) {
3248 assert_eq!(
3249 a.to_bits(),
3250 b.to_bits(),
3251 "cached D²H_φ[{i},{j}] is not bit-identical to per-pair: \
3252 reference={a}, cached={b}"
3253 );
3254 }
3255 }
3256 }
3257 }
3258
3259 pub(crate) fn fixed_design_5x3() -> Array2<f64> {
3261 array![
3262 [1.0, -1.10, 0.35],
3263 [1.0, -0.40, -0.65],
3264 [1.0, 0.15, 0.20],
3265 [1.0, 0.80, -0.45],
3266 [1.0, 1.25, 0.70],
3267 ]
3268 }
3269
3270 #[test]
3271 pub(crate) fn link_general_logit_path_reproduces_historical_logit_build() {
3272 let x = fixed_design_5x3();
3277 let beta = array![0.20, -0.55, 0.30];
3278 let eta = x.dot(&beta);
3279
3280 let historical = build_logit_firth_dense_operator(&x, &eta).expect("historical logit");
3281 let link_general = FirthDenseOperator::build_with_observation_weights_impl(
3282 &InverseLink::Standard(StandardLink::Logit),
3283 &x,
3284 &eta,
3285 None,
3286 )
3287 .expect("link-general logit");
3288
3289 assert_eq!(
3290 historical.jeffreys_logdet(),
3291 link_general.jeffreys_logdet(),
3292 "logit Φ must be bit-identical through the link-general path"
3293 );
3294 let g_hist = historical.jeffreys_beta_gradient();
3295 let g_link = link_general.jeffreys_beta_gradient();
3296 for j in 0..g_hist.len() {
3297 assert_eq!(
3298 g_hist[j], g_link[j],
3299 "logit gradient component {j} must be bit-identical"
3300 );
3301 }
3302 let hat_hist = historical.pirls_hat_diag();
3303 let hat_link = link_general.pirls_hat_diag();
3304 for i in 0..hat_hist.len() {
3305 assert_eq!(
3306 hat_hist[i], hat_link[i],
3307 "logit PIRLS hat diagonal {i} must be bit-identical"
3308 );
3309 }
3310 for i in 0..eta.len() {
3311 assert_eq!(historical.w[i], link_general.w[i]);
3312 assert_eq!(historical.w1[i], link_general.w1[i]);
3313 assert_eq!(historical.w2[i], link_general.w2[i]);
3314 assert_eq!(historical.w3[i], link_general.w3[i]);
3315 assert_eq!(historical.w4[i], link_general.w4[i]);
3316 }
3317 }
3318
3319 #[test]
3320 pub(crate) fn link_general_probit_jeffreys_gradient_matches_finite_difference() {
3321 let x = fixed_design_5x3();
3324 let beta = array![0.10, -0.40, 0.25];
3325 let grad = link_firth_grad(StandardLink::Probit, &x, &beta);
3326 let h = 1e-6_f64;
3327 let mut max_rel = 0.0_f64;
3328 for j in 0..beta.len() {
3329 let mut bp = beta.clone();
3330 bp[j] += h;
3331 let mut bm = beta.clone();
3332 bm[j] -= h;
3333 let fd = (link_firth_phi(StandardLink::Probit, &x, &bp)
3334 - link_firth_phi(StandardLink::Probit, &x, &bm))
3335 / (2.0 * h);
3336 let denom = grad[j].abs().max(fd.abs()).max(1e-8);
3337 let rel = (grad[j] - fd).abs() / denom;
3338 max_rel = max_rel.max(rel);
3339 assert!(
3340 rel < 1e-6,
3341 "probit Firth gradient mismatch at {j}: analytic={}, fd={}, rel={:e}",
3342 grad[j],
3343 fd,
3344 rel
3345 );
3346 }
3347 assert!(
3348 max_rel < 1e-6,
3349 "probit gradient worst relative error {max_rel:e} exceeds 1e-6"
3350 );
3351 }
3352
3353 #[test]
3354 pub(crate) fn link_general_probit_hphi_direction_matches_finite_difference_of_hessian() {
3355 let x = fixed_design_5x3();
3363 let beta = array![0.10, -0.40, 0.25];
3364 let p = beta.len();
3365
3366 let directions = [
3368 array![1.0, 0.0, 0.0],
3369 array![0.0, 1.0, 0.0],
3370 array![0.0, 0.0, 1.0],
3371 array![0.7, -0.5, 0.3],
3372 ];
3373
3374 let h_inner = 1e-4_f64; let h_dir = 1e-4_f64; let mut worst = 0.0_f64;
3377 for u in directions.iter() {
3378 let op = build_link_firth_op(StandardLink::Probit, &x, &beta);
3379 let deta = x.dot(u);
3380 let dir = op.direction_from_deta(deta);
3381 let analytic = op.hphi_direction(&dir);
3382
3383 let beta_plus = &beta + &(u * h_dir);
3384 let beta_minus = &beta - &(u * h_dir);
3385 let hess_plus = numeric_firth_hessian(StandardLink::Probit, &x, &beta_plus, h_inner);
3386 let hess_minus = numeric_firth_hessian(StandardLink::Probit, &x, &beta_minus, h_inner);
3387 let fd = (&hess_plus - &hess_minus) / (2.0 * h_dir);
3388
3389 let mut scale = 1e-6_f64;
3390 for r in 0..p {
3391 for c in 0..p {
3392 scale = scale.max(analytic[[r, c]].abs()).max(fd[[r, c]].abs());
3393 }
3394 }
3395 for r in 0..p {
3396 for c in 0..p {
3397 let rel = (analytic[[r, c]] - fd[[r, c]]).abs() / scale;
3398 worst = worst.max(rel);
3399 assert!(
3400 rel < 5e-3,
3401 "probit D H_φ[u] mismatch at ({r},{c}) for u={u:?}: analytic={}, fd={}, rel={:e}",
3402 analytic[[r, c]],
3403 fd[[r, c]],
3404 rel
3405 );
3406 }
3407 }
3408 }
3409 assert!(
3410 worst < 5e-3,
3411 "probit Hessian-derivative worst relative error {worst:e} exceeds 5e-3"
3412 );
3413 }
3414
3415 #[test]
3416 pub(crate) fn link_general_probit_jeffreys_finite_on_rank_deficient_design() {
3417 let x_full = array![
3421 [1.0, -1.20, -0.20],
3422 [1.0, -0.40, 0.60],
3423 [1.0, 0.10, 1.10],
3424 [1.0, 0.70, 1.70],
3425 [1.0, 1.30, 2.30],
3426 ];
3427 let x_reduced = array![
3428 [1.0, -1.20],
3429 [1.0, -0.40],
3430 [1.0, 0.10],
3431 [1.0, 0.70],
3432 [1.0, 1.30],
3433 ];
3434 let beta_full = array![0.25, -0.50, 0.15];
3435 let beta_reduced = array![beta_full[0] + beta_full[2], beta_full[1] + beta_full[2]];
3436
3437 let phi_full = link_firth_phi(StandardLink::Probit, &x_full, &beta_full);
3438 let phi_reduced = link_firth_phi(StandardLink::Probit, &x_reduced, &beta_reduced);
3439 assert!(
3440 phi_full.is_finite(),
3441 "probit Φ on rank-deficient design must be finite, got {phi_full}"
3442 );
3443 assert!(
3444 (phi_full - phi_reduced).abs() < 1e-12,
3445 "probit reduced |Uᵀ W U| form mismatch: full={phi_full}, reduced={phi_reduced}"
3446 );
3447
3448 let op_full = build_link_firth_op(StandardLink::Probit, &x_full, &beta_full);
3449 let grad_full = op_full.jeffreys_beta_gradient();
3450 assert!(
3451 grad_full.iter().all(|v| v.is_finite()),
3452 "probit gradient on rank-deficient design must be finite: {grad_full:?}"
3453 );
3454 let hat_full = op_full.pirls_hat_diag();
3455 let hat_reduced =
3456 build_link_firth_op(StandardLink::Probit, &x_reduced, &beta_reduced).pirls_hat_diag();
3457 for i in 0..hat_full.len() {
3458 assert!(
3459 (hat_full[i] - hat_reduced[i]).abs() < 1e-12,
3460 "probit hat diagonal {i} mismatch on rank-deficient design: full={}, reduced={}",
3461 hat_full[i],
3462 hat_reduced[i]
3463 );
3464 }
3465 }
3466
3467 #[test]
3468 pub(crate) fn rank_deficient_and_explicit_reduced_designs_share_same_jeffreys_objective() {
3469 let x_full = array![
3473 [1.0, -1.2, -0.2],
3474 [1.0, -0.4, 0.6],
3475 [1.0, 0.1, 1.1],
3476 [1.0, 0.7, 1.7],
3477 [1.0, 1.3, 2.3],
3478 ];
3479 let x_reduced = array![[1.0, -1.2], [1.0, -0.4], [1.0, 0.1], [1.0, 0.7], [1.0, 1.3],];
3480 let beta_full: ndarray::Array1<f64> = array![0.25, -0.5, 0.15];
3481 let beta_reduced = array![beta_full[0] + beta_full[2], beta_full[1] + beta_full[2]];
3482 let eta_full = x_full.dot(&beta_full);
3483 let eta_reduced = x_reduced.dot(&beta_reduced);
3484 let observation_weights = array![1.0, 0.5, 1.75, 0.9, 1.2];
3485
3486 for i in 0..eta_full.len() {
3487 assert!(
3488 (eta_full[i] - eta_reduced[i]).abs() < 1e-12,
3489 "eta mismatch at row {i}: full={} reduced={}",
3490 eta_full[i],
3491 eta_reduced[i]
3492 );
3493 }
3494
3495 let op_full = build_weighted_logit_firth_dense_operator(
3496 &x_full,
3497 &eta_full,
3498 observation_weights.view(),
3499 )
3500 .expect("full firth operator");
3501 let op_reduced = build_weighted_logit_firth_dense_operator(
3502 &x_reduced,
3503 &eta_reduced,
3504 observation_weights.view(),
3505 )
3506 .expect("reduced firth operator");
3507
3508 assert!(
3509 (op_full.jeffreys_logdet() - op_reduced.jeffreys_logdet()).abs() < 1e-12,
3510 "Jeffreys logdet mismatch between rank-deficient full design and its explicit reduced identifiable basis: full={} reduced={}",
3511 op_full.jeffreys_logdet(),
3512 op_reduced.jeffreys_logdet()
3513 );
3514
3515 let hat_full = op_full.pirls_hat_diag();
3516 let hat_reduced = op_reduced.pirls_hat_diag();
3517 for i in 0..hat_full.len() {
3518 assert!(
3519 (hat_full[i] - hat_reduced[i]).abs() < 1e-12,
3520 "PIRLS hat-diagonal mismatch at row {i}: full={} reduced={}",
3521 hat_full[i],
3522 hat_reduced[i]
3523 );
3524 }
3525 }
3526
3527 #[test]
3528 pub(crate) fn full_rank_reparameterizations_share_same_jeffreys_objective() {
3529 let x = array![[1.0, -1.2], [1.0, -0.4], [1.0, 0.1], [1.0, 0.7], [1.0, 1.3],];
3530 let basis = array![[1.4, -0.3], [0.6, 1.1]];
3531 let x_reparameterized = x.dot(&basis);
3532 let beta = array![0.25, -0.5];
3533 let basis_det: f64 = basis[[0, 0]] * basis[[1, 1]] - basis[[0, 1]] * basis[[1, 0]];
3534 assert!(
3535 basis_det.abs() > 1e-12,
3536 "basis transform must be invertible"
3537 );
3538 let basis_inv = array![
3539 [basis[[1, 1]] / basis_det, -basis[[0, 1]] / basis_det],
3540 [-basis[[1, 0]] / basis_det, basis[[0, 0]] / basis_det],
3541 ];
3542 let beta_reparameterized = basis_inv.dot(&beta);
3543 let eta = x.dot(&beta);
3544 let eta_reparameterized = x_reparameterized.dot(&beta_reparameterized);
3545 let observation_weights = array![1.0, 0.5, 1.75, 0.9, 1.2];
3546
3547 for i in 0..eta.len() {
3548 assert!(
3549 (eta[i] - eta_reparameterized[i]).abs() < 1e-12,
3550 "eta mismatch at row {i}: original={} reparameterized={}",
3551 eta[i],
3552 eta_reparameterized[i]
3553 );
3554 }
3555
3556 let op = build_weighted_logit_firth_dense_operator(&x, &eta, observation_weights.view())
3557 .expect("original firth operator");
3558 let op_reparameterized = build_weighted_logit_firth_dense_operator(
3559 &x_reparameterized,
3560 &eta_reparameterized,
3561 observation_weights.view(),
3562 )
3563 .expect("reparameterized firth operator");
3564
3565 assert!(
3566 (op.jeffreys_logdet() - op_reparameterized.jeffreys_logdet()).abs() < 1e-12,
3567 "Jeffreys logdet mismatch under invertible reparameterization: original={} reparameterized={}",
3568 op.jeffreys_logdet(),
3569 op_reparameterized.jeffreys_logdet()
3570 );
3571
3572 let hat = op.pirls_hat_diag();
3573 let hat_reparameterized = op_reparameterized.pirls_hat_diag();
3574 for i in 0..hat.len() {
3575 assert!(
3576 (hat[i] - hat_reparameterized[i]).abs() < 1e-12,
3577 "PIRLS hat-diagonal mismatch at row {i}: original={} reparameterized={}",
3578 hat[i],
3579 hat_reparameterized[i]
3580 );
3581 }
3582 }
3583
3584 #[test]
3585 pub(crate) fn full_rank_identifiable_basis_diagonalizes_design_metric() {
3586 let x = array![[1.0, -1.2], [1.0, -0.4], [1.0, 0.1], [1.0, 0.7], [1.0, 1.3],];
3587 let beta = array![0.25, -0.5];
3588 let eta = x.dot(&beta);
3589 let observation_weights = array![1.0, 0.5, 1.75, 0.9, 1.2];
3590 let op = build_weighted_logit_firth_dense_operator(&x, &eta, observation_weights.view())
3591 .expect("firth operator");
3592
3593 let reduced_metric = fast_atb(&op.x_reduced, &op.x_reduced);
3594 for i in 0..reduced_metric.nrows() {
3595 for j in 0..reduced_metric.ncols() {
3596 if i == j {
3597 continue;
3598 }
3599 assert!(
3600 reduced_metric[[i, j]].abs() < 1e-10,
3601 "full-rank identifiable basis should diagonalize X_r'X_r: metric[{i},{j}]={}",
3602 reduced_metric[[i, j]]
3603 );
3604 }
3605 }
3606 }
3607
3608 #[test]
3609 pub(crate) fn firth_mixedsecond_direction_apply_is_symmetric_in_direction_order() {
3610 let x = array![
3611 [1.0, -1.0, 0.2],
3612 [1.0, -0.6, -0.3],
3613 [1.0, -0.1, 0.5],
3614 [1.0, 0.3, -0.7],
3615 [1.0, 0.8, 0.1],
3616 [1.0, 1.2, -0.4],
3617 ];
3618 let beta = array![0.1, -0.25, 0.2];
3619 let eta = x.dot(&beta);
3620 let op = build_logit_firth_dense_operator(&x, &eta).expect("firth operator");
3621
3622 let u = array![0.3, -0.2, 0.4];
3623 let v = array![-0.5, 0.1, 0.25];
3624 let du = op.direction_from_deta(x.dot(&u));
3625 let dv = op.direction_from_deta(x.dot(&v));
3626
3627 let eye = Array2::<f64>::eye(x.ncols());
3628 let uv = op.hphisecond_direction_apply(&du, &dv, &eye);
3629 let vu = op.hphisecond_direction_apply(&dv, &du, &eye);
3630
3631 for i in 0..uv.nrows() {
3632 for j in 0..uv.ncols() {
3633 let a = uv[[i, j]];
3634 let b = vu[[i, j]];
3635 assert_eq!(
3636 a.signum(),
3637 b.signum(),
3638 "mixed direction sign mismatch at ({i},{j}): uv={a} vu={b}"
3639 );
3640 assert!(
3641 (a - b).abs() < 2e-7,
3642 "mixed direction mismatch at ({i},{j}): uv={a} vu={b}"
3643 );
3644 }
3645 }
3646 }
3647
3648 #[test]
3649 pub(crate) fn firth_direction_matrix_form_matches_apply_identity_form() {
3650 let x = array![
3651 [1.0, -1.1, 0.2],
3652 [1.0, -0.6, -0.3],
3653 [1.0, -0.1, 0.5],
3654 [1.0, 0.3, -0.7],
3655 [1.0, 0.8, 0.1],
3656 [1.0, 1.2, -0.4],
3657 ];
3658 let beta = array![0.08, -0.22, 0.27];
3659 let eta = x.dot(&beta);
3660 let op = build_logit_firth_dense_operator(&x, &eta).expect("firth operator");
3661 let u = Array1::from_vec(vec![0.25, -0.4, 0.35]);
3662 let dir = op.direction_from_deta(x.dot(&u));
3663
3664 let p = x.ncols();
3665 let eye = Array2::<f64>::eye(p);
3666 let mut via_apply = op.hphi_direction_apply(&dir, &eye);
3667 for i in 0..p {
3668 for j in 0..i {
3669 let sym = 0.5 * (via_apply[[i, j]] + via_apply[[j, i]]);
3670 via_apply[[i, j]] = sym;
3671 via_apply[[j, i]] = sym;
3672 }
3673 }
3674 let direct = op.hphi_direction(&dir);
3675 let diff = &direct - &via_apply;
3676 let err = diff.iter().map(|v| v * v).sum::<f64>().sqrt();
3677 assert!(err < 1e-10, "direction/apply mismatch: {err:e}");
3678 }
3679
3680 #[test]
3681 pub(crate) fn firthphi_tau_partial_matches_finite_difference_logdet() {
3682 let x = array![
3683 [1.0, -1.0, 0.2],
3684 [1.0, -0.6, -0.3],
3685 [1.0, -0.1, 0.5],
3686 [1.0, 0.3, -0.7],
3687 [1.0, 0.8, 0.1],
3688 [1.0, 1.2, -0.4],
3689 ];
3690 let x_tau = array![
3691 [0.0, 0.15, -0.05],
3692 [0.0, -0.10, 0.02],
3693 [0.0, 0.08, 0.04],
3694 [0.0, -0.06, -0.03],
3695 [0.0, 0.05, 0.01],
3696 [0.0, -0.12, 0.06],
3697 ];
3698 let beta = array![0.1, -0.25, 0.2];
3699 let eta = x.dot(&beta);
3700 let op = build_logit_firth_dense_operator(&x, &eta).expect("firth operator");
3701 let analytic = op.exact_tau_kernel(&x_tau, &beta, false).phi_tau_partial;
3702
3703 let h = 1e-6;
3704 let x_plus = &x + &(h * &x_tau);
3705 let x_minus = &x - &(h * &x_tau);
3706 let fd = (firthphivalue(&x_plus, &beta) - firthphivalue(&x_minus, &beta)) / (2.0 * h);
3707
3708 assert!(
3709 (analytic - fd).abs() < 1e-6,
3710 "Phi_tau mismatch: analytic={analytic:.12e}, fd={fd:.12e}"
3711 );
3712 }
3713
3714 #[test]
3715 pub(crate) fn firth_gphi_tau_matches_finite_differencegradphi() {
3716 let x = array![
3717 [1.0, -1.0, 0.2],
3718 [1.0, -0.6, -0.3],
3719 [1.0, -0.1, 0.5],
3720 [1.0, 0.3, -0.7],
3721 [1.0, 0.8, 0.1],
3722 [1.0, 1.2, -0.4],
3723 ];
3724 let x_tau = array![
3725 [0.0, 0.15, -0.05],
3726 [0.0, -0.10, 0.02],
3727 [0.0, 0.08, 0.04],
3728 [0.0, -0.06, -0.03],
3729 [0.0, 0.05, 0.01],
3730 [0.0, -0.12, 0.06],
3731 ];
3732 let beta = array![0.1, -0.25, 0.2];
3733 let eta = x.dot(&beta);
3734 let op = build_logit_firth_dense_operator(&x, &eta).expect("firth operator");
3735 let analytic = op.exact_tau_kernel(&x_tau, &beta, false).gphi_tau;
3736
3737 let h = 1e-6;
3738 let x_plus = &x + &(h * &x_tau);
3739 let x_minus = &x - &(h * &x_tau);
3740 let fd = (firthgradphi(&x_plus, &beta) - firthgradphi(&x_minus, &beta)) / (2.0 * h);
3741
3742 let err = (&analytic - &fd).iter().map(|v| v * v).sum::<f64>().sqrt();
3743 assert!(
3744 err < 1e-6,
3745 "gphi_tau mismatch: analytic={analytic:?}, fd={fd:?}, err={err:e}"
3746 );
3747 }
3748
3749 #[test]
3754 pub(crate) fn firthphi_tau_tau_pair_scalar_matches_finite_difference() {
3755 let x = array![
3756 [1.0, -1.0, 0.2],
3757 [1.0, -0.6, -0.3],
3758 [1.0, -0.1, 0.5],
3759 [1.0, 0.3, -0.7],
3760 [1.0, 0.8, 0.1],
3761 [1.0, 1.2, -0.4],
3762 ];
3763 let x_tau_i = array![
3764 [0.0, 0.15, -0.05],
3765 [0.0, -0.10, 0.02],
3766 [0.0, 0.08, 0.04],
3767 [0.0, -0.06, -0.03],
3768 [0.0, 0.05, 0.01],
3769 [0.0, -0.12, 0.06],
3770 ];
3771 let x_tau_j = array![
3772 [0.0, -0.04, 0.11],
3773 [0.0, 0.09, -0.02],
3774 [0.0, -0.06, 0.07],
3775 [0.0, 0.10, -0.05],
3776 [0.0, -0.03, 0.08],
3777 [0.0, 0.07, -0.09],
3778 ];
3779 let beta = array![0.1, -0.25, 0.2];
3780 let eta = x.dot(&beta);
3781 let op = build_logit_firth_dense_operator(&x, &eta).expect("firth operator");
3782
3783 let analytic = op
3784 .exact_tau_tau_kernel(&x_tau_i, &x_tau_j, None, &beta, false)
3785 .phi_tau_tau_partial;
3786
3787 let h = 1e-5_f64;
3788 let eval_phi_tau_i = |x_eval: &Array2<f64>| -> f64 {
3789 let eta_e = x_eval.dot(&beta);
3790 let op_e = build_logit_firth_dense_operator(x_eval, &eta_e).expect("perturbed op");
3791 op_e.exact_tau_kernel(&x_tau_i, &beta, false)
3792 .phi_tau_partial
3793 };
3794 let x_plus = &x + &(h * &x_tau_j);
3795 let x_minus = &x - &(h * &x_tau_j);
3796 let fd = (eval_phi_tau_i(&x_plus) - eval_phi_tau_i(&x_minus)) / (2.0 * h);
3797
3798 let rel = (analytic - fd).abs() / fd.abs().max(1.0);
3799 assert!(
3800 rel < 1e-7,
3801 "pair.a scalar mismatch: analytic={analytic:.6e}, fd={fd:.6e}, rel={rel:.3e}"
3802 );
3803 }
3804
3805 #[test]
3810 pub(crate) fn firthphi_tau_tau_pair_g_vector_matches_finite_difference() {
3811 let x = array![
3812 [1.0, -1.0, 0.2],
3813 [1.0, -0.6, -0.3],
3814 [1.0, -0.1, 0.5],
3815 [1.0, 0.3, -0.7],
3816 [1.0, 0.8, 0.1],
3817 [1.0, 1.2, -0.4],
3818 ];
3819 let x_tau_i = array![
3820 [0.0, 0.15, -0.05],
3821 [0.0, -0.10, 0.02],
3822 [0.0, 0.08, 0.04],
3823 [0.0, -0.06, -0.03],
3824 [0.0, 0.05, 0.01],
3825 [0.0, -0.12, 0.06],
3826 ];
3827 let x_tau_j = array![
3828 [0.0, -0.04, 0.11],
3829 [0.0, 0.09, -0.02],
3830 [0.0, -0.06, 0.07],
3831 [0.0, 0.10, -0.05],
3832 [0.0, -0.03, 0.08],
3833 [0.0, 0.07, -0.09],
3834 ];
3835 let beta = array![0.1, -0.25, 0.2];
3836 let eta = x.dot(&beta);
3837 let op = build_logit_firth_dense_operator(&x, &eta).expect("firth operator");
3838
3839 let analytic = op
3840 .exact_tau_tau_kernel(&x_tau_i, &x_tau_j, None, &beta, false)
3841 .gphi_tau_tau;
3842
3843 let h = 1e-5_f64;
3844 let eval_gphi_tau_i = |x_eval: &Array2<f64>| -> Array1<f64> {
3845 let eta_e = x_eval.dot(&beta);
3846 let op_e = build_logit_firth_dense_operator(x_eval, &eta_e).expect("perturbed op");
3847 op_e.exact_tau_kernel(&x_tau_i, &beta, false).gphi_tau
3848 };
3849 let x_plus = &x + &(h * &x_tau_j);
3850 let x_minus = &x - &(h * &x_tau_j);
3851 let fd = (&eval_gphi_tau_i(&x_plus) - &eval_gphi_tau_i(&x_minus)) / (2.0 * h);
3852
3853 let scale = analytic
3854 .iter()
3855 .chain(fd.iter())
3856 .map(|v| v.abs())
3857 .fold(0.0_f64, f64::max)
3858 .max(1.0);
3859 let err_max = (&analytic - &fd)
3860 .iter()
3861 .map(|v| v.abs())
3862 .fold(0.0_f64, f64::max);
3863 let rel = err_max / scale;
3864 assert!(
3865 rel < 1e-7,
3866 "pair.g p-vector mismatch: rel={rel:.3e}\nanalytic={analytic:?}\nfd={fd:?}"
3867 );
3868 }
3869
3870 #[test]
3890 pub(crate) fn firthphi_tau_tau_partial_matches_finite_difference() {
3891 let x = array![
3892 [1.0, -1.0, 0.2],
3893 [1.0, -0.6, -0.3],
3894 [1.0, -0.1, 0.5],
3895 [1.0, 0.3, -0.7],
3896 [1.0, 0.8, 0.1],
3897 [1.0, 1.2, -0.4],
3898 ];
3899 let x_tau_i = array![
3900 [0.0, 0.15, -0.05],
3901 [0.0, -0.10, 0.02],
3902 [0.0, 0.08, 0.04],
3903 [0.0, -0.06, -0.03],
3904 [0.0, 0.05, 0.01],
3905 [0.0, -0.12, 0.06],
3906 ];
3907 let x_tau_j = array![
3908 [0.0, -0.04, 0.11],
3909 [0.0, 0.09, -0.02],
3910 [0.0, -0.06, 0.07],
3911 [0.0, 0.10, -0.05],
3912 [0.0, -0.03, 0.08],
3913 [0.0, 0.07, -0.09],
3914 ];
3915 let beta = array![0.1, -0.25, 0.2];
3916 let eta = x.dot(&beta);
3917 let op = build_logit_firth_dense_operator(&x, &eta).expect("firth operator");
3918 let p = x.ncols();
3919
3920 let m = 3usize;
3922 let mut rhs = Array2::<f64>::zeros((p, m));
3923 let vals = [0.21, -0.44, 0.17, 0.38, 0.05, -0.22, -0.11, 0.27, 0.31];
3924 for r in 0..p {
3925 for c in 0..m {
3926 rhs[[r, c]] = vals[(r * m + c) % vals.len()];
3927 }
3928 }
3929
3930 let x_tau_i_reduced = op.reduce_explicit_design(&x_tau_i);
3933 let x_tau_j_reduced = op.reduce_explicit_design(&x_tau_j);
3934 let deta_i = x_tau_i.dot(&beta);
3935 let deta_j = x_tau_j.dot(&beta);
3936 let (dot_i_i, dot_h_i) = op.dot_i_and_h_from_reduced(&x_tau_i_reduced, &deta_i);
3937 let (dot_i_j, dot_h_j) = op.dot_i_and_h_from_reduced(&x_tau_j_reduced, &deta_j);
3938
3939 let kernel_ij = op.hphi_tau_tau_partial_prepare_from_partials(
3940 x_tau_i_reduced.clone(),
3941 x_tau_j_reduced.clone(),
3942 &deta_i,
3943 &deta_j,
3944 dot_h_i.clone(),
3945 dot_h_j.clone(),
3946 dot_i_i.clone(),
3947 dot_i_j.clone(),
3948 None,
3949 None,
3950 );
3951 let kernel_ji = op.hphi_tau_tau_partial_prepare_from_partials(
3952 x_tau_j_reduced,
3953 x_tau_i_reduced,
3954 &deta_j,
3955 &deta_i,
3956 dot_h_j,
3957 dot_h_i,
3958 dot_i_j,
3959 dot_i_i,
3960 None,
3961 None,
3962 );
3963 let analytic_ij = op.hphi_tau_tau_partial_apply(&x_tau_i, &x_tau_j, &kernel_ij, &rhs);
3964 let analytic_ji = op.hphi_tau_tau_partial_apply(&x_tau_j, &x_tau_i, &kernel_ji, &rhs);
3965
3966 let sym_diff: f64 = (&analytic_ij - &analytic_ji)
3968 .iter()
3969 .map(|v| v.abs())
3970 .fold(0.0_f64, f64::max);
3971 let sym_scale: f64 = analytic_ij
3972 .iter()
3973 .chain(analytic_ji.iter())
3974 .map(|v| v.abs())
3975 .fold(0.0_f64, f64::max)
3976 .max(1.0);
3977 assert!(
3978 sym_diff / sym_scale < 1e-10,
3979 "τ×τ primitive not symmetric in direction order: sym_diff={sym_diff:.3e}"
3980 );
3981
3982 let h = 1e-5_f64;
3985 let fd_block = |x_eval: &Array2<f64>| -> Array2<f64> {
3986 let eta_e = x_eval.dot(&beta);
3987 let op_e =
3988 build_logit_firth_dense_operator(x_eval, &eta_e).expect("perturbed firth operator");
3989 let x_tau_i_r = op_e.reduce_explicit_design(&x_tau_i);
3990 let deta_i_e = x_tau_i.dot(&beta);
3991 let (dot_i_i_e, dot_h_i_e) = op_e.dot_i_and_h_from_reduced(&x_tau_i_r, &deta_i_e);
3992 let kernel_i_e = op_e
3993 .hphi_tau_partial_prepare_from_partials(x_tau_i_r, &deta_i_e, dot_h_i_e, dot_i_i_e);
3994 op_e.hphi_tau_partial_apply(&x_tau_i, &kernel_i_e, &rhs)
3995 };
3996 let x_plus = &x + &(h * &x_tau_j);
3997 let x_minus = &x - &(h * &x_tau_j);
3998 let fd_ij = (&fd_block(&x_plus) - &fd_block(&x_minus)) / (2.0 * h);
3999
4000 let rel_max_abs_diff = |a: &Array2<f64>, b: &Array2<f64>| -> f64 {
4003 let scale = a
4004 .iter()
4005 .chain(b.iter())
4006 .map(|v| v.abs())
4007 .fold(0.0_f64, f64::max)
4008 .max(1.0);
4009 let max_diff = (a - b).iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
4010 max_diff / scale
4011 };
4012 let err_ij = rel_max_abs_diff(&analytic_ij, &fd_ij);
4013
4014 let fd_block_j = |x_eval: &Array2<f64>| -> Array2<f64> {
4017 let eta_e = x_eval.dot(&beta);
4018 let op_e =
4019 build_logit_firth_dense_operator(x_eval, &eta_e).expect("perturbed firth operator");
4020 let x_tau_j_r = op_e.reduce_explicit_design(&x_tau_j);
4021 let deta_j_e = x_tau_j.dot(&beta);
4022 let (dot_i_j_e, dot_h_j_e) = op_e.dot_i_and_h_from_reduced(&x_tau_j_r, &deta_j_e);
4023 let kernel_j_e = op_e
4024 .hphi_tau_partial_prepare_from_partials(x_tau_j_r, &deta_j_e, dot_h_j_e, dot_i_j_e);
4025 op_e.hphi_tau_partial_apply(&x_tau_j, &kernel_j_e, &rhs)
4026 };
4027 let x_plus_i = &x + &(h * &x_tau_i);
4028 let x_minus_i = &x - &(h * &x_tau_i);
4029 let fd_ji = (&fd_block_j(&x_plus_i) - &fd_block_j(&x_minus_i)) / (2.0 * h);
4030 let err_ji = rel_max_abs_diff(&analytic_ji, &fd_ji);
4031
4032 let tol = 1e-7_f64;
4033 assert!(
4034 err_ij < tol,
4035 "∂²H_φ/∂τ_i∂τ_j apply mismatch (i,j): rel_max_abs_diff={err_ij:.3e} > {tol:.1e}\n\
4036 analytic=\n{analytic_ij:?}\n\
4037 fd=\n{fd_ij:?}"
4038 );
4039 assert!(
4040 err_ji < tol,
4041 "∂²H_φ/∂τ_j∂τ_i apply mismatch (j,i): rel_max_abs_diff={err_ji:.3e} > {tol:.1e}\n\
4042 analytic=\n{analytic_ji:?}\n\
4043 fd=\n{fd_ji:?}"
4044 );
4045 }
4046
4047 #[test]
4065 pub(crate) fn firth_d_beta_hphi_tau_partial_matches_finite_difference() {
4066 let x = array![
4067 [1.0, -1.0, 0.2],
4068 [1.0, -0.6, -0.3],
4069 [1.0, -0.1, 0.5],
4070 [1.0, 0.3, -0.7],
4071 [1.0, 0.8, 0.1],
4072 [1.0, 1.2, -0.4],
4073 ];
4074 let x_tau = array![
4075 [0.0, 0.15, -0.05],
4076 [0.0, -0.10, 0.02],
4077 [0.0, 0.08, 0.04],
4078 [0.0, -0.06, -0.03],
4079 [0.0, 0.05, 0.01],
4080 [0.0, -0.12, 0.06],
4081 ];
4082 let beta = array![0.1, -0.25, 0.2];
4083 let v = array![0.3, 0.2, -0.15];
4085
4086 let eta = x.dot(&beta);
4087 let op = build_logit_firth_dense_operator(&x, &eta).expect("firth operator");
4088 let p = x.ncols();
4089
4090 let m = 3usize;
4092 let mut rhs = Array2::<f64>::zeros((p, m));
4093 let vals = [0.21, -0.44, 0.17, 0.38, 0.05, -0.22, -0.11, 0.27, 0.31];
4094 for r in 0..p {
4095 for c in 0..m {
4096 rhs[[r, c]] = vals[(r * m + c) % vals.len()];
4097 }
4098 }
4099
4100 let x_tau_reduced = op.reduce_explicit_design(&x_tau);
4102 let deta_partial = x_tau.dot(&beta);
4103 let (dot_i_partial, dot_h_partial) =
4104 op.dot_i_and_h_from_reduced(&x_tau_reduced, &deta_partial);
4105 let tau_kernel = op.hphi_tau_partial_prepare_from_partials(
4106 x_tau_reduced.clone(),
4107 &deta_partial,
4108 dot_h_partial.clone(),
4109 dot_i_partial.clone(),
4110 );
4111
4112 let deta_v = x.dot(&v);
4113 let direction = op.direction_from_deta(deta_v);
4114 let x_tau_v = x_tau.dot(&v);
4115 let pair_kernel = op.d_beta_hphi_tau_partial_prepare_from_partials(
4116 &tau_kernel,
4117 &deta_partial,
4118 &dot_i_partial,
4119 &direction,
4120 &x_tau_v,
4121 );
4122 let analytic = op.d_beta_hphi_tau_partial_apply(&x_tau, &pair_kernel, &rhs);
4123
4124 let h = 1e-5_f64;
4127 let single_tau_apply = |beta_eval: &Array1<f64>| -> Array2<f64> {
4128 let eta_e = x.dot(beta_eval);
4129 let op_e =
4130 build_logit_firth_dense_operator(&x, &eta_e).expect("perturbed firth operator");
4131 let x_tau_r = op_e.reduce_explicit_design(&x_tau);
4132 let deta_e = x_tau.dot(beta_eval);
4133 let (dot_i_e, dot_h_e) = op_e.dot_i_and_h_from_reduced(&x_tau_r, &deta_e);
4134 let ker_e =
4135 op_e.hphi_tau_partial_prepare_from_partials(x_tau_r, &deta_e, dot_h_e, dot_i_e);
4136 op_e.hphi_tau_partial_apply(&x_tau, &ker_e, &rhs)
4137 };
4138 let beta_plus = &beta + &(h * &v);
4139 let beta_minus = &beta - &(h * &v);
4140 let fd = (&single_tau_apply(&beta_plus) - &single_tau_apply(&beta_minus)) / (2.0 * h);
4141
4142 let rel_max_abs_diff = |a: &Array2<f64>, b: &Array2<f64>| -> f64 {
4143 let scale = a
4144 .iter()
4145 .chain(b.iter())
4146 .map(|v| v.abs())
4147 .fold(0.0_f64, f64::max)
4148 .max(1.0);
4149 let max_diff = (a - b).iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
4150 max_diff / scale
4151 };
4152 let err = rel_max_abs_diff(&analytic, &fd);
4153
4154 let tol = 1e-7_f64;
4155 assert!(
4156 err < tol,
4157 "D_β (H_φ)_τ|_β apply mismatch: rel_max_abs_diff={err:.3e} > {tol:.1e}\n\
4158 analytic=\n{analytic:?}\n\
4159 fd=\n{fd:?}"
4160 );
4161 }
4162
4163 #[test]
4164 pub(crate) fn logisticweight_loses_positive_tail_mass() {
4165 let eta = 50.0_f64;
4166 let z = (-eta).exp();
4167 let stable = z / (1.0_f64 + z).powi(2);
4168 assert!(stable > 0.0);
4169 let got = logisticweight(eta);
4170 assert!(
4171 (got - stable).abs() < 1e-30,
4172 "Firth logisticweight should equal the stable tail formula z/(1+z)^2 at eta={eta}; got {} vs {}",
4173 got,
4174 stable
4175 );
4176 }
4177
4178 #[test]
4179 pub(crate) fn fisher_weight_jet5_logit_is_byte_identical_to_inverse_link_jet() {
4180 for &eta in &[
4184 -40.0, -8.0, -3.0, -1.0, -0.25, 0.0, 0.25, 1.0, 3.0, 8.0, 40.0,
4185 ] {
4186 let jet = logit_inverse_link_jet5(eta);
4187 let (w, w1, w2, w3, w4) =
4188 crate::mixture_link::fisher_weight_jet5(StandardLink::Logit, eta);
4189 assert!(
4190 w == jet.d1 && w1 == jet.d2 && w2 == jet.d3 && w3 == jet.d4 && w4 == jet.d5,
4191 "logit Fisher-weight jet must equal inverse-link jet derivatives at eta={eta}: \
4192 got ({w}, {w1}, {w2}, {w3}, {w4}) vs ({}, {}, {}, {}, {})",
4193 jet.d1,
4194 jet.d2,
4195 jet.d3,
4196 jet.d4,
4197 jet.d5
4198 );
4199 }
4200 }
4201
4202 #[test]
4203 pub(crate) fn fisher_weight_jet5_probit_matches_finite_difference() {
4204 fn reference_probit_weight(eta: f64) -> f64 {
4208 let p = gam_math::probability::normal_cdf(eta);
4209 let q = 1.0 - p;
4210 let phi = gam_math::probability::normal_pdf(eta);
4211 if p <= 0.0 || q <= 0.0 {
4212 return 0.0;
4213 }
4214 phi * phi / (p * q)
4215 }
4216 let h = 1e-4_f64;
4217 for &eta in &[-3.0, -1.5, -0.5, 0.0, 0.3, 1.5, 3.0] {
4218 let (w, w1, w2, _w3, _w4) =
4219 crate::mixture_link::fisher_weight_jet5(StandardLink::Probit, eta);
4220 let ref_w = reference_probit_weight(eta);
4221 let fd1 =
4222 (reference_probit_weight(eta + h) - reference_probit_weight(eta - h)) / (2.0 * h);
4223 let fd2 = (reference_probit_weight(eta + h) - 2.0 * reference_probit_weight(eta)
4224 + reference_probit_weight(eta - h))
4225 / (h * h);
4226 assert!(
4227 (w - ref_w).abs() < 1e-10,
4228 "probit W mismatch at eta={eta}: jet {w} vs ref {ref_w}"
4229 );
4230 assert!(
4231 (w1 - fd1).abs() < 1e-5,
4232 "probit W' mismatch at eta={eta}: jet {w1} vs fd {fd1}"
4233 );
4234 assert!(
4235 (w2 - fd2).abs() < 1e-3,
4236 "probit W'' mismatch at eta={eta}: jet {w2} vs fd {fd2}"
4237 );
4238 }
4239 }
4240
4241 #[test]
4242 pub(crate) fn fisher_weight_jet5_probit_saturates_to_zero_in_tails() {
4243 for &eta in &[40.0_f64, -40.0, 80.0, -80.0] {
4247 let (w, w1, w2, w3, w4) =
4248 crate::mixture_link::fisher_weight_jet5(StandardLink::Probit, eta);
4249 assert!(
4250 w == 0.0 && w1 == 0.0 && w2 == 0.0 && w3 == 0.0 && w4 == 0.0,
4251 "probit Fisher weight jet must saturate to zero at eta={eta}; got \
4252 ({w}, {w1}, {w2}, {w3}, {w4})"
4253 );
4254 }
4255 for &eta in &[12.0_f64, -12.0] {
4260 let (w, w1, w2, w3, w4) =
4261 crate::mixture_link::fisher_weight_jet5(StandardLink::Probit, eta);
4262 assert!(
4263 w > 0.0
4264 && w.is_finite()
4265 && w1.is_finite()
4266 && w2.is_finite()
4267 && w3.is_finite()
4268 && w4.is_finite(),
4269 "probit Fisher weight jet must be tiny-positive and finite at eta={eta}; got \
4270 ({w}, {w1}, {w2}, {w3}, {w4})"
4271 );
4272 }
4273 }
4274}