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 NonFiniteInitialLogLambda {
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::NonFiniteInitialLogLambda { value } => {
114 write!(f, "joint penalty initial_log_lambda is non-finite: {value}")
115 }
116 Self::NotSymmetric {
117 row,
118 col,
119 asymmetry,
120 } => write!(
121 f,
122 "joint penalty matrix is not symmetric at ({row},{col}): |S - Sᵀ|={asymmetry:.3e}"
123 ),
124 Self::NullspaceTooLarge {
125 total,
126 nullspace_dim,
127 } => write!(
128 f,
129 "joint penalty nullspace_dim={nullspace_dim} exceeds dim={total}"
130 ),
131 Self::NotPositiveSemidefinite {
132 min_eigenvalue,
133 max_abs_eigenvalue,
134 } => write!(
135 f,
136 "joint penalty matrix is not positive semidefinite: min eigenvalue \
137 {min_eigenvalue:.6e} (max |eigenvalue| {max_abs_eigenvalue:.6e}); the \
138 penalized objective is unbounded below along the negative mode"
139 ),
140 Self::NullspaceMismatch {
141 declared,
142 numerical,
143 } => write!(
144 f,
145 "joint penalty declares nullspace_dim={declared} but the eigenspectrum has \
146 {numerical} numerical-zero direction(s); the REML pseudo-logdet rank would \
147 be wrong"
148 ),
149 Self::EigendecompositionFailed { reason } => write!(
150 f,
151 "joint penalty eigendecomposition failed during validation: {reason}"
152 ),
153 }
154 }
155}
156
157impl std::error::Error for JointPenaltyError {}
158
159impl JointPenaltySpec {
160 const SYMMETRY_TOL: f64 = 1e-10;
164
165 #[inline]
167 pub fn dim(&self) -> usize {
168 self.matrix.nrows()
169 }
170
171 pub fn trace(&self) -> f64 {
173 self.matrix.diag().iter().copied().sum()
174 }
175
176 #[inline]
180 pub fn pseudo_rank(&self) -> usize {
181 self.dim().saturating_sub(self.nullspace_dim)
182 }
183
184 pub fn quadratic_form(&self, beta: ArrayView1<'_, f64>) -> f64 {
188 assert_eq!(
189 beta.len(),
190 self.dim(),
191 "joint penalty quadratic form: beta length {} != dim {}",
192 beta.len(),
193 self.dim()
194 );
195 beta.dot(&self.matrix.dot(&beta))
196 }
197
198 pub fn validate(&self) -> Result<(), JointPenaltyError> {
200 let (nrows, ncols) = self.matrix.dim();
201 if nrows != ncols {
202 return Err(JointPenaltyError::NotSquare { nrows, ncols });
203 }
204 if !self.initial_log_lambda.is_finite() {
205 return Err(JointPenaltyError::NonFiniteInitialLogLambda {
206 value: self.initial_log_lambda,
207 });
208 }
209 if self.nullspace_dim > nrows {
210 return Err(JointPenaltyError::NullspaceTooLarge {
211 total: nrows,
212 nullspace_dim: self.nullspace_dim,
213 });
214 }
215 for ((row, col), &value) in self.matrix.indexed_iter() {
216 if !value.is_finite() {
217 return Err(JointPenaltyError::NonFiniteEntry { row, col, value });
218 }
219 }
220 for row in 0..nrows {
221 for col in (row + 1)..ncols {
222 let asymmetry = (self.matrix[[row, col]] - self.matrix[[col, row]]).abs();
223 if asymmetry > Self::SYMMETRY_TOL {
224 return Err(JointPenaltyError::NotSymmetric {
225 row,
226 col,
227 asymmetry,
228 });
229 }
230 }
231 }
232 if nrows > 0 {
239 use gam_linalg::faer_ndarray::FaerEigh;
240 let (eigenvalues, _) =
241 FaerEigh::eigh(&self.matrix, faer::Side::Lower).map_err(|e| {
242 JointPenaltyError::EigendecompositionFailed {
243 reason: e.to_string(),
244 }
245 })?;
246 let max_abs_eigenvalue = eigenvalues
247 .iter()
248 .fold(0.0_f64, |acc, &ev| acc.max(ev.abs()));
249 let tol = 100.0 * (nrows as f64) * f64::EPSILON * max_abs_eigenvalue;
252 if let Some(&min_eigenvalue) = eigenvalues
253 .iter()
254 .filter(|&&ev| ev < -tol)
255 .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
256 {
257 return Err(JointPenaltyError::NotPositiveSemidefinite {
258 min_eigenvalue,
259 max_abs_eigenvalue,
260 });
261 }
262 let numerical = eigenvalues.iter().filter(|&&ev| ev <= tol).count();
263 if numerical != self.nullspace_dim {
264 return Err(JointPenaltyError::NullspaceMismatch {
265 declared: self.nullspace_dim,
266 numerical,
267 });
268 }
269 }
270 Ok(())
271 }
272}
273
274#[derive(Clone, Debug)]
283pub struct JointPenaltyBundle {
284 pub specs: std::sync::Arc<Vec<JointPenaltySpec>>,
285 pub log_lambdas: Vec<f64>,
286}
287
288impl JointPenaltyBundle {
289 pub fn new(
292 specs: std::sync::Arc<Vec<JointPenaltySpec>>,
293 log_lambdas: Vec<f64>,
294 total_compiled: usize,
295 ) -> Result<Self, String> {
296 if specs.len() != log_lambdas.len() {
297 return Err(format!(
298 "joint penalty bundle: {} specs vs {} log_lambdas",
299 specs.len(),
300 log_lambdas.len(),
301 ));
302 }
303 for (i, spec) in specs.iter().enumerate() {
304 if spec.dim() != total_compiled {
305 return Err(format!(
306 "joint penalty {i}: dim {} != total_compiled {}",
307 spec.dim(),
308 total_compiled,
309 ));
310 }
311 }
312 Ok(Self { specs, log_lambdas })
313 }
314
315 #[inline]
316 pub fn len(&self) -> usize {
317 self.specs.len()
318 }
319
320 #[inline]
321 pub fn is_empty(&self) -> bool {
322 self.specs.is_empty()
323 }
324
325 pub fn quadratic(&self, beta: ArrayView1<'_, f64>) -> f64 {
328 let mut total = 0.0;
329 for (spec, &log_lambda) in self.specs.iter().zip(self.log_lambdas.iter()) {
330 let lam = log_lambda.exp();
331 total += 0.5 * lam * spec.quadratic_form(beta);
332 }
333 total
334 }
335
336 pub fn add_apply_into(&self, vector: ArrayView1<'_, f64>, out: &mut ndarray::Array1<f64>) {
338 assert_eq!(out.len(), vector.len());
339 for (spec, &log_lambda) in self.specs.iter().zip(self.log_lambdas.iter()) {
340 let lam = log_lambda.exp();
341 let sv = spec.matrix.dot(&vector);
342 out.scaled_add(lam, &sv);
343 }
344 }
345
346 pub fn add_diag(&self, diag: &mut ndarray::Array1<f64>) {
348 for (spec, &log_lambda) in self.specs.iter().zip(self.log_lambdas.iter()) {
349 let lam = log_lambda.exp();
350 for (i, value) in spec.matrix.diag().iter().enumerate() {
351 diag[i] += lam * *value;
352 }
353 }
354 }
355
356 pub fn add_to_matrix(&self, matrix: &mut Array2<f64>) {
358 assert_eq!(matrix.nrows(), matrix.ncols());
359 for (spec, &log_lambda) in self.specs.iter().zip(self.log_lambdas.iter()) {
360 let lam = log_lambda.exp();
361 matrix.scaled_add(lam, &spec.matrix);
362 }
363 }
364
365 pub fn rho_objective_gradient(&self, beta: ArrayView1<'_, f64>, out: &mut [f64]) {
368 assert_eq!(out.len(), self.specs.len());
369 for (i, (spec, &log_lambda)) in self.specs.iter().zip(self.log_lambdas.iter()).enumerate() {
370 let lam = log_lambda.exp();
371 out[i] = 0.5 * lam * spec.quadratic_form(beta);
372 }
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use ndarray::{Array1, Array2, array};
380
381 fn cross_block_spec() -> JointPenaltySpec {
385 let v: Array1<f64> = array![1.0, 0.0, -1.0, 0.0];
387 let w: Array1<f64> = array![0.0, 1.0, 0.0, -1.0];
388 let mut matrix: Array2<f64> = Array2::zeros((4, 4));
389 for i in 0..4 {
390 for j in 0..4 {
391 matrix[[i, j]] = v[i] * v[j] + w[i] * w[j];
392 }
393 }
394 JointPenaltySpec {
395 label: Some("cross_block_pullback".to_string()),
396 matrix,
397 initial_log_lambda: -1.5,
398 nullspace_dim: 2,
399 }
400 }
401
402 #[test]
403 fn cross_block_dense_validates() {
404 let result = cross_block_spec().validate();
405 assert!(
406 result.is_ok(),
407 "valid cross-block spec rejected: {result:?}"
408 );
409 }
410
411 #[test]
412 fn trace_matches_diagonal_sum() {
413 let spec = cross_block_spec();
414 assert!((spec.trace() - 4.0).abs() < 1e-12);
416 }
417
418 #[test]
419 fn pseudo_rank_uses_declared_nullspace() {
420 let spec = cross_block_spec();
421 assert_eq!(spec.dim(), 4);
422 assert_eq!(spec.pseudo_rank(), 2);
423 }
424
425 #[test]
426 fn quadratic_form_matches_explicit_mat_vec() {
427 let spec = cross_block_spec();
428 let beta: Array1<f64> = array![0.5, -0.25, 1.0, 0.75];
430 let q = spec.quadratic_form(beta.view());
433 assert!((q - 1.25).abs() < 1e-12, "got {q}");
434 }
435
436 #[test]
437 fn determinant_zero_for_rank_deficient_matches_nullspace() {
438 use gam_linalg::faer_ndarray::FaerEigh;
439 let spec = cross_block_spec();
440 let (eigvals, _) =
443 FaerEigh::eigh(&spec.matrix, faer::Side::Lower).expect("symmetric eigh succeeds");
444 let mut sorted: Vec<f64> = eigvals.iter().copied().collect();
445 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
446 let zeros = sorted.iter().take_while(|&&v| v.abs() < 1e-10).count();
447 assert_eq!(
448 zeros, spec.nullspace_dim,
449 "spectrum {sorted:?} should have {} near-zeros",
450 spec.nullspace_dim
451 );
452 let det: f64 = sorted.iter().product();
455 assert!(det.abs() < 1e-10, "expected ~0 determinant, got {det}");
456 }
457
458 #[test]
459 fn validate_rejects_non_square() {
460 let spec = JointPenaltySpec {
461 label: None,
462 matrix: Array2::zeros((3, 4)),
463 initial_log_lambda: 0.0,
464 nullspace_dim: 0,
465 };
466 assert!(matches!(
467 spec.validate(),
468 Err(JointPenaltyError::NotSquare { nrows: 3, ncols: 4 })
469 ));
470 }
471
472 #[test]
473 fn validate_rejects_non_symmetric() {
474 let mut matrix = Array2::<f64>::zeros((3, 3));
475 matrix[[0, 1]] = 1.0;
476 matrix[[1, 0]] = -1.0;
477 let spec = JointPenaltySpec {
478 label: None,
479 matrix,
480 initial_log_lambda: 0.0,
481 nullspace_dim: 0,
482 };
483 assert!(matches!(
484 spec.validate(),
485 Err(JointPenaltyError::NotSymmetric { .. })
486 ));
487 }
488
489 #[test]
490 fn validate_rejects_oversized_nullspace() {
491 let spec = JointPenaltySpec {
492 label: None,
493 matrix: Array2::zeros((3, 3)),
494 initial_log_lambda: 0.0,
495 nullspace_dim: 4,
496 };
497 assert!(matches!(
498 spec.validate(),
499 Err(JointPenaltyError::NullspaceTooLarge {
500 total: 3,
501 nullspace_dim: 4
502 })
503 ));
504 }
505
506 #[test]
507 fn validate_rejects_non_finite_initial_log_lambda() {
508 let spec = JointPenaltySpec {
509 label: None,
510 matrix: Array2::zeros((2, 2)),
511 initial_log_lambda: f64::NAN,
512 nullspace_dim: 0,
513 };
514 assert!(matches!(
515 spec.validate(),
516 Err(JointPenaltyError::NonFiniteInitialLogLambda { .. })
517 ));
518 }
519
520 #[test]
534 fn bundle_two_block_minimiser_matches_analytic_solution() {
535 use gam_linalg::faer_ndarray::FaerCholesky;
536 use ndarray::Array2;
537
538 let spec = JointPenaltySpec {
539 label: Some("toy_cross_block".to_string()),
540 matrix: array![[2.0_f64, 1.0], [1.0, 2.0]],
541 initial_log_lambda: 0.0,
542 nullspace_dim: 0,
543 };
544 let log_lambda = -0.4_f64;
545 let lam = log_lambda.exp();
546 let bundle = JointPenaltyBundle::new(std::sync::Arc::new(vec![spec]), vec![log_lambda], 2)
547 .expect("valid bundle");
548
549 let mut lhs = Array2::<f64>::eye(2);
552 bundle.add_to_matrix(&mut lhs);
553 let expected_lhs = array![[1.0 + lam * 2.0, lam], [lam, 1.0 + lam * 2.0]];
555 for r in 0..2 {
556 for c in 0..2 {
557 assert!(
558 (lhs[[r, c]] - expected_lhs[[r, c]]).abs() < 1e-12,
559 "lhs[{r}, {c}] = {} expected {}",
560 lhs[[r, c]],
561 expected_lhs[[r, c]]
562 );
563 }
564 }
565
566 let b: Array1<f64> = array![1.0, -0.5];
568 let chol = lhs.cholesky(faer::Side::Lower).expect("SPD");
569 let mut rhs_mat = Array2::<f64>::zeros((2, 1));
570 rhs_mat[[0, 0]] = b[0];
571 rhs_mat[[1, 0]] = b[1];
572 let mut beta_mat = rhs_mat.clone();
573 chol.solve_mat_in_place(&mut beta_mat);
574 let beta_hat: Array1<f64> = array![beta_mat[[0, 0]], beta_mat[[1, 0]]];
575
576 let mut grad = &beta_hat - &b;
578 bundle.add_apply_into(beta_hat.view(), &mut grad);
579 let grad_inf = grad.iter().map(|v: &f64| v.abs()).fold(0.0_f64, f64::max);
580 assert!(
581 grad_inf < 1e-12,
582 "penalised gradient at analytic minimiser must vanish: {grad_inf:.3e}"
583 );
584
585 let resid = &beta_hat - &b;
588 let unpen = 0.5 * resid.dot(&resid);
589 let pen = bundle.quadratic(beta_hat.view());
590 let expected_obj = 0.5 * resid.dot(&resid)
591 + 0.5 * lam * beta_hat.dot(&array![[2.0, 1.0], [1.0, 2.0]].dot(&beta_hat));
592 assert!(
593 (unpen + pen - expected_obj).abs() < 1e-12,
594 "objective sum {} mismatched expected {}",
595 unpen + pen,
596 expected_obj
597 );
598
599 let mut diag = ndarray::Array1::<f64>::from_elem(2, 1.0);
601 bundle.add_diag(&mut diag);
602 assert!((diag[0] - (1.0 + lam * 2.0)).abs() < 1e-12);
603 assert!((diag[1] - (1.0 + lam * 2.0)).abs() < 1e-12);
604
605 let mut rho_grad = vec![0.0_f64];
607 bundle.rho_objective_gradient(beta_hat.view(), &mut rho_grad);
608 let expected_rho_grad =
609 0.5 * lam * beta_hat.dot(&array![[2.0, 1.0], [1.0, 2.0]].dot(&beta_hat));
610 assert!(
611 (rho_grad[0] - expected_rho_grad).abs() < 1e-12,
612 "rho-grad {} expected {}",
613 rho_grad[0],
614 expected_rho_grad
615 );
616 }
617
618 #[test]
619 fn bundle_rejects_dim_mismatch() {
620 let spec = JointPenaltySpec {
621 label: None,
622 matrix: Array2::<f64>::eye(3),
623 initial_log_lambda: 0.0,
624 nullspace_dim: 0,
625 };
626 let err = JointPenaltyBundle::new(std::sync::Arc::new(vec![spec]), vec![0.0], 4)
627 .expect_err("dim mismatch must reject");
628 assert!(err.contains("total_compiled"));
629 }
630
631 #[test]
632 fn bundle_rejects_lambda_count_mismatch() {
633 let spec = JointPenaltySpec {
634 label: None,
635 matrix: Array2::<f64>::eye(2),
636 initial_log_lambda: 0.0,
637 nullspace_dim: 0,
638 };
639 let err = JointPenaltyBundle::new(std::sync::Arc::new(vec![spec]), vec![], 2)
640 .expect_err("count mismatch must reject");
641 assert!(err.contains("specs vs"));
642 }
643}