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