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}