1use crate::parameter_block::ParameterBlockInput;
2use gam_linalg::matrix::{DenseDesignMatrix, DesignMatrix};
3use gam_solve::pirls::LinearInequalityConstraints;
4use gam_terms::basis::{
5 BasisOptions, Dense, KnotSource, create_basis, create_ispline_derivative_dense,
6 ispline_function_penalties,
7};
8use ndarray::{Array1, Array2, ArrayView1};
9use serde::{Deserialize, Serialize};
10
11#[derive(Clone, Debug)]
12pub struct WiggleBlockConfig {
13 pub degree: usize,
14 pub num_internal_knots: usize,
15 pub penalty_order: usize,
16 pub double_penalty: bool,
17}
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
25#[serde(tag = "kind", rename_all = "kebab-case")]
26pub enum WigglePenaltyBlockKind {
27 Roughness { derivative_order: usize },
28 NullspaceShrinkage { derivative_order: usize },
29}
30
31#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
40pub struct WigglePenaltyMetadata {
41 pub derivative_orders: Vec<usize>,
42 pub double_penalty: bool,
43 pub blocks: Vec<WigglePenaltyBlockKind>,
44}
45
46#[derive(Clone, Debug)]
48pub struct CanonicalWigglePenaltySet {
49 pub metadata: WigglePenaltyMetadata,
50 pub matrices: Vec<Array2<f64>>,
51 pub nullspace_dims: Vec<usize>,
52}
53
54#[derive(Clone)]
55pub(crate) struct SelectedWiggleBasis {
56 pub knots: Array1<f64>,
57 pub degree: usize,
58 pub block: ParameterBlockInput,
59 pub penalty_metadata: WigglePenaltyMetadata,
60}
61
62pub(crate) use gam_terms::basis::initializewiggle_knots_from_seed;
68
69#[inline]
70pub(crate) fn monotone_wiggle_internal_degree(degree: usize) -> Result<usize, String> {
71 degree
76 .checked_sub(1)
77 .filter(|&internal_degree| internal_degree >= 1)
78 .ok_or_else(|| "monotone wiggle degree must be >= 2".to_string())
79}
80
81pub fn canonical_wiggle_function_penalties(
90 knots: &Array1<f64>,
91 degree: usize,
92 derivative_orders: &[usize],
93 double_penalty: bool,
94) -> Result<CanonicalWigglePenaltySet, String> {
95 if derivative_orders.is_empty() {
96 return Err("wiggle penalty metadata requires at least one derivative order".to_string());
97 }
98 if derivative_orders.contains(&0) {
99 return Err("wiggle penalty derivative orders must all be positive".to_string());
100 }
101 for (index, &order) in derivative_orders.iter().enumerate() {
102 if derivative_orders[..index].contains(&order) {
103 return Err(format!(
104 "wiggle penalty derivative order {order} is duplicated in canonical metadata"
105 ));
106 }
107 }
108
109 let internal_degree = monotone_wiggle_internal_degree(degree)?;
110 let mut blocks = Vec::new();
111 let mut matrices = Vec::new();
112 let mut nullspace_dims = Vec::new();
113 for (index, &derivative_order) in derivative_orders.iter().enumerate() {
114 let penalties = ispline_function_penalties(
115 knots.view(),
116 internal_degree,
117 derivative_order,
118 index == 0 && double_penalty,
119 )
120 .map_err(|error| error.to_string())?;
121 blocks.push(WigglePenaltyBlockKind::Roughness { derivative_order });
122 matrices.push(penalties.roughness);
123 nullspace_dims.push(penalties.roughness_nullspace_dim);
124 if let Some(nullspace_shrinkage) = penalties.nullspace_shrinkage {
125 blocks.push(WigglePenaltyBlockKind::NullspaceShrinkage { derivative_order });
126 matrices.push(nullspace_shrinkage);
127 nullspace_dims.push(0);
128 }
129 }
130
131 Ok(CanonicalWigglePenaltySet {
132 metadata: WigglePenaltyMetadata {
133 derivative_orders: derivative_orders.to_vec(),
134 double_penalty,
135 blocks,
136 },
137 matrices,
138 nullspace_dims,
139 })
140}
141
142fn buildwiggle_block_input_from_canonical_penalties(
143 seed: ArrayView1<'_, f64>,
144 knots: &Array1<f64>,
145 degree: usize,
146 canonical: &CanonicalWigglePenaltySet,
147) -> Result<ParameterBlockInput, String> {
148 let design = monotone_wiggle_basis_from_knots(seed, knots, degree)?;
149 let p = design.ncols();
150 if p == 0 {
151 return Err("wiggle basis has no free monotone columns".to_string());
152 }
153 if canonical.matrices.len() != canonical.nullspace_dims.len()
154 || canonical.matrices.len() != canonical.metadata.blocks.len()
155 {
156 return Err(
157 "canonical wiggle penalty matrices, nullities, and topology disagree".to_string(),
158 );
159 }
160 for (index, matrix) in canonical.matrices.iter().enumerate() {
161 if matrix.dim() != (p, p) {
162 return Err(format!(
163 "canonical I-spline penalty block {index} is {}x{} but wiggle design has {p} columns",
164 matrix.nrows(),
165 matrix.ncols(),
166 ));
167 }
168 }
169 Ok(ParameterBlockInput {
170 design: DesignMatrix::Dense(DenseDesignMatrix::from(design)),
171 offset: Array1::zeros(seed.len()),
172 penalties: canonical
173 .matrices
174 .iter()
175 .cloned()
176 .map(crate::model_types::PenaltySpec::Dense)
177 .collect(),
178 nullspace_dims: canonical.nullspace_dims.clone(),
179 initial_log_lambdas: None,
180 initial_beta: Some(Array1::zeros(p)),
181 })
182}
183
184pub fn buildwiggle_block_input_from_knots(
185 seed: ArrayView1<'_, f64>,
186 knots: &Array1<f64>,
187 degree: usize,
188 penalty_order: usize,
189 double_penalty: bool,
190) -> Result<ParameterBlockInput, String> {
191 let canonical =
192 canonical_wiggle_function_penalties(knots, degree, &[penalty_order], double_penalty)?;
193 buildwiggle_block_input_from_canonical_penalties(seed, knots, degree, &canonical)
194}
195
196pub fn buildwiggle_block_input_from_seed(
197 seed: ArrayView1<'_, f64>,
198 cfg: &WiggleBlockConfig,
199) -> Result<(ParameterBlockInput, Array1<f64>), String> {
200 let knots = initializewiggle_knots_from_seed(seed, cfg.degree, cfg.num_internal_knots)?;
201 let block = buildwiggle_block_input_from_knots(
202 seed,
203 &knots,
204 cfg.degree,
205 cfg.penalty_order,
206 cfg.double_penalty,
207 )?;
208 Ok((block, knots))
209}
210
211pub(crate) fn monotone_wiggle_basis_from_knots(
212 seed: ArrayView1<'_, f64>,
213 knots: &Array1<f64>,
214 degree: usize,
215) -> Result<Array2<f64>, String> {
216 let internal_degree = monotone_wiggle_internal_degree(degree)?;
217 let (basis, _) = create_basis::<Dense>(
218 seed,
219 KnotSource::Provided(knots.view()),
220 internal_degree,
221 BasisOptions::i_spline(),
222 )
223 .map_err(|e| e.to_string())?;
224 Ok(basis.as_ref().clone())
225}
226
227pub fn monotone_wiggle_basis_with_derivative_order(
228 seed: ArrayView1<'_, f64>,
229 knots: &Array1<f64>,
230 degree: usize,
231 derivative_order: usize,
232) -> Result<Array2<f64>, String> {
233 if derivative_order == 0 {
234 return monotone_wiggle_basis_from_knots(seed, knots, degree);
235 }
236 let internal_degree = monotone_wiggle_internal_degree(degree)?;
237 create_ispline_derivative_dense(seed, knots, internal_degree, derivative_order)
238 .map_err(|e| e.to_string())
239}
240
241pub(crate) fn monotone_wiggle_nonnegative_constraints(
242 beta_dim: usize,
243) -> Option<gam_solve::pirls::ConstraintSet> {
244 if beta_dim == 0 {
245 return None;
246 }
247 let mut a = Array2::<f64>::zeros((beta_dim, beta_dim));
248 for i in 0..beta_dim {
249 a[[i, i]] = 1.0;
250 }
251 Some(gam_solve::pirls::ConstraintSet::Dense(
252 LinearInequalityConstraints {
253 a,
254 b: Array1::zeros(beta_dim),
255 },
256 ))
257}
258
259pub(crate) fn validate_monotone_wiggle_beta_nonnegative<'a>(
260 beta: impl IntoIterator<Item = &'a f64>,
261 context: &str,
262) -> Result<(), String> {
263 for (idx, &value) in beta.into_iter().enumerate() {
264 if !value.is_finite() {
265 return Err(format!("{context} coefficient {idx} is non-finite"));
266 }
267 if value < -1e-12 {
268 return Err(format!(
269 "{context} coefficient {idx} is negative ({value:.3e}); monotone wiggle coefficients must be non-negative"
270 ));
271 }
272 }
273 Ok(())
274}
275
276pub(crate) const MONOTONE_WIGGLE_ACTIVE_SET_TOL: f64 = 1e-6;
288
289pub(crate) fn project_monotone_wiggle_beta_nonnegative(mut beta: Array1<f64>) -> Array1<f64> {
296 for value in beta.iter_mut() {
297 if *value < 0.0 && *value >= -MONOTONE_WIGGLE_ACTIVE_SET_TOL {
298 *value = 0.0;
299 }
300 }
301 beta
302}
303
304pub fn split_wiggle_penalty_orders(
315 default_primary: usize,
316 penalty_orders: &[usize],
317) -> Result<(usize, Vec<usize>), String> {
318 if default_primary == 0 {
319 return Err("default wiggle penalty derivative order must be positive".to_string());
320 }
321 if penalty_orders.contains(&0) {
322 return Err("wiggle penalty derivative orders must all be positive".to_string());
323 }
324 let primary_order = penalty_orders
325 .iter()
326 .copied()
327 .min()
328 .unwrap_or(default_primary);
329 let mut extras = Vec::new();
330 for &order in penalty_orders {
331 if order == primary_order || extras.contains(&order) {
332 continue;
333 }
334 extras.push(order);
335 }
336 Ok((primary_order, extras))
337}
338
339pub fn append_selected_wiggle_function_penalties(
342 block: &mut ParameterBlockInput,
343 knots: &Array1<f64>,
344 degree: usize,
345 penalty_orders: &[usize],
346) -> Result<(), String> {
347 let p = block.design.ncols();
348 if p == 0 {
349 return Err("cannot append wiggle penalties to an empty basis".to_string());
350 }
351 let internal_degree = monotone_wiggle_internal_degree(degree)?;
352 for &order in penalty_orders {
353 let function_penalty =
354 ispline_function_penalties(knots.view(), internal_degree, order, false)
355 .map_err(|error| error.to_string())?;
356 if function_penalty.roughness.dim() != (p, p) {
357 return Err(format!(
358 "order-{order} I-spline function penalty is {}x{} but wiggle design has {p} columns",
359 function_penalty.roughness.nrows(),
360 function_penalty.roughness.ncols(),
361 ));
362 }
363 block.penalties.push(crate::model_types::PenaltySpec::Dense(
364 function_penalty.roughness,
365 ));
366 block
367 .nullspace_dims
368 .push(function_penalty.roughness_nullspace_dim);
369 }
370 Ok(())
371}
372
373pub(crate) fn select_wiggle_basis_from_seed(
374 seed: ArrayView1<'_, f64>,
375 cfg: &WiggleBlockConfig,
376 penalty_orders: &[usize],
377) -> Result<SelectedWiggleBasis, String> {
378 let (primary_order, extra_orders) =
379 split_wiggle_penalty_orders(cfg.penalty_order, penalty_orders)?;
380 let mut derivative_orders = Vec::with_capacity(1 + extra_orders.len());
381 derivative_orders.push(primary_order);
382 derivative_orders.extend(extra_orders);
383 let knots = initializewiggle_knots_from_seed(seed, cfg.degree, cfg.num_internal_knots)?;
384 let canonical = canonical_wiggle_function_penalties(
385 &knots,
386 cfg.degree,
387 &derivative_orders,
388 cfg.double_penalty,
389 )?;
390 let block =
391 buildwiggle_block_input_from_canonical_penalties(seed, &knots, cfg.degree, &canonical)?;
392 Ok(SelectedWiggleBasis {
393 knots,
394 degree: cfg.degree,
395 block,
396 penalty_metadata: canonical.metadata,
397 })
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403 use crate::model_types::PenaltySpec;
404 use ndarray::Array1;
405
406 fn dense_penalty(spec: &PenaltySpec) -> &Array2<f64> {
407 match spec {
408 PenaltySpec::Dense(m) => m,
409 other => panic!("expected Dense penalty, got {other:?}"),
410 }
411 }
412
413 fn is_symmetric(m: &Array2<f64>) -> bool {
414 let n = m.nrows();
415 if m.ncols() != n {
416 return false;
417 }
418 for i in 0..n {
419 for j in 0..n {
420 if (m[[i, j]] - m[[j, i]]).abs() > 1e-12 {
421 return false;
422 }
423 }
424 }
425 true
426 }
427
428 #[test]
431 fn internal_degree_rejects_degree_below_two() {
432 for d in [0usize, 1] {
434 let err = monotone_wiggle_internal_degree(d).unwrap_err();
435 assert_eq!(err, "monotone wiggle degree must be >= 2");
436 }
437 }
438
439 #[test]
440 fn internal_degree_is_degree_minus_one_for_valid_degrees() {
441 assert_eq!(monotone_wiggle_internal_degree(2).unwrap(), 1);
444 assert_eq!(monotone_wiggle_internal_degree(3).unwrap(), 2);
445 assert_eq!(monotone_wiggle_internal_degree(10).unwrap(), 9);
446 }
447
448 fn build(double_penalty: bool, penalty_order: usize) -> (ParameterBlockInput, usize) {
451 let seed = Array1::linspace(0.0, 1.0, 40);
453 let cfg = WiggleBlockConfig {
454 degree: 3,
455 num_internal_knots: 5,
456 penalty_order,
457 double_penalty,
458 };
459 let knots =
460 initializewiggle_knots_from_seed(seed.view(), cfg.degree, cfg.num_internal_knots)
461 .expect("knot init");
462 let block = buildwiggle_block_input_from_knots(
463 seed.view(),
464 &knots,
465 cfg.degree,
466 cfg.penalty_order,
467 cfg.double_penalty,
468 )
469 .expect("build block");
470 let p = block.design.ncols();
471 (block, p)
472 }
473
474 #[test]
475 fn single_penalty_block_shapes_and_invariants() {
476 let (block, p) = build(false, 2);
477 assert!(p >= 2, "expected multiple monotone columns, got p={p}");
478 assert_eq!(block.offset.len(), 40);
480 assert!(block.offset.iter().all(|&v| v == 0.0));
481 let beta = block.initial_beta.as_ref().expect("initial_beta");
483 assert_eq!(beta.len(), p);
484 assert!(beta.iter().all(|&v| v == 0.0));
485 assert_eq!(block.penalties.len(), 1);
487 assert_eq!(block.nullspace_dims.len(), 1);
488 let s = dense_penalty(&block.penalties[0]);
490 assert_eq!(s.dim(), (p, p));
491 assert!(is_symmetric(s));
492 assert_eq!(block.nullspace_dims[0], 1);
495 }
496
497 #[test]
498 fn double_penalty_appends_nullspace_only_function_ridge() {
499 let (block, p) = build(true, 2);
500 assert!(p >= 2);
501 assert_eq!(block.penalties.len(), 2);
504 assert_eq!(block.nullspace_dims.len(), 2);
505 let ridge = dense_penalty(&block.penalties[1]);
506 assert_eq!(ridge.dim(), (p, p));
507 assert!(is_symmetric(ridge));
508 assert!(
509 (0..p).any(|i| (0..p).any(|j| i != j && ridge[[i, j]].abs() > 1e-12)),
510 "function-metric null shrinkage must not collapse to eye(p)"
511 );
512 assert_eq!(block.nullspace_dims[1], 0);
513 }
514
515 #[test]
516 fn order_one_has_no_nullspace_ridge() {
517 let (block, _) = build(true, 1);
518 assert_eq!(block.penalties.len(), 1);
519 assert_eq!(block.nullspace_dims, vec![0]);
520 }
521
522 #[test]
523 fn unsupported_derivative_order_is_rejected_not_clamped() {
524 let seed = Array1::linspace(0.0, 1.0, 40);
525 let knots = initializewiggle_knots_from_seed(seed.view(), 3, 5).expect("knot init");
526 let error = match buildwiggle_block_input_from_knots(seed.view(), &knots, 3, 4, false) {
527 Ok(_) => panic!("order above represented value degree must be rejected"),
528 Err(error) => error,
529 };
530 assert!(error.contains("derivative"), "unexpected error: {error}");
531 }
532
533 #[test]
534 fn explicit_zero_penalty_order_is_rejected() {
535 let error = split_wiggle_penalty_orders(2, &[0, 2]).unwrap_err();
536 assert_eq!(
537 error,
538 "wiggle penalty derivative orders must all be positive"
539 );
540 }
541}