Skip to main content

gam_models/
wiggle.rs

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/// Semantic identity of one canonical I-spline penalty block.
20///
21/// The order of these values is the smoothing-parameter order. Persisting the
22/// topology prevents inference code from guessing a derivative order from a
23/// lambda index or inventing a zero block when the guess is invalid.
24#[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/// Complete semantic description of a realized monotone-wiggle penalty list.
32///
33/// `derivative_orders` is already canonicalized into the exact roughness-block
34/// order used by fitting: primary first, followed by deduplicated additional
35/// orders. `blocks` additionally records whether the primary roughness emitted
36/// a function-metric nullspace shrinkage coordinate. For example, an order-one
37/// anchored I-spline roughness is full rank, so `double_penalty=true` emits no
38/// synthetic ridge block.
39#[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/// Exact matrices and nullities accompanying [`WigglePenaltyMetadata`].
47#[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
62// #1521: relocated DOWN into `gam_terms::basis` (was a gamlss/wiggle helper).
63// The knot-generation primitive carries no model-family type, so family modules
64// and this module's own callers consume it from the basis layer via this
65// re-export — keeping every `crate::wiggle::initializewiggle_knots_from_seed`
66// call site (gamlss / bms / transformation-normal) resolving unchanged.
67pub(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    // Public monotone-wiggle degree refers to the value basis. The low-level
72    // I-spline builder integrates a degree-`internal_degree` specification
73    // into a degree-`internal_degree + 1` value basis, so we subtract one here
74    // to keep the public degree and the per-span value degree aligned.
75    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
81/// Build the exact ordered function-space penalty set for an anchored
82/// I-spline monotone wiggle.
83///
84/// `derivative_orders` must already be in the fitting order and contain no
85/// duplicates. The first order is the primary roughness; only its structural
86/// null space is eligible for the separate double-penalty coordinate. Every
87/// matrix comes from the canonical `C^T S_B C` function Gram, never a
88/// coefficient difference or identity metric.
89pub 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
276/// Slack tolerance for the `beta >= 0` monotone-wiggle inequality constraints.
277///
278/// The constrained inner Newton/QP holds a binding coordinate at the boundary
279/// only up to its own KKT tolerance, so an accepted step can leave the active
280/// coordinate a few ULPs below zero (e.g. `-2e-9`). That is feasibility within
281/// the solver tolerance, not a genuine sign violation, so the post-update hook
282/// projects such coordinates back onto the non-negative cone (clamps them to
283/// exactly `0`) rather than failing the fit. The band matches the constrained
284/// blockwise solver's KKT tolerances (`1e-6 * scale + 1e-10`,
285/// `1e-10 * (1 + scale)`); anything more negative survives the projection and
286/// is rejected by [`validate_monotone_wiggle_beta_nonnegative`].
287pub(crate) const MONOTONE_WIGGLE_ACTIVE_SET_TOL: f64 = 1e-6;
288
289/// Project a monotone-wiggle coefficient vector onto the non-negative cone the
290/// `beta >= 0` constraints define, clamping coordinates the constrained solve
291/// left slightly negative (within [`MONOTONE_WIGGLE_ACTIVE_SET_TOL`]) to exactly
292/// `0`. Coordinates more negative than the tolerance are left untouched so the
293/// subsequent [`validate_monotone_wiggle_beta_nonnegative`] still rejects
294/// genuine sign violations.
295pub(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
304/// Resolve a requested wiggle penalty-order set into:
305///
306/// - the primary derivative order used by the monotone I-spline function
307///   roughness, and
308/// - the remaining function-derivative orders to append on the same basis.
309///
310/// The primary order is the smallest requested order. If the list is empty,
311/// `default_primary` is used. Zero is never silently dropped: it is not a
312/// roughness derivative and is therefore a typed configuration error. Extra
313/// orders are returned in original order, deduplicated, and exclude primary.
314pub 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
339/// Append exact function-derivative roughness penalties for the requested
340/// orders to an existing monotone I-spline block.
341pub 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    // ---- monotone_wiggle_internal_degree ----
429
430    #[test]
431    fn internal_degree_rejects_degree_below_two() {
432        // degree 0 and 1 yield internal_degree < 1 -> Err with the documented message.
433        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        // degree >= 2 -> Ok(degree - 1): the per-span value degree aligned to the
442        // public value-basis degree.
443        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    // ---- buildwiggle_block_input_from_knots (driven via seed for valid knots) ----
449
450    fn build(double_penalty: bool, penalty_order: usize) -> (ParameterBlockInput, usize) {
451        // A spread-out seed so knot generation yields several monotone columns.
452        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        // Offset is zeros with length = seed length.
479        assert_eq!(block.offset.len(), 40);
480        assert!(block.offset.iter().all(|&v| v == 0.0));
481        // initial_beta is Some(zeros(p)).
482        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        // Without double penalty there is exactly one penalty.
486        assert_eq!(block.penalties.len(), 1);
487        assert_eq!(block.nullspace_dims.len(), 1);
488        // The exact function-derivative Gram is p x p and symmetric.
489        let s = dense_penalty(&block.penalties[0]);
490        assert_eq!(s.dim(), (p, p));
491        assert!(is_symmetric(s));
492        // The anchored I-spline excludes the constant polynomial, so the
493        // order-two derivative null space contains only the linear direction.
494        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        // Order two has one structural null direction, so double penalty emits
502        // one separate function-space shrinkage block.
503        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}