Skip to main content

gam_models/
wiggle.rs

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
25// #1521: relocated DOWN into `gam_terms::basis` (was a gamlss/wiggle helper).
26// The knot-generation primitive carries no model-family type, so family modules
27// and this module's own callers consume it from the basis layer via this
28// re-export — keeping every `crate::wiggle::initializewiggle_knots_from_seed`
29// call site (gamlss / bms / transformation-normal) resolving unchanged.
30pub(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    // Public monotone-wiggle degree refers to the value basis. The low-level
35    // I-spline builder integrates a degree-`internal_degree` specification
36    // into a degree-`internal_degree + 1` value basis, so we subtract one here
37    // to keep the public degree and the per-span value degree aligned.
38    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
164/// Slack tolerance for the `beta >= 0` monotone-wiggle inequality constraints.
165///
166/// The constrained inner Newton/QP holds a binding coordinate at the boundary
167/// only up to its own KKT tolerance, so an accepted step can leave the active
168/// coordinate a few ULPs below zero (e.g. `-2e-9`). That is feasibility within
169/// the solver tolerance, not a genuine sign violation, so the post-update hook
170/// projects such coordinates back onto the non-negative cone (clamps them to
171/// exactly `0`) rather than failing the fit. The band matches the constrained
172/// blockwise solver's KKT tolerances (`1e-6 * scale + 1e-10`,
173/// `1e-10 * (1 + scale)`); anything more negative survives the projection and
174/// is rejected by [`validate_monotone_wiggle_beta_nonnegative`].
175pub(crate) const MONOTONE_WIGGLE_ACTIVE_SET_TOL: f64 = 1e-6;
176
177/// Project a monotone-wiggle coefficient vector onto the non-negative cone the
178/// `beta >= 0` constraints define, clamping coordinates the constrained solve
179/// left slightly negative (within [`MONOTONE_WIGGLE_ACTIVE_SET_TOL`]) to exactly
180/// `0`. Coordinates more negative than the tolerance are left untouched so the
181/// subsequent [`validate_monotone_wiggle_beta_nonnegative`] still rejects
182/// genuine sign violations.
183pub(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
192/// Resolve a requested wiggle penalty-order set into:
193///
194/// - the primary order used by the monotone I-spline coefficient penalty, and
195/// - the remaining plain difference-penalty orders to append on the same basis.
196///
197/// The primary order is the smallest positive requested order. If no positive
198/// order is requested, `fallback_primary` is used instead. Extra orders are
199/// returned in the original order, deduplicated, and exclude the primary order.
200pub 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
220/// Append raw difference penalties for the given orders to an existing block.
221///
222/// These are plain difference penalties `D_k^T D_k` on the monotone I-spline
223/// coefficients, whose nullspace is the set of polynomial sequences of degree
224/// ≤ k−1, giving `nullspace_dim = k`.
225pub 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            // A k-th order difference operator applied to a length-p coefficient
239            // vector produces a (p-k)-row matrix. When p <= k, that operator has
240            // zero rows and `S = Dᵀ D` is the p×p zero matrix; equivalently,
241            // every length-p sequence is a polynomial of degree < k restricted
242            // to the integer grid, so the entire coefficient space is in the
243            // penalty's null space. Append that degenerate-but-mathematically-
244            // consistent penalty rather than silently dropping the user's
245            // request — silently discarding requested penalty orders hides
246            // misconfiguration and changes the model the caller asked for.
247            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    // ---- monotone_wiggle_internal_degree ----
315
316    #[test]
317    fn internal_degree_rejects_degree_below_two() {
318        // degree 0 and 1 yield internal_degree < 1 -> Err with the documented message.
319        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        // degree >= 2 -> Ok(degree - 1): the per-span value degree aligned to the
328        // public value-basis degree.
329        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    // ---- buildwiggle_block_input_from_knots (driven via seed for valid knots) ----
335
336    fn build(double_penalty: bool, penalty_order: usize) -> (ParameterBlockInput, usize) {
337        // A spread-out seed so knot generation yields several monotone columns.
338        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        // Offset is zeros with length = seed length.
364        assert_eq!(block.offset.len(), 40);
365        assert!(block.offset.iter().all(|&v| v == 0.0));
366        // initial_beta is Some(zeros(p)).
367        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        // Without double penalty there is exactly one penalty.
371        assert_eq!(block.penalties.len(), 1);
372        assert_eq!(block.nullspace_dims.len(), 1);
373        // The penalty is the p x p difference penalty; symmetric (S = Dᵀ D).
374        let s = dense_penalty(&block.penalties[0]);
375        assert_eq!(s.dim(), (p, p));
376        assert!(is_symmetric(s));
377        // effective_order = penalty_order.max(1).min(p-1); here 2 (<= p-1 since p>=3
378        // for this seed). nullspace_dim equals the effective difference order.
379        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        // double_penalty -> two penalties: difference penalty then p x p identity.
388        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        // Identity: diagonal ones, off-diagonal zeros.
393        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        // The appended identity is full rank, so its nullspace dim is 0.
400        assert_eq!(block.nullspace_dims[1], 0);
401    }
402
403    #[test]
404    fn penalty_order_clamped_to_p_minus_one() {
405        // Requesting an absurdly large penalty order clamps effective_order to p-1
406        // (still a valid difference penalty), per `penalty_order.max(1).min(p-1)`.
407        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}