1use gam_terms::basis::{
2 BasisOptions, Dense, KnotSource, create_basis, create_difference_penalty_matrix,
3 create_ispline_derivative_dense,
4};
5use crate::parameter_block::ParameterBlockInput;
6use gam_linalg::matrix::{DenseDesignMatrix, DesignMatrix};
7use gam_solve::pirls::LinearInequalityConstraints;
8use ndarray::{Array1, Array2, ArrayView1};
9
10#[derive(Clone, Debug)]
11pub struct WiggleBlockConfig {
12 pub degree: usize,
13 pub num_internal_knots: usize,
14 pub penalty_order: usize,
15 pub double_penalty: bool,
16}
17
18#[derive(Clone)]
19pub(crate) struct SelectedWiggleBasis {
20 pub knots: Array1<f64>,
21 pub degree: usize,
22 pub block: ParameterBlockInput,
23}
24
25pub(crate) use gam_terms::basis::initializewiggle_knots_from_seed;
31
32#[inline]
33pub(crate) fn monotone_wiggle_internal_degree(degree: usize) -> Result<usize, String> {
34 degree
39 .checked_sub(1)
40 .filter(|&internal_degree| internal_degree >= 1)
41 .ok_or_else(|| "monotone wiggle degree must be >= 2".to_string())
42}
43
44pub fn buildwiggle_block_input_from_knots(
45 seed: ArrayView1<'_, f64>,
46 knots: &Array1<f64>,
47 degree: usize,
48 penalty_order: usize,
49 double_penalty: bool,
50) -> Result<ParameterBlockInput, String> {
51 let design = monotone_wiggle_basis_from_knots(seed, knots, degree)?;
52 let p = design.ncols();
53 if p == 0 {
54 return Err("wiggle basis has no free monotone columns".to_string());
55 }
56 let mut penalties: Vec<crate::model_types::PenaltySpec> = Vec::new();
57 let mut nullspace_dims = Vec::new();
58 if p == 1 {
59 penalties.push(crate::model_types::PenaltySpec::Dense(Array2::<f64>::eye(
60 1,
61 )));
62 nullspace_dims.push(0);
63 } else {
64 let effective_order = penalty_order.max(1).min(p - 1);
65 let diff_penalty = create_difference_penalty_matrix(p, effective_order, None)
66 .map_err(|e| e.to_string())?;
67 penalties.push(crate::model_types::PenaltySpec::Dense(diff_penalty));
68 nullspace_dims.push(effective_order);
69 }
70 if double_penalty {
71 penalties.push(crate::model_types::PenaltySpec::Dense(Array2::<f64>::eye(
72 p,
73 )));
74 nullspace_dims.push(0);
75 }
76 Ok(ParameterBlockInput {
77 design: DesignMatrix::Dense(DenseDesignMatrix::from(design)),
78 offset: Array1::zeros(seed.len()),
79 penalties,
80 nullspace_dims,
81 initial_log_lambdas: None,
82 initial_beta: Some(Array1::zeros(p)),
83 })
84}
85
86pub fn buildwiggle_block_input_from_seed(
87 seed: ArrayView1<'_, f64>,
88 cfg: &WiggleBlockConfig,
89) -> Result<(ParameterBlockInput, Array1<f64>), String> {
90 let knots = initializewiggle_knots_from_seed(seed, cfg.degree, cfg.num_internal_knots)?;
91 let block = buildwiggle_block_input_from_knots(
92 seed,
93 &knots,
94 cfg.degree,
95 cfg.penalty_order,
96 cfg.double_penalty,
97 )?;
98 Ok((block, knots))
99}
100
101pub(crate) fn monotone_wiggle_basis_from_knots(
102 seed: ArrayView1<'_, f64>,
103 knots: &Array1<f64>,
104 degree: usize,
105) -> Result<Array2<f64>, String> {
106 let internal_degree = monotone_wiggle_internal_degree(degree)?;
107 let (basis, _) = create_basis::<Dense>(
108 seed,
109 KnotSource::Provided(knots.view()),
110 internal_degree,
111 BasisOptions::i_spline(),
112 )
113 .map_err(|e| e.to_string())?;
114 Ok(basis.as_ref().clone())
115}
116
117pub fn monotone_wiggle_basis_with_derivative_order(
118 seed: ArrayView1<'_, f64>,
119 knots: &Array1<f64>,
120 degree: usize,
121 derivative_order: usize,
122) -> Result<Array2<f64>, String> {
123 if derivative_order == 0 {
124 return monotone_wiggle_basis_from_knots(seed, knots, degree);
125 }
126 let internal_degree = monotone_wiggle_internal_degree(degree)?;
127 create_ispline_derivative_dense(seed, knots, internal_degree, derivative_order)
128 .map_err(|e| e.to_string())
129}
130
131pub(crate) fn monotone_wiggle_nonnegative_constraints(
132 beta_dim: usize,
133) -> Option<LinearInequalityConstraints> {
134 if beta_dim == 0 {
135 return None;
136 }
137 let mut a = Array2::<f64>::zeros((beta_dim, beta_dim));
138 for i in 0..beta_dim {
139 a[[i, i]] = 1.0;
140 }
141 Some(LinearInequalityConstraints {
142 a,
143 b: Array1::zeros(beta_dim),
144 })
145}
146
147pub(crate) fn validate_monotone_wiggle_beta_nonnegative<'a>(
148 beta: impl IntoIterator<Item = &'a f64>,
149 context: &str,
150) -> Result<(), String> {
151 for (idx, &value) in beta.into_iter().enumerate() {
152 if !value.is_finite() {
153 return Err(format!("{context} coefficient {idx} is non-finite"));
154 }
155 if value < -1e-12 {
156 return Err(format!(
157 "{context} coefficient {idx} is negative ({value:.3e}); monotone wiggle coefficients must be non-negative"
158 ));
159 }
160 }
161 Ok(())
162}
163
164pub(crate) const MONOTONE_WIGGLE_ACTIVE_SET_TOL: f64 = 1e-6;
176
177pub(crate) fn project_monotone_wiggle_beta_nonnegative(mut beta: Array1<f64>) -> Array1<f64> {
184 for value in beta.iter_mut() {
185 if *value < 0.0 && *value >= -MONOTONE_WIGGLE_ACTIVE_SET_TOL {
186 *value = 0.0;
187 }
188 }
189 beta
190}
191
192pub fn split_wiggle_penalty_orders(
201 fallback_primary: usize,
202 penalty_orders: &[usize],
203) -> (usize, Vec<usize>) {
204 let primary_order = penalty_orders
205 .iter()
206 .copied()
207 .filter(|&order| order >= 1)
208 .min()
209 .unwrap_or_else(|| fallback_primary.max(1));
210 let mut extras = Vec::new();
211 for &order in penalty_orders {
212 if order == 0 || order == primary_order || extras.contains(&order) {
213 continue;
214 }
215 extras.push(order);
216 }
217 (primary_order, extras)
218}
219
220pub fn append_selected_wiggle_penalty_orders(
226 block: &mut ParameterBlockInput,
227 penalty_orders: &[usize],
228) -> Result<(), String> {
229 let p = block.design.ncols();
230 if p == 0 {
231 return Ok(());
232 }
233 for &order in penalty_orders {
234 if order == 0 {
235 continue;
236 }
237 if order >= p {
238 let zero_penalty = ndarray::Array2::<f64>::zeros((p, p));
248 block
249 .penalties
250 .push(crate::model_types::PenaltySpec::Dense(zero_penalty));
251 block.nullspace_dims.push(p);
252 continue;
253 }
254 let penalty =
255 create_difference_penalty_matrix(p, order, None).map_err(|e| e.to_string())?;
256 block
257 .penalties
258 .push(crate::model_types::PenaltySpec::Dense(penalty));
259 block.nullspace_dims.push(order);
260 }
261 Ok(())
262}
263
264pub(crate) fn select_wiggle_basis_from_seed(
265 seed: ArrayView1<'_, f64>,
266 cfg: &WiggleBlockConfig,
267 penalty_orders: &[usize],
268) -> Result<SelectedWiggleBasis, String> {
269 let (primary_order, extra_orders) =
270 split_wiggle_penalty_orders(cfg.penalty_order, penalty_orders);
271 let effective_cfg = WiggleBlockConfig {
272 degree: cfg.degree,
273 num_internal_knots: cfg.num_internal_knots,
274 penalty_order: primary_order,
275 double_penalty: cfg.double_penalty,
276 };
277 let (mut block, knots) = buildwiggle_block_input_from_seed(seed, &effective_cfg)?;
278 append_selected_wiggle_penalty_orders(&mut block, &extra_orders)?;
279 Ok(SelectedWiggleBasis {
280 knots,
281 degree: cfg.degree,
282 block,
283 })
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use crate::model_types::PenaltySpec;
290 use ndarray::Array1;
291
292 fn dense_penalty(spec: &PenaltySpec) -> &Array2<f64> {
293 match spec {
294 PenaltySpec::Dense(m) => m,
295 other => panic!("expected Dense penalty, got {other:?}"),
296 }
297 }
298
299 fn is_symmetric(m: &Array2<f64>) -> bool {
300 let n = m.nrows();
301 if m.ncols() != n {
302 return false;
303 }
304 for i in 0..n {
305 for j in 0..n {
306 if (m[[i, j]] - m[[j, i]]).abs() > 1e-12 {
307 return false;
308 }
309 }
310 }
311 true
312 }
313
314 #[test]
317 fn internal_degree_rejects_degree_below_two() {
318 for d in [0usize, 1] {
320 let err = monotone_wiggle_internal_degree(d).unwrap_err();
321 assert_eq!(err, "monotone wiggle degree must be >= 2");
322 }
323 }
324
325 #[test]
326 fn internal_degree_is_degree_minus_one_for_valid_degrees() {
327 assert_eq!(monotone_wiggle_internal_degree(2).unwrap(), 1);
330 assert_eq!(monotone_wiggle_internal_degree(3).unwrap(), 2);
331 assert_eq!(monotone_wiggle_internal_degree(10).unwrap(), 9);
332 }
333
334 fn build(double_penalty: bool, penalty_order: usize) -> (ParameterBlockInput, usize) {
337 let seed = Array1::linspace(0.0, 1.0, 40);
339 let cfg = WiggleBlockConfig {
340 degree: 3,
341 num_internal_knots: 5,
342 penalty_order,
343 double_penalty,
344 };
345 let knots = initializewiggle_knots_from_seed(seed.view(), cfg.degree, cfg.num_internal_knots)
346 .expect("knot init");
347 let block = buildwiggle_block_input_from_knots(
348 seed.view(),
349 &knots,
350 cfg.degree,
351 cfg.penalty_order,
352 cfg.double_penalty,
353 )
354 .expect("build block");
355 let p = block.design.ncols();
356 (block, p)
357 }
358
359 #[test]
360 fn single_penalty_block_shapes_and_invariants() {
361 let (block, p) = build(false, 2);
362 assert!(p >= 2, "expected multiple monotone columns, got p={p}");
363 assert_eq!(block.offset.len(), 40);
365 assert!(block.offset.iter().all(|&v| v == 0.0));
366 let beta = block.initial_beta.as_ref().expect("initial_beta");
368 assert_eq!(beta.len(), p);
369 assert!(beta.iter().all(|&v| v == 0.0));
370 assert_eq!(block.penalties.len(), 1);
372 assert_eq!(block.nullspace_dims.len(), 1);
373 let s = dense_penalty(&block.penalties[0]);
375 assert_eq!(s.dim(), (p, p));
376 assert!(is_symmetric(s));
377 let effective = 2usize.max(1).min(p - 1);
380 assert_eq!(block.nullspace_dims[0], effective);
381 }
382
383 #[test]
384 fn double_penalty_appends_identity_ridge() {
385 let (block, p) = build(true, 2);
386 assert!(p >= 2);
387 assert_eq!(block.penalties.len(), 2);
389 assert_eq!(block.nullspace_dims.len(), 2);
390 let ridge = dense_penalty(&block.penalties[1]);
391 assert_eq!(ridge.dim(), (p, p));
392 for i in 0..p {
394 for j in 0..p {
395 let expected = if i == j { 1.0 } else { 0.0 };
396 assert_eq!(ridge[[i, j]], expected);
397 }
398 }
399 assert_eq!(block.nullspace_dims[1], 0);
401 }
402
403 #[test]
404 fn penalty_order_clamped_to_p_minus_one() {
405 let (block, p) = build(false, 10_000);
408 assert!(p >= 2);
409 let s = dense_penalty(&block.penalties[0]);
410 assert_eq!(s.dim(), (p, p));
411 assert!(is_symmetric(s));
412 assert_eq!(block.nullspace_dims[0], p - 1);
413 }
414}