1use ndarray::{Array1, Array2, Axis};
5use rayon::iter::{IndexedParallelIterator, IntoParallelIterator, ParallelIterator};
6
7#[derive(Clone, Debug)]
17pub enum PenaltyMatrix {
18 Dense(Array2<f64>),
19 KroneckerFactored {
20 left: Array2<f64>,
21 right: Array2<f64>,
22 },
23 Blockwise {
27 local: Array2<f64>,
28 col_range: std::ops::Range<usize>,
29 total_dim: usize,
30 },
31 Labeled {
34 label: String,
35 inner: Box<PenaltyMatrix>,
36 },
37 Fixed {
41 log_lambda: f64,
42 inner: Box<PenaltyMatrix>,
43 },
44}
45
46impl PenaltyMatrix {
47 pub fn dim(&self) -> usize {
49 match self {
50 Self::Dense(m) => m.nrows(),
51 Self::KroneckerFactored { left, right } => left.nrows() * right.nrows(),
52 Self::Blockwise { total_dim, .. } => *total_dim,
53 Self::Labeled { inner, .. } | Self::Fixed { inner, .. } => inner.dim(),
54 }
55 }
56
57 pub fn shape(&self) -> (usize, usize) {
63 match self {
64 Self::Dense(m) => m.dim(),
65 Self::KroneckerFactored { left, right } => {
66 (left.nrows() * right.nrows(), left.ncols() * right.ncols())
67 }
68 Self::Blockwise { total_dim, .. } => (*total_dim, *total_dim),
69 Self::Labeled { inner, .. } | Self::Fixed { inner, .. } => inner.shape(),
70 }
71 }
72
73 pub fn validate(&self, expected_dim: usize) -> Result<(), String> {
86 let (nrows, ncols) = self.shape();
87 if nrows != ncols || nrows != expected_dim {
88 return Err(format!(
89 "penalty must be {expected_dim}x{expected_dim}, got {nrows}x{ncols}"
90 ));
91 }
92 match self {
93 Self::Dense(m) => validate_symmetric_psd_core(m, "dense penalty"),
94 Self::KroneckerFactored { left, right } => {
95 validate_symmetric_psd_core(left, "Kronecker left factor")?;
100 validate_symmetric_psd_core(right, "Kronecker right factor")
101 }
102 Self::Blockwise {
103 local,
104 col_range,
105 total_dim,
106 } => {
107 if col_range.end > *total_dim || col_range.len() != local.nrows() {
108 return Err(format!(
109 "blockwise penalty embedding is inconsistent: local {}x{} at columns \
110 {}..{} of total_dim {}",
111 local.nrows(),
112 local.ncols(),
113 col_range.start,
114 col_range.end,
115 total_dim
116 ));
117 }
118 validate_symmetric_psd_core(local, "blockwise local penalty")
119 }
120 Self::Labeled { inner, .. } => inner.validate(expected_dim),
121 Self::Fixed { log_lambda, inner } => {
122 crate::validate_log_strength(*log_lambda)
123 .map_err(|error| format!("fixed penalty log-precision: {error}"))?;
124 inner.validate(expected_dim)
125 }
126 }
127 }
128
129 pub fn to_dense(&self) -> Array2<f64> {
131 match self {
132 Self::Dense(m) => m.clone(),
133 Self::KroneckerFactored { left, right } => kronecker_product(left, right),
134 Self::Blockwise {
135 local,
136 col_range,
137 total_dim,
138 } => {
139 let mut g = Array2::zeros((*total_dim, *total_dim));
140 g.slice_mut(ndarray::s![
141 col_range.start..col_range.end,
142 col_range.start..col_range.end
143 ])
144 .assign(local);
145 g
146 }
147 Self::Labeled { inner, .. } | Self::Fixed { inner, .. } => inner.to_dense(),
148 }
149 }
150
151 pub fn as_dense_cow(&self) -> std::borrow::Cow<'_, Array2<f64>> {
153 match self {
154 Self::Dense(m) => std::borrow::Cow::Borrowed(m),
155 Self::KroneckerFactored { .. }
156 | Self::Blockwise { .. }
157 | Self::Labeled { .. }
158 | Self::Fixed { .. } => std::borrow::Cow::Owned(self.to_dense()),
159 }
160 }
161
162 pub fn as_dense_ref(&self) -> Option<&Array2<f64>> {
164 match self {
165 Self::Dense(m) => Some(m),
166 Self::Fixed { inner, .. } => inner.as_dense_ref(),
167 Self::KroneckerFactored { .. } | Self::Blockwise { .. } | Self::Labeled { .. } => None,
168 }
169 }
170
171 pub fn with_precision_label(self, label: impl Into<String>) -> Self {
172 Self::Labeled {
173 label: label.into(),
174 inner: Box::new(self),
175 }
176 }
177
178 pub fn precision_label(&self) -> Option<&str> {
179 match self {
180 Self::Labeled { label, .. } => Some(label.as_str()),
181 Self::Fixed { .. } => None,
182 _ => None,
183 }
184 }
185
186 pub fn with_fixed_log_lambda(self, log_lambda: f64) -> Self {
187 Self::Fixed {
188 log_lambda,
189 inner: Box::new(self),
190 }
191 }
192
193 pub fn fixed_log_lambda(&self) -> Option<f64> {
194 match self {
195 Self::Fixed { log_lambda, .. } => Some(*log_lambda),
196 Self::Labeled { inner, .. } => inner.fixed_log_lambda(),
197 _ => None,
198 }
199 }
200
201 pub fn dot(&self, v: &Array1<f64>) -> Array1<f64> {
205 match self {
206 Self::Dense(m) => m.dot(v),
207 Self::KroneckerFactored { left, right } => {
208 let p_left = left.nrows();
209 let p_right = right.nrows();
210 let v_mat =
212 ndarray::ArrayView2::from_shape((p_left, p_right), v.as_slice().unwrap())
213 .unwrap();
214 let avbt = left.dot(&v_mat).dot(&right.t());
215 let standard = avbt.as_standard_layout();
216 Array1::from_iter(standard.iter().copied())
217 }
218 Self::Blockwise {
219 local,
220 col_range,
221 total_dim,
222 } => {
223 let mut out = Array1::zeros(*total_dim);
224 let v_block = v.slice(ndarray::s![col_range.clone()]);
225 let result_block = local.dot(&v_block);
226 out.slice_mut(ndarray::s![col_range.clone()])
227 .assign(&result_block);
228 out
229 }
230 Self::Labeled { inner, .. } | Self::Fixed { inner, .. } => inner.dot(v),
231 }
232 }
233
234 pub fn add_scaled_to(&self, lambda: f64, target: &mut Array2<f64>) {
236 match self {
237 Self::Dense(m) => {
238 target.scaled_add(lambda, m);
239 }
240 Self::KroneckerFactored { left, right } => {
241 let p_left = left.nrows();
242 let p_right = right.nrows();
243 for i1 in 0..p_left {
244 for j1 in 0..p_left {
245 let a_ij = left[[i1, j1]];
246 if a_ij == 0.0 {
247 continue;
248 }
249 let scaled_a = lambda * a_ij;
250 for i2 in 0..p_right {
251 let row = i1 * p_right + i2;
252 for j2 in 0..p_right {
253 let col = j1 * p_right + j2;
254 target[[row, col]] += scaled_a * right[[i2, j2]];
255 }
256 }
257 }
258 }
259 }
260 Self::Blockwise {
261 local, col_range, ..
262 } => {
263 target
264 .slice_mut(ndarray::s![col_range.clone(), col_range.clone()])
265 .scaled_add(lambda, local);
266 }
267 Self::Labeled { inner, .. } | Self::Fixed { inner, .. } => {
268 inner.add_scaled_to(lambda, target)
269 }
270 }
271 }
272
273 pub fn add_scaled_diag_to(&self, lambda: f64, target: &mut Array1<f64>) {
275 match self {
276 Self::Dense(m) => {
277 let p = m.nrows().min(target.len());
278 for j in 0..p {
279 target[j] += lambda * m[[j, j]];
280 }
281 }
282 Self::KroneckerFactored { left, right } => {
283 let p_left = left.nrows();
284 let p_right = right.nrows();
285 assert_eq!(target.len(), p_left * p_right);
286 for i_left in 0..p_left {
287 let left_diag = left[[i_left, i_left]];
288 if left_diag == 0.0 {
289 continue;
290 }
291 let scaled_left = lambda * left_diag;
292 for i_right in 0..p_right {
293 target[i_left * p_right + i_right] +=
294 scaled_left * right[[i_right, i_right]];
295 }
296 }
297 }
298 Self::Blockwise {
299 local, col_range, ..
300 } => {
301 let width = local.nrows().min(col_range.len());
302 for local_idx in 0..width {
303 target[col_range.start + local_idx] += lambda * local[[local_idx, local_idx]];
304 }
305 }
306 Self::Labeled { inner, .. } | Self::Fixed { inner, .. } => {
307 inner.add_scaled_diag_to(lambda, target)
308 }
309 }
310 }
311
312 pub fn quadratic_form(&self, beta: &Array1<f64>) -> f64 {
314 match self {
315 Self::Dense(m) => beta.dot(&m.dot(beta)),
316 Self::KroneckerFactored { .. } => {
317 let sv = self.dot(beta);
318 beta.dot(&sv)
319 }
320 Self::Blockwise {
321 local, col_range, ..
322 } => {
323 let beta_block = beta.slice(ndarray::s![col_range.clone()]);
324 let sv = local.dot(&beta_block);
325 beta_block.dot(&sv)
326 }
327 Self::Labeled { inner, .. } | Self::Fixed { inner, .. } => inner.quadratic_form(beta),
328 }
329 }
330
331 pub fn nrows(&self) -> usize {
333 self.dim()
334 }
335
336 pub fn ncols(&self) -> usize {
337 self.dim()
338 }
339}
340
341impl From<Array2<f64>> for PenaltyMatrix {
342 fn from(m: Array2<f64>) -> Self {
343 Self::Dense(m)
344 }
345}
346
347fn validate_symmetric_psd_core(matrix: &Array2<f64>, what: &str) -> Result<(), String> {
353 use gam_linalg::faer_ndarray::FaerEigh;
354
355 let (nrows, ncols) = matrix.dim();
356 if nrows != ncols {
357 return Err(format!("{what} is not square: {nrows}x{ncols}"));
358 }
359 let mut max_abs = 0.0_f64;
360 for ((row, col), &value) in matrix.indexed_iter() {
361 if !value.is_finite() {
362 return Err(format!(
363 "{what} has non-finite entry at ({row},{col}): {value}"
364 ));
365 }
366 max_abs = max_abs.max(value.abs());
367 }
368 let sym_tol = 1e-10 * max_abs.max(1.0);
371 for row in 0..nrows {
372 for col in (row + 1)..ncols {
373 let asymmetry = (matrix[[row, col]] - matrix[[col, row]]).abs();
374 if asymmetry > sym_tol {
375 return Err(format!(
376 "{what} is not symmetric at ({row},{col}): |S - Sᵀ| = {asymmetry:.3e}; \
377 the gradient of βᵀSβ/2 is sym(S)β, so a skew component would make the \
378 implemented objective and gradient describe different functions"
379 ));
380 }
381 }
382 }
383 if nrows == 0 || max_abs == 0.0 {
384 return Ok(()); }
386 let (eigenvalues, _) = matrix
387 .eigh(faer::Side::Lower)
388 .map_err(|e| format!("{what} eigendecomposition failed during validation: {e}"))?;
389 let max_abs_eval = eigenvalues
390 .iter()
391 .fold(0.0_f64, |acc, &ev| acc.max(ev.abs()));
392 let psd_tol = 100.0 * (nrows as f64) * f64::EPSILON * max_abs_eval;
393 if let Some(&min_eval) = eigenvalues
394 .iter()
395 .filter(|&&ev| ev < -psd_tol)
396 .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
397 {
398 return Err(format!(
399 "{what} is not positive semidefinite: min eigenvalue {min_eval:.6e} \
400 (max |eigenvalue| {max_abs_eval:.6e}); the penalized objective is unbounded \
401 below along the negative mode while rank/logdet filtering would silently \
402 drop it"
403 ));
404 }
405 Ok(())
406}
407
408fn kronecker_product(a: &Array2<f64>, b: &Array2<f64>) -> Array2<f64> {
412 let (arows, a_cols) = a.dim();
413 let (brows, b_cols) = b.dim();
414 if arows == 0 || a_cols == 0 || brows == 0 || b_cols == 0 {
415 return Array2::zeros((arows * brows, a_cols * b_cols));
416 }
417 let mut result = Array2::zeros((arows * brows, a_cols * b_cols));
418
419 result
420 .axis_chunks_iter_mut(Axis(0), brows)
421 .into_par_iter()
422 .enumerate()
423 .for_each(|(i, mut row_block)| {
424 let arow = a.row(i);
425 let col_chunks = row_block.axis_chunks_iter_mut(Axis(1), b_cols);
426 for (j, mut block) in col_chunks.into_iter().enumerate() {
427 let aval = arow[j];
428 if aval == 0.0 {
429 continue;
430 }
431 for (dest, &src) in block.iter_mut().zip(b.iter()) {
432 *dest = aval * src;
433 }
434 }
435 });
436
437 result
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443 use ndarray::array;
444
445 #[test]
448 fn dense_dim_and_shape() {
449 let m = array![[1.0, 0.0], [0.0, 2.0]];
450 let p = PenaltyMatrix::Dense(m);
451 assert_eq!(p.dim(), 2);
452 assert_eq!(p.shape(), (2, 2));
453 assert_eq!(p.nrows(), 2);
454 assert_eq!(p.ncols(), 2);
455 }
456
457 #[test]
458 fn dense_to_dense_is_clone() {
459 let m = array![[3.0, 1.0], [1.0, 4.0]];
460 let p = PenaltyMatrix::Dense(m.clone());
461 assert_eq!(p.to_dense(), m);
462 }
463
464 #[test]
465 fn dense_dot_product() {
466 let m = array![[1.0, 0.0], [0.0, 2.0]];
468 let p = PenaltyMatrix::Dense(m);
469 let v = ndarray::array![3.0, 5.0];
470 let result = p.dot(&v);
471 assert_eq!(result.as_slice().unwrap(), &[3.0, 10.0]);
472 }
473
474 #[test]
475 fn dense_quadratic_form() {
476 let m = array![[1.0, 0.0], [0.0, 2.0]];
478 let p = PenaltyMatrix::Dense(m);
479 let beta = ndarray::array![3.0, 2.0];
480 assert!((p.quadratic_form(&beta) - 17.0).abs() < 1e-14);
481 }
482
483 #[test]
484 fn dense_add_scaled_to() {
485 let s = array![[1.0, 0.0], [0.0, 1.0]];
486 let p = PenaltyMatrix::Dense(s);
487 let mut acc = ndarray::Array2::<f64>::zeros((2, 2));
488 p.add_scaled_to(3.0, &mut acc);
489 assert_eq!(acc, array![[3.0, 0.0], [0.0, 3.0]]);
490 }
491
492 #[test]
493 fn dense_add_scaled_diag_to() {
494 let s = array![[2.0, 5.0], [5.0, 7.0]];
495 let p = PenaltyMatrix::Dense(s);
496 let mut diag = ndarray::array![0.0, 0.0];
497 p.add_scaled_diag_to(1.0, &mut diag);
498 assert_eq!(diag.as_slice().unwrap(), &[2.0, 7.0]);
500 }
501
502 #[test]
505 fn kronecker_dim_is_product() {
506 let left = array![[1.0, 0.0], [0.0, 1.0]]; let right = array![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; let p = PenaltyMatrix::KroneckerFactored { left, right };
509 assert_eq!(p.dim(), 6);
510 }
511
512 #[test]
513 fn kronecker_to_dense_identity_x_identity() {
514 let eye2 = ndarray::Array2::<f64>::eye(2);
516 let p = PenaltyMatrix::KroneckerFactored {
517 left: eye2.clone(),
518 right: eye2,
519 };
520 let dense = p.to_dense();
521 assert_eq!(dense, ndarray::Array2::<f64>::eye(4));
522 }
523
524 #[test]
525 fn kronecker_dot_matches_dense_dot() {
526 let left = array![[2.0, 0.0], [0.0, 3.0]];
527 let right = array![[1.0, 1.0], [0.0, 1.0]];
528 let p = PenaltyMatrix::KroneckerFactored {
529 left: left.clone(),
530 right: right.clone(),
531 };
532 let dense = p.to_dense();
534 let v = ndarray::array![1.0, 2.0, 3.0, 4.0];
535 let got = p.dot(&v);
536 let expected = dense.dot(&v);
537 for (a, b) in got.iter().zip(expected.iter()) {
538 assert!((a - b).abs() < 1e-14, "got={a} expected={b}");
539 }
540 }
541
542 #[test]
545 fn blockwise_dim_is_total() {
546 let local = array![[1.0, 0.0], [0.0, 1.0]];
547 let p = PenaltyMatrix::Blockwise {
548 local,
549 col_range: 1..3,
550 total_dim: 5,
551 };
552 assert_eq!(p.dim(), 5);
553 }
554
555 #[test]
556 fn blockwise_to_dense_embeds_local_block() {
557 let local = array![[2.0, 1.0], [1.0, 3.0]];
559 let p = PenaltyMatrix::Blockwise {
560 local,
561 col_range: 1..3,
562 total_dim: 3,
563 };
564 let dense = p.to_dense();
565 assert_eq!(dense[[0, 0]], 0.0);
566 assert_eq!(dense[[1, 1]], 2.0);
567 assert_eq!(dense[[1, 2]], 1.0);
568 assert_eq!(dense[[2, 1]], 1.0);
569 assert_eq!(dense[[2, 2]], 3.0);
570 }
571
572 #[test]
573 fn blockwise_dot_only_touches_block() {
574 let local = array![[2.0, 0.0], [0.0, 3.0]];
575 let p = PenaltyMatrix::Blockwise {
576 local,
577 col_range: 1..3,
578 total_dim: 4,
579 };
580 let v = ndarray::array![7.0, 1.0, 2.0, 9.0];
581 let out = p.dot(&v);
582 assert_eq!(out.as_slice().unwrap(), &[0.0, 2.0, 6.0, 0.0]);
584 }
585
586 #[test]
589 fn labeled_inherits_dim_and_delegates_dot() {
590 let m = array![[1.0, 0.0], [0.0, 2.0]];
591 let p = PenaltyMatrix::Dense(m).with_precision_label("smooth");
592 assert_eq!(p.dim(), 2);
593 assert_eq!(p.precision_label(), Some("smooth"));
594 let v = ndarray::array![3.0, 4.0];
595 let out = p.dot(&v);
596 assert_eq!(out.as_slice().unwrap(), &[3.0, 8.0]);
597 }
598
599 #[test]
600 fn fixed_inherits_dim_and_exposes_log_lambda() {
601 let m = array![[5.0, 0.0], [0.0, 5.0]];
602 let p = PenaltyMatrix::Dense(m).with_fixed_log_lambda(2.5);
603 assert_eq!(p.dim(), 2);
604 assert_eq!(p.fixed_log_lambda(), Some(2.5));
605 }
606
607 #[test]
610 fn shape_reports_actual_storage_not_fabricated_square() {
611 let p = PenaltyMatrix::Dense(Array2::<f64>::zeros((2, 3)));
614 assert_eq!(p.shape(), (2, 3));
615 assert!(p.validate(2).is_err());
616 assert!(p.validate(3).is_err());
617 }
618
619 #[test]
620 fn validate_accepts_canonical_carriers() {
621 let dense = PenaltyMatrix::Dense(array![[2.0, -1.0], [-1.0, 2.0]]);
622 assert_eq!(dense.validate(2), Ok(()));
623
624 let kron = PenaltyMatrix::KroneckerFactored {
625 left: array![[1.0, -1.0], [-1.0, 1.0]],
626 right: ndarray::Array2::<f64>::eye(3),
627 };
628 assert_eq!(kron.validate(6), Ok(()));
629
630 let blockwise = PenaltyMatrix::Blockwise {
631 local: array![[1.0, 0.0], [0.0, 1.0]],
632 col_range: 1..3,
633 total_dim: 4,
634 };
635 assert_eq!(blockwise.validate(4), Ok(()));
636 }
637
638 #[test]
639 fn validate_rejects_asymmetric_indefinite_and_nonfinite() {
640 let skew = PenaltyMatrix::Dense(array![[1.0, 1.0], [0.0, 1.0]]);
642 assert!(skew.validate(2).unwrap_err().contains("not symmetric"));
643
644 let indefinite = PenaltyMatrix::Dense(array![[1.0, 0.0], [0.0, -1.0]]);
646 assert!(
647 indefinite
648 .validate(2)
649 .unwrap_err()
650 .contains("not positive semidefinite")
651 );
652
653 let nan = PenaltyMatrix::Dense(array![[f64::NAN, 0.0], [0.0, 1.0]]);
654 assert!(nan.validate(2).unwrap_err().contains("non-finite"));
655
656 let bad_fixed = PenaltyMatrix::Dense(ndarray::Array2::<f64>::eye(2))
658 .with_fixed_log_lambda(f64::INFINITY);
659 assert!(
660 bad_fixed
661 .validate(2)
662 .unwrap_err()
663 .contains("must be finite")
664 );
665
666 let finite_but_out_of_domain = PenaltyMatrix::Dense(ndarray::Array2::<f64>::eye(2))
667 .with_fixed_log_lambda(crate::LOG_STRENGTH_MAX + 1.0);
668 assert!(
669 finite_but_out_of_domain
670 .validate(2)
671 .unwrap_err()
672 .contains("must be finite and in")
673 );
674 }
675
676 #[test]
677 fn validate_rejects_inconsistent_blockwise_embedding() {
678 let p = PenaltyMatrix::Blockwise {
680 local: ndarray::Array2::<f64>::eye(3),
681 col_range: 1..3,
682 total_dim: 4,
683 };
684 assert!(p.validate(4).is_err());
685 let q = PenaltyMatrix::Blockwise {
687 local: ndarray::Array2::<f64>::eye(2),
688 col_range: 3..5,
689 total_dim: 4,
690 };
691 assert!(q.validate(4).is_err());
692 }
693}