1use ndarray::{Array2, ArrayView1};
42
43#[derive(Debug, Clone)]
53pub struct JointPenaltySpec {
54 pub label: Option<String>,
58 pub matrix: Array2<f64>,
60 pub initial_log_lambda: f64,
62 pub nullspace_dim: usize,
64}
65
66#[derive(Debug, Clone, PartialEq)]
68pub enum JointPenaltyError {
69 NotSquare {
70 nrows: usize,
71 ncols: usize,
72 },
73 NonFiniteEntry {
74 row: usize,
75 col: usize,
76 value: f64,
77 },
78 InitialLogStrengthOutOfDomain {
79 value: f64,
80 },
81 NotSymmetric {
82 row: usize,
83 col: usize,
84 asymmetry: f64,
85 },
86 NullspaceTooLarge {
87 total: usize,
88 nullspace_dim: usize,
89 },
90 NotPositiveSemidefinite {
91 min_eigenvalue: f64,
92 max_abs_eigenvalue: f64,
93 },
94 NullspaceMismatch {
95 declared: usize,
96 numerical: usize,
97 },
98 EigendecompositionFailed {
99 reason: String,
100 },
101}
102
103impl std::fmt::Display for JointPenaltyError {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 match self {
106 Self::NotSquare { nrows, ncols } => {
107 write!(f, "joint penalty matrix is not square: {nrows}x{ncols}")
108 }
109 Self::NonFiniteEntry { row, col, value } => write!(
110 f,
111 "joint penalty matrix has non-finite entry at ({row},{col}): {value}"
112 ),
113 Self::InitialLogStrengthOutOfDomain { value } => {
114 write!(
115 f,
116 "joint penalty initial_log_lambda is outside the exact strength domain: {value}"
117 )
118 }
119 Self::NotSymmetric {
120 row,
121 col,
122 asymmetry,
123 } => write!(
124 f,
125 "joint penalty matrix is not symmetric at ({row},{col}): |S - Sᵀ|={asymmetry:.3e}"
126 ),
127 Self::NullspaceTooLarge {
128 total,
129 nullspace_dim,
130 } => write!(
131 f,
132 "joint penalty nullspace_dim={nullspace_dim} exceeds dim={total}"
133 ),
134 Self::NotPositiveSemidefinite {
135 min_eigenvalue,
136 max_abs_eigenvalue,
137 } => write!(
138 f,
139 "joint penalty matrix is not positive semidefinite: min eigenvalue \
140 {min_eigenvalue:.6e} (max |eigenvalue| {max_abs_eigenvalue:.6e}); the \
141 penalized objective is unbounded below along the negative mode"
142 ),
143 Self::NullspaceMismatch {
144 declared,
145 numerical,
146 } => write!(
147 f,
148 "joint penalty declares nullspace_dim={declared} but the eigenspectrum has \
149 {numerical} numerical-zero direction(s); the REML pseudo-logdet rank would \
150 be wrong"
151 ),
152 Self::EigendecompositionFailed { reason } => write!(
153 f,
154 "joint penalty eigendecomposition failed during validation: {reason}"
155 ),
156 }
157 }
158}
159
160impl std::error::Error for JointPenaltyError {}
161
162impl JointPenaltySpec {
163 const SYMMETRY_TOL: f64 = 1e-10;
167
168 #[inline]
170 pub fn dim(&self) -> usize {
171 self.matrix.nrows()
172 }
173
174 pub fn trace(&self) -> f64 {
176 self.matrix.diag().iter().copied().sum()
177 }
178
179 #[inline]
183 pub fn pseudo_rank(&self) -> usize {
184 self.dim().saturating_sub(self.nullspace_dim)
185 }
186
187 pub fn quadratic_form(&self, beta: ArrayView1<'_, f64>) -> f64 {
191 assert_eq!(
192 beta.len(),
193 self.dim(),
194 "joint penalty quadratic form: beta length {} != dim {}",
195 beta.len(),
196 self.dim()
197 );
198 beta.dot(&self.matrix.dot(&beta))
199 }
200
201 pub fn validate(&self) -> Result<(), JointPenaltyError> {
203 let (nrows, ncols) = self.matrix.dim();
204 if nrows != ncols {
205 return Err(JointPenaltyError::NotSquare { nrows, ncols });
206 }
207 if crate::validate_log_strength(self.initial_log_lambda).is_err() {
208 return Err(JointPenaltyError::InitialLogStrengthOutOfDomain {
209 value: self.initial_log_lambda,
210 });
211 }
212 if self.nullspace_dim > nrows {
213 return Err(JointPenaltyError::NullspaceTooLarge {
214 total: nrows,
215 nullspace_dim: self.nullspace_dim,
216 });
217 }
218 for ((row, col), &value) in self.matrix.indexed_iter() {
219 if !value.is_finite() {
220 return Err(JointPenaltyError::NonFiniteEntry { row, col, value });
221 }
222 }
223 for row in 0..nrows {
224 for col in (row + 1)..ncols {
225 let asymmetry = (self.matrix[[row, col]] - self.matrix[[col, row]]).abs();
226 if asymmetry > Self::SYMMETRY_TOL {
227 return Err(JointPenaltyError::NotSymmetric {
228 row,
229 col,
230 asymmetry,
231 });
232 }
233 }
234 }
235 if nrows > 0 {
242 use gam_linalg::faer_ndarray::FaerEigh;
243 let (eigenvalues, _) =
244 FaerEigh::eigh(&self.matrix, faer::Side::Lower).map_err(|e| {
245 JointPenaltyError::EigendecompositionFailed {
246 reason: e.to_string(),
247 }
248 })?;
249 let max_abs_eigenvalue = eigenvalues
250 .iter()
251 .fold(0.0_f64, |acc, &ev| acc.max(ev.abs()));
252 let tol = 100.0 * (nrows as f64) * f64::EPSILON * max_abs_eigenvalue;
255 if let Some(&min_eigenvalue) = eigenvalues
256 .iter()
257 .filter(|&&ev| ev < -tol)
258 .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
259 {
260 return Err(JointPenaltyError::NotPositiveSemidefinite {
261 min_eigenvalue,
262 max_abs_eigenvalue,
263 });
264 }
265 let numerical = eigenvalues.iter().filter(|&&ev| ev <= tol).count();
266 if numerical != self.nullspace_dim {
267 return Err(JointPenaltyError::NullspaceMismatch {
268 declared: self.nullspace_dim,
269 numerical,
270 });
271 }
272 }
273 Ok(())
274 }
275}
276
277#[derive(Clone, Debug)]
286pub struct JointPenaltyBundle {
287 specs: std::sync::Arc<Vec<JointPenaltySpec>>,
288 log_lambdas: Vec<f64>,
289 lambdas: Vec<f64>,
290}
291
292impl JointPenaltyBundle {
293 pub fn new(
296 specs: std::sync::Arc<Vec<JointPenaltySpec>>,
297 log_lambdas: Vec<f64>,
298 total_compiled: usize,
299 ) -> Result<Self, String> {
300 if specs.len() != log_lambdas.len() {
301 return Err(format!(
302 "joint penalty bundle: {} specs vs {} log_lambdas",
303 specs.len(),
304 log_lambdas.len(),
305 ));
306 }
307 let mut lambdas = Vec::with_capacity(log_lambdas.len());
308 for (i, (spec, &log_lambda)) in specs.iter().zip(log_lambdas.iter()).enumerate() {
309 if spec.dim() != total_compiled {
310 return Err(format!(
311 "joint penalty {i}: dim {} != total_compiled {}",
312 spec.dim(),
313 total_compiled,
314 ));
315 }
316 lambdas.push(
317 crate::checked_exp_log_strength(log_lambda)
318 .map_err(|error| format!("joint penalty {i} current log-precision: {error}"))?,
319 );
320 }
321 Ok(Self {
322 specs,
323 log_lambdas,
324 lambdas,
325 })
326 }
327
328 #[inline]
329 pub fn len(&self) -> usize {
330 self.specs.len()
331 }
332
333 #[inline]
334 pub fn is_empty(&self) -> bool {
335 self.specs.is_empty()
336 }
337
338 #[inline]
339 pub fn specs(&self) -> &[JointPenaltySpec] {
340 self.specs.as_slice()
341 }
342
343 #[inline]
344 pub fn log_lambdas(&self) -> &[f64] {
345 self.log_lambdas.as_slice()
346 }
347
348 #[inline]
349 pub fn lambdas(&self) -> &[f64] {
350 self.lambdas.as_slice()
351 }
352
353 pub fn quadratic(&self, beta: ArrayView1<'_, f64>) -> f64 {
356 let mut total = 0.0;
357 for (spec, &lam) in self.specs.iter().zip(self.lambdas.iter()) {
358 total += 0.5 * lam * spec.quadratic_form(beta);
359 }
360 total
361 }
362
363 pub fn add_apply_into(&self, vector: ArrayView1<'_, f64>, out: &mut ndarray::Array1<f64>) {
365 assert_eq!(out.len(), vector.len());
366 for (spec, &lam) in self.specs.iter().zip(self.lambdas.iter()) {
367 let sv = spec.matrix.dot(&vector);
368 out.scaled_add(lam, &sv);
369 }
370 }
371
372 pub fn add_diag(&self, diag: &mut ndarray::Array1<f64>) {
374 for (spec, &lam) in self.specs.iter().zip(self.lambdas.iter()) {
375 for (i, value) in spec.matrix.diag().iter().enumerate() {
376 diag[i] += lam * *value;
377 }
378 }
379 }
380
381 pub fn add_to_matrix(&self, matrix: &mut Array2<f64>) {
383 assert_eq!(matrix.nrows(), matrix.ncols());
384 for (spec, &lam) in self.specs.iter().zip(self.lambdas.iter()) {
385 matrix.scaled_add(lam, &spec.matrix);
386 }
387 }
388
389 pub fn rho_objective_gradient(&self, beta: ArrayView1<'_, f64>, out: &mut [f64]) {
392 assert_eq!(out.len(), self.specs.len());
393 for (i, (spec, &lam)) in self.specs.iter().zip(self.lambdas.iter()).enumerate() {
394 out[i] = 0.5 * lam * spec.quadratic_form(beta);
395 }
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use ndarray::{Array1, Array2, array};
403
404 fn cross_block_spec() -> JointPenaltySpec {
408 let v: Array1<f64> = array![1.0, 0.0, -1.0, 0.0];
410 let w: Array1<f64> = array![0.0, 1.0, 0.0, -1.0];
411 let mut matrix: Array2<f64> = Array2::zeros((4, 4));
412 for i in 0..4 {
413 for j in 0..4 {
414 matrix[[i, j]] = v[i] * v[j] + w[i] * w[j];
415 }
416 }
417 JointPenaltySpec {
418 label: Some("cross_block_pullback".to_string()),
419 matrix,
420 initial_log_lambda: -1.5,
421 nullspace_dim: 2,
422 }
423 }
424
425 #[test]
426 fn cross_block_dense_validates() {
427 let result = cross_block_spec().validate();
428 assert!(
429 result.is_ok(),
430 "valid cross-block spec rejected: {result:?}"
431 );
432 }
433
434 #[test]
435 fn trace_matches_diagonal_sum() {
436 let spec = cross_block_spec();
437 assert!((spec.trace() - 4.0).abs() < 1e-12);
439 }
440
441 #[test]
442 fn pseudo_rank_uses_declared_nullspace() {
443 let spec = cross_block_spec();
444 assert_eq!(spec.dim(), 4);
445 assert_eq!(spec.pseudo_rank(), 2);
446 }
447
448 #[test]
449 fn quadratic_form_matches_explicit_mat_vec() {
450 let spec = cross_block_spec();
451 let beta: Array1<f64> = array![0.5, -0.25, 1.0, 0.75];
453 let q = spec.quadratic_form(beta.view());
456 assert!((q - 1.25).abs() < 1e-12, "got {q}");
457 }
458
459 #[test]
460 fn determinant_zero_for_rank_deficient_matches_nullspace() {
461 use gam_linalg::faer_ndarray::FaerEigh;
462 let spec = cross_block_spec();
463 let (eigvals, _) =
466 FaerEigh::eigh(&spec.matrix, faer::Side::Lower).expect("symmetric eigh succeeds");
467 let mut sorted: Vec<f64> = eigvals.iter().copied().collect();
468 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
469 let zeros = sorted.iter().take_while(|&&v| v.abs() < 1e-10).count();
470 assert_eq!(
471 zeros, spec.nullspace_dim,
472 "spectrum {sorted:?} should have {} near-zeros",
473 spec.nullspace_dim
474 );
475 let det: f64 = sorted.iter().product();
478 assert!(det.abs() < 1e-10, "expected ~0 determinant, got {det}");
479 }
480
481 #[test]
482 fn validate_rejects_non_square() {
483 let spec = JointPenaltySpec {
484 label: None,
485 matrix: Array2::zeros((3, 4)),
486 initial_log_lambda: 0.0,
487 nullspace_dim: 0,
488 };
489 assert!(matches!(
490 spec.validate(),
491 Err(JointPenaltyError::NotSquare { nrows: 3, ncols: 4 })
492 ));
493 }
494
495 #[test]
496 fn validate_rejects_non_symmetric() {
497 let mut matrix = Array2::<f64>::zeros((3, 3));
498 matrix[[0, 1]] = 1.0;
499 matrix[[1, 0]] = -1.0;
500 let spec = JointPenaltySpec {
501 label: None,
502 matrix,
503 initial_log_lambda: 0.0,
504 nullspace_dim: 0,
505 };
506 assert!(matches!(
507 spec.validate(),
508 Err(JointPenaltyError::NotSymmetric { .. })
509 ));
510 }
511
512 #[test]
513 fn validate_rejects_oversized_nullspace() {
514 let spec = JointPenaltySpec {
515 label: None,
516 matrix: Array2::zeros((3, 3)),
517 initial_log_lambda: 0.0,
518 nullspace_dim: 4,
519 };
520 assert!(matches!(
521 spec.validate(),
522 Err(JointPenaltyError::NullspaceTooLarge {
523 total: 3,
524 nullspace_dim: 4
525 })
526 ));
527 }
528
529 #[test]
530 fn validate_rejects_initial_log_strength_outside_exact_domain() {
531 let spec = JointPenaltySpec {
532 label: None,
533 matrix: Array2::zeros((2, 2)),
534 initial_log_lambda: f64::NAN,
535 nullspace_dim: 0,
536 };
537 assert!(matches!(
538 spec.validate(),
539 Err(JointPenaltyError::InitialLogStrengthOutOfDomain { .. })
540 ));
541
542 let mut finite_but_too_large = cross_block_spec();
543 finite_but_too_large.initial_log_lambda = crate::LOG_STRENGTH_MAX + 1.0;
544 assert!(matches!(
545 finite_but_too_large.validate(),
546 Err(JointPenaltyError::InitialLogStrengthOutOfDomain { .. })
547 ));
548 }
549
550 #[test]
551 fn bundle_construction_is_atomic_at_exact_log_strength_faces() {
552 let specs = std::sync::Arc::new(vec![cross_block_spec(), cross_block_spec()]);
553 let bundle = JointPenaltyBundle::new(
554 specs.clone(),
555 vec![crate::LOG_STRENGTH_MIN, crate::LOG_STRENGTH_MAX],
556 4,
557 )
558 .expect("closed endpoints");
559 for ((&actual, &log_strength), expected) in bundle
560 .lambdas()
561 .iter()
562 .zip(bundle.log_lambdas())
563 .zip([crate::LOG_STRENGTH_MIN.exp(), crate::LOG_STRENGTH_MAX.exp()])
564 {
565 assert_eq!(actual.to_bits(), expected.to_bits());
566 assert_eq!(actual.to_bits(), log_strength.exp().to_bits());
567 }
568
569 let error = JointPenaltyBundle::new(specs, vec![0.0, crate::LOG_STRENGTH_MAX + 1.0], 4)
570 .expect_err("one invalid coordinate refuses the whole bundle");
571 assert!(error.contains("joint penalty 1 current log-precision"));
572 }
573
574 #[test]
588 fn bundle_two_block_minimiser_matches_analytic_solution() {
589 use gam_linalg::faer_ndarray::FaerCholesky;
590 use ndarray::Array2;
591
592 let spec = JointPenaltySpec {
593 label: Some("toy_cross_block".to_string()),
594 matrix: array![[2.0_f64, 1.0], [1.0, 2.0]],
595 initial_log_lambda: 0.0,
596 nullspace_dim: 0,
597 };
598 let log_lambda = -0.4_f64;
599 let lam = log_lambda.exp();
600 let bundle = JointPenaltyBundle::new(std::sync::Arc::new(vec![spec]), vec![log_lambda], 2)
601 .expect("valid bundle");
602
603 let mut lhs = Array2::<f64>::eye(2);
606 bundle.add_to_matrix(&mut lhs);
607 let expected_lhs = array![[1.0 + lam * 2.0, lam], [lam, 1.0 + lam * 2.0]];
609 for r in 0..2 {
610 for c in 0..2 {
611 assert!(
612 (lhs[[r, c]] - expected_lhs[[r, c]]).abs() < 1e-12,
613 "lhs[{r}, {c}] = {} expected {}",
614 lhs[[r, c]],
615 expected_lhs[[r, c]]
616 );
617 }
618 }
619
620 let b: Array1<f64> = array![1.0, -0.5];
622 let chol = lhs.cholesky(faer::Side::Lower).expect("SPD");
623 let mut rhs_mat = Array2::<f64>::zeros((2, 1));
624 rhs_mat[[0, 0]] = b[0];
625 rhs_mat[[1, 0]] = b[1];
626 let mut beta_mat = rhs_mat.clone();
627 chol.solve_mat_in_place(&mut beta_mat);
628 let beta_hat: Array1<f64> = array![beta_mat[[0, 0]], beta_mat[[1, 0]]];
629
630 let mut grad = &beta_hat - &b;
632 bundle.add_apply_into(beta_hat.view(), &mut grad);
633 let grad_inf = grad.iter().map(|v: &f64| v.abs()).fold(0.0_f64, f64::max);
634 assert!(
635 grad_inf < 1e-12,
636 "penalised gradient at analytic minimiser must vanish: {grad_inf:.3e}"
637 );
638
639 let resid = &beta_hat - &b;
642 let unpen = 0.5 * resid.dot(&resid);
643 let pen = bundle.quadratic(beta_hat.view());
644 let expected_obj = 0.5 * resid.dot(&resid)
645 + 0.5 * lam * beta_hat.dot(&array![[2.0, 1.0], [1.0, 2.0]].dot(&beta_hat));
646 assert!(
647 (unpen + pen - expected_obj).abs() < 1e-12,
648 "objective sum {} mismatched expected {}",
649 unpen + pen,
650 expected_obj
651 );
652
653 let mut diag = ndarray::Array1::<f64>::from_elem(2, 1.0);
655 bundle.add_diag(&mut diag);
656 assert!((diag[0] - (1.0 + lam * 2.0)).abs() < 1e-12);
657 assert!((diag[1] - (1.0 + lam * 2.0)).abs() < 1e-12);
658
659 let mut rho_grad = vec![0.0_f64];
661 bundle.rho_objective_gradient(beta_hat.view(), &mut rho_grad);
662 let expected_rho_grad =
663 0.5 * lam * beta_hat.dot(&array![[2.0, 1.0], [1.0, 2.0]].dot(&beta_hat));
664 assert!(
665 (rho_grad[0] - expected_rho_grad).abs() < 1e-12,
666 "rho-grad {} expected {}",
667 rho_grad[0],
668 expected_rho_grad
669 );
670 }
671
672 #[test]
673 fn bundle_rejects_dim_mismatch() {
674 let spec = JointPenaltySpec {
675 label: None,
676 matrix: Array2::<f64>::eye(3),
677 initial_log_lambda: 0.0,
678 nullspace_dim: 0,
679 };
680 let err = JointPenaltyBundle::new(std::sync::Arc::new(vec![spec]), vec![0.0], 4)
681 .expect_err("dim mismatch must reject");
682 assert!(err.contains("total_compiled"));
683 }
684
685 #[test]
686 fn bundle_rejects_lambda_count_mismatch() {
687 let spec = JointPenaltySpec {
688 label: None,
689 matrix: Array2::<f64>::eye(2),
690 initial_log_lambda: 0.0,
691 nullspace_dim: 0,
692 };
693 let err = JointPenaltyBundle::new(std::sync::Arc::new(vec![spec]), vec![], 2)
694 .expect_err("count mismatch must reject");
695 assert!(err.contains("specs vs"));
696 }
697}