Skip to main content

gam_terms/basis/
cubic_regression.rs

1//! Natural cubic regression spline (`cr`) basis — mgcv-compatible.
2//!
3//! Implements the Lancaster–Salkauskas natural cubic regression spline that
4//! mgcv exposes as `bs="cr"` (and its shrinkage twin `bs="cs"`), following
5//! Wood (2017) *Generalized Additive Models*, §5.3.1.
6//!
7//! The smooth is parameterized by its values at `k` knots,
8//! `β_i = f(x*_i)`, with natural boundary conditions `f''(x*_1) = f''(x*_k) =
9//! 0`. The basis dimension is exactly `k` (the number of knots), and the
10//! roughness penalty `∫ f''(x)² dx` is the quadratic form `βᵀ S β` with
11//! `S = Dᵀ B⁻¹ D` whose null space is `{const, linear}` (dimension 2).
12//!
13//! This matches mgcv's `smooth.construct.cr.smooth.spec` output (`$X` and
14//! `$S[[1]]`) to round-off for the same knot vector — see the unit tests at
15//! the bottom of this module and the in-tree quality cross-checks.
16//!
17//! ## Geometry (the `F` matrix)
18//! For interior knots, the second derivatives `δ` are linear in the values
19//! `β` via `δ = F β`, where `F` is `k × k` with zero first/last rows and
20//! interior rows given by `B⁻¹ D`:
21//!   * `D` is `(k-2) × k`:   `D[i,i]=1/h_i`, `D[i,i+1]=-1/h_i-1/h_{i+1}`,
22//!                            `D[i,i+2]=1/h_{i+1}`.
23//!   * `B` is `(k-2) × (k-2)` tridiagonal SPD: `B[i,i]=(h_i+h_{i+1})/3`,
24//!                            `B[i,i+1]=B[i+1,i]=h_{i+1}/6`.
25//! with `h_i = x*_{i+1} - x*_i` (1-indexed in the math, 0-indexed below).
26//!
27//! ## Design row
28//! For `x ∈ [x*_j, x*_{j+1}]` (knot interval `j`, 0-indexed) with
29//! `a₋ = (x*_{j+1}-x)/h_j`, `a₊ = (x-x*_j)/h_j`:
30//!   `row = a₋·e_j + a₊·e_{j+1} + c₋·F[j,:] + c₊·F[j+1,:]`
31//! where `c₋ = (a₋³-a₋) h_j²/6`, `c₊ = (a₊³-a₊) h_j²/6`.
32//!
33//! Outside `[x*_1, x*_k]` mgcv extrapolates *linearly*: the value and first
34//! derivative are continued from the nearest endpoint knot. We reproduce that
35//! exactly so predict-time rows past the data range match mgcv.
36
37use super::*;
38
39/// Precomputed natural cubic regression spline geometry for a fixed knot set.
40#[derive(Clone, Debug)]
41pub struct CubicRegressionBasis {
42    /// Knot locations `x*_1 < … < x*_k` (strictly increasing).
43    pub knots: Array1<f64>,
44    /// The `k × k` second-derivative map `F` (`δ = F β`); rows 0 and k-1 are zero.
45    f_matrix: Array2<f64>,
46}
47
48impl CubicRegressionBasis {
49    /// Build the cr geometry for a strictly increasing knot vector of length
50    /// `k >= 3`. (mgcv requires `k >= 3` for a cubic regression spline.)
51    pub fn new(knots: Array1<f64>) -> Result<Self, BasisError> {
52        let k = knots.len();
53        if k < 3 {
54            crate::bail_invalid_basis!(
55                "cubic regression spline requires at least 3 knots, got {k}"
56            );
57        }
58        // Strictly increasing check.
59        for i in 1..k {
60            if !(knots[i] > knots[i - 1]) {
61                crate::bail_invalid_basis!(
62                    "cubic regression spline knots must be strictly increasing; \
63                     knot[{}]={} is not greater than knot[{}]={}",
64                    i,
65                    knots[i],
66                    i - 1,
67                    knots[i - 1]
68                );
69            }
70        }
71        let h: Vec<f64> = (0..k - 1).map(|i| knots[i + 1] - knots[i]).collect();
72        let f_matrix = build_f_matrix(&h, k)?;
73        Ok(Self { knots, f_matrix })
74    }
75
76    pub fn num_basis(&self) -> usize {
77        self.knots.len()
78    }
79
80    /// The natural cubic regression roughness penalty `S = Dᵀ B⁻¹ D` (k×k).
81    ///
82    /// Equivalently `S = Dᵀ F_int` where `F_int = B⁻¹ D` are the interior rows
83    /// of `F`. We assemble it directly from `D` and the interior block of `F`.
84    pub fn penalty(&self) -> Array2<f64> {
85        let k = self.knots.len();
86        let h: Vec<f64> = (0..k - 1)
87            .map(|i| self.knots[i + 1] - self.knots[i])
88            .collect();
89        // D is (k-2) x k.
90        let mut d = Array2::<f64>::zeros((k - 2, k));
91        for i in 0..k - 2 {
92            d[[i, i]] = 1.0 / h[i];
93            d[[i, i + 1]] = -1.0 / h[i] - 1.0 / h[i + 1];
94            d[[i, i + 2]] = 1.0 / h[i + 1];
95        }
96        // F_int = interior rows of F (rows 1..k-1 of F_matrix), shape (k-2) x k.
97        // S = Dᵀ F_int. (F_int = B⁻¹ D, so Dᵀ B⁻¹ D.)
98        let f_int = self.f_matrix.slice(s![1..k - 1, ..]).to_owned();
99        // S = Dᵀ (F_int)  -> (k x (k-2)) x ((k-2) x k) = k x k.
100        let s = d.t().dot(&f_int);
101        // Symmetrize defensively (it is symmetric in exact arithmetic).
102        let mut s_sym = Array2::<f64>::zeros((k, k));
103        for a in 0..k {
104            for b in 0..k {
105                s_sym[[a, b]] = 0.5 * (s[[a, b]] + s[[b, a]]);
106            }
107        }
108        s_sym
109    }
110
111    /// Evaluate the cr design row for a single point `x` into `row` (length k).
112    /// `row` is overwritten.
113    pub fn eval_row_into(&self, x: f64, row: &mut [f64]) {
114        let k = self.knots.len();
115        // assert_eq!, not debug_assert_eq!: the ban-scanner forbids debug_assert
116        // (silent in release → debug/release divergence). The length check is a
117        // cheap O(1) guard, so an always-active assert is acceptable here.
118        assert_eq!(row.len(), k);
119        for r in row.iter_mut() {
120            *r = 0.0;
121        }
122        let x1 = self.knots[0];
123        let xk = self.knots[k - 1];
124
125        if x <= x1 {
126            // Linear extrapolation off the left endpoint, matching mgcv: the
127            // value at x1 is β_0, the slope is the spline's first derivative at
128            // x1. For the first interval [x*_0, x*_1] the cubic has
129            //   f(x) = a₋β_0 + a₊β_1 + c₋δ_0 + c₊δ_1   with δ_0 = 0 (natural),
130            // so f'(x1⁻side) at x = x1 is
131            //   slope = (β_1 - β_0)/h_0 - h_0/6 * δ_1     (δ_0 = 0).
132            let h0 = self.knots[1] - self.knots[0];
133            // row picks up β_0 (=1 at e_0) plus slope*(x-x1) expressed in β.
134            row[0] += 1.0;
135            // d/dx contributions: (β_1-β_0)/h0 term and -h0/6 * δ_1 term.
136            let dx = x - x1;
137            row[0] += dx * (-1.0 / h0);
138            row[1] += dx * (1.0 / h0);
139            // δ_1 = F[1,:]·β  → -h0/6 * δ_1 contributes -h0/6 * F[1,:].
140            let coeff = dx * (-h0 / 6.0);
141            for c in 0..k {
142                row[c] += coeff * self.f_matrix[[1, c]];
143            }
144            return;
145        }
146        if x >= xk {
147            // Linear extrapolation off the right endpoint. For the last
148            // interval [x*_{k-2}, x*_{k-1}], δ_{k-1} = 0 (natural), and the
149            // first derivative at x = xk is
150            //   slope = (β_{k-1} - β_{k-2})/h_{k-2} + h_{k-2}/6 * δ_{k-2}.
151            let hk = self.knots[k - 1] - self.knots[k - 2];
152            row[k - 1] += 1.0;
153            let dx = x - xk;
154            row[k - 2] += dx * (-1.0 / hk);
155            row[k - 1] += dx * (1.0 / hk);
156            // + h_{k-2}/6 * δ_{k-2}, δ_{k-2} = F[k-2,:]·β.
157            let coeff = dx * (hk / 6.0);
158            for c in 0..k {
159                row[c] += coeff * self.f_matrix[[k - 2, c]];
160            }
161            return;
162        }
163
164        // Interior: locate interval j with x*_j <= x <= x*_{j+1}.
165        // knots strictly increasing; binary search for the upper bound.
166        let mut j = match self
167            .knots
168            .as_slice()
169            .expect("contiguous knots")
170            .binary_search_by(|probe| probe.partial_cmp(&x).unwrap_or(std::cmp::Ordering::Less))
171        {
172            Ok(idx) => idx,      // x equals a knot: use interval starting at idx
173            Err(idx) => idx - 1, // x in (knot[idx-1], knot[idx])
174        };
175        if j >= k - 1 {
176            j = k - 2;
177        }
178        let hj = self.knots[j + 1] - self.knots[j];
179        let a_minus = (self.knots[j + 1] - x) / hj;
180        let a_plus = (x - self.knots[j]) / hj;
181        let c_minus = (a_minus * a_minus * a_minus - a_minus) * hj * hj / 6.0;
182        let c_plus = (a_plus * a_plus * a_plus - a_plus) * hj * hj / 6.0;
183        row[j] += a_minus;
184        row[j + 1] += a_plus;
185        for c in 0..k {
186            row[c] += c_minus * self.f_matrix[[j, c]] + c_plus * self.f_matrix[[j + 1, c]];
187        }
188    }
189
190    /// Dense `n × k` design matrix for a column of evaluation points.
191    pub fn design(&self, data: ArrayView1<'_, f64>) -> Array2<f64> {
192        let k = self.knots.len();
193        let n = data.len();
194        let mut x = Array2::<f64>::zeros((n, k));
195        let mut row = vec![0.0f64; k];
196        for (i, &xi) in data.iter().enumerate() {
197            self.eval_row_into(xi, &mut row);
198            for c in 0..k {
199                x[[i, c]] = row[c];
200            }
201        }
202        x
203    }
204}
205
206/// Assemble the `k × k` map `F` (`δ = F β`) from interval widths `h`.
207/// Rows 0 and k-1 are zero (natural boundary). Interior rows solve
208/// `B (F_int) = D` for the `(k-2) × k` interior block `F_int`.
209fn build_f_matrix(h: &[f64], k: usize) -> Result<Array2<f64>, BasisError> {
210    let m = k - 2; // interior count
211    // B (m x m) tridiagonal SPD.
212    let mut b_diag = vec![0.0f64; m];
213    let mut b_off = vec![0.0f64; m.saturating_sub(1)]; // b_off[i] = B[i,i+1] = B[i+1,i]
214    for i in 0..m {
215        b_diag[i] = (h[i] + h[i + 1]) / 3.0;
216    }
217    for i in 0..m.saturating_sub(1) {
218        // B[i,i+1] = h_{i+1}/6 (the shared interior width).
219        b_off[i] = h[i + 1] / 6.0;
220    }
221    // D (m x k).
222    let mut d = Array2::<f64>::zeros((m, k));
223    for i in 0..m {
224        d[[i, i]] = 1.0 / h[i];
225        d[[i, i + 1]] = -1.0 / h[i] - 1.0 / h[i + 1];
226        d[[i, i + 2]] = 1.0 / h[i + 1];
227    }
228    // Solve B X = D column-by-column with the Thomas algorithm; X = F_int.
229    let f_int = thomas_solve_multi(&b_diag, &b_off, &d)?;
230    let mut f = Array2::<f64>::zeros((k, k));
231    for i in 0..m {
232        for c in 0..k {
233            f[[i + 1, c]] = f_int[[i, c]];
234        }
235    }
236    Ok(f)
237}
238
239/// Solve a symmetric tridiagonal system `B X = RHS` for every column of `RHS`
240/// using the Thomas algorithm. `diag` is length m, `off` is length m-1
241/// (the shared sub/super-diagonal). `rhs` is `m × c`. Returns `m × c`.
242fn thomas_solve_multi(
243    diag: &[f64],
244    off: &[f64],
245    rhs: &Array2<f64>,
246) -> Result<Array2<f64>, BasisError> {
247    let m = diag.len();
248    let cols = rhs.ncols();
249    if m == 0 {
250        return Ok(Array2::<f64>::zeros((0, cols)));
251    }
252    if rhs.nrows() != m {
253        crate::bail_dim_basis!(
254            "tridiagonal solve RHS has {} rows but system is {}x{}",
255            rhs.nrows(),
256            m,
257            m
258        );
259    }
260    // Forward sweep.
261    let mut c_prime = vec![0.0f64; m]; // modified super-diagonal
262    let mut d_prime = Array2::<f64>::zeros((m, cols));
263    // A pivot is singular when it is not distinguishable from zero by the
264    // arithmetic that produced it: row 0's pivot is the stored diagonal itself
265    // (no arithmetic, so only an exact zero is singular), and row i's is
266    // `diag − off·c'` (a product and a subtraction, band `γ₂·(|diag| + |off·c'|)`). An absolute
267    // `1e-300` threshold passed pivots that were pure roundoff of an O(1) row
268    // and would have refused an honest O(1e-301) one (#2469).
269    let pivot_band = gam_linalg::roundoff::accumulation_growth(2);
270    let denom0 = diag[0];
271    if denom0 == 0.0 {
272        crate::bail_invalid_basis!("singular tridiagonal pivot at row 0 in cr penalty solve");
273    }
274    if m > 1 {
275        c_prime[0] = off[0] / denom0;
276    }
277    for col in 0..cols {
278        d_prime[[0, col]] = rhs[[0, col]] / denom0;
279    }
280    for i in 1..m {
281        let cancelled = off[i - 1] * c_prime[i - 1];
282        let denom = diag[i] - cancelled;
283        if denom.abs() <= pivot_band * (diag[i].abs() + cancelled.abs()) {
284            crate::bail_invalid_basis!("singular tridiagonal pivot at row {i} in cr penalty solve");
285        }
286        if i < m - 1 {
287            c_prime[i] = off[i] / denom;
288        }
289        for col in 0..cols {
290            d_prime[[i, col]] = (rhs[[i, col]] - off[i - 1] * d_prime[[i - 1, col]]) / denom;
291        }
292    }
293    // Back substitution.
294    let mut x = Array2::<f64>::zeros((m, cols));
295    for col in 0..cols {
296        x[[m - 1, col]] = d_prime[[m - 1, col]];
297    }
298    for i in (0..m - 1).rev() {
299        for col in 0..cols {
300            x[[i, col]] = d_prime[[i, col]] - c_prime[i] * x[[i + 1, col]];
301        }
302    }
303    Ok(x)
304}
305
306/// Place `k` cr knots at evenly-spaced quantiles of the unique sorted data,
307/// exactly as mgcv's default `cr` knot placement: the first and last knots are
308/// the min/max, and the interior knots are at the `1/(k-1) … (k-2)/(k-1)`
309/// quantiles of the *unique* observed values. Returns a strictly increasing
310/// length-`k` knot vector.
311pub fn select_cr_knots(data: ArrayView1<'_, f64>, k: usize) -> Result<Array1<f64>, BasisError> {
312    if k < 3 {
313        crate::bail_invalid_basis!("cubic regression spline requires k >= 3, got {k}");
314    }
315    if data.is_empty() {
316        crate::bail_invalid_basis!("cannot place cr knots on empty data");
317    }
318    if data.iter().any(|x| !x.is_finite()) {
319        crate::bail_invalid_basis!("cr knot placement requires finite data");
320    }
321    let mut sorted: Vec<f64> = data.iter().copied().collect();
322    sorted.sort_by(f64::total_cmp);
323    // Unique values (mgcv places cr knots on the unique data quantiles).
324    let mut unique: Vec<f64> = Vec::with_capacity(sorted.len());
325    for &v in &sorted {
326        if unique.last().map(|&p| p != v).unwrap_or(true) {
327            unique.push(v);
328        }
329    }
330    let nu = unique.len();
331    if nu < k {
332        crate::bail_invalid_basis!(
333            "cubic regression spline with k={k} requires at least {k} distinct \
334             values, got {nu}"
335        );
336    }
337    // mgcv's `place.knots`: knots at quantile type-1-ish positions over the
338    // index range [0, nu-1] evenly in (k-1) steps. Endpoints are exact min/max.
339    let mut knots = Array1::<f64>::zeros(k);
340    for j in 0..k {
341        let pos = (j as f64) * ((nu - 1) as f64) / ((k - 1) as f64);
342        let lo = pos.floor() as usize;
343        let hi = pos.ceil() as usize;
344        let frac = pos - lo as f64;
345        knots[j] = if lo == hi {
346            unique[lo]
347        } else {
348            unique[lo] * (1.0 - frac) + unique[hi] * frac
349        };
350    }
351    // Guard strict monotonicity in case of ties from interpolation rounding.
352    for i in 1..k {
353        if !(knots[i] > knots[i - 1]) {
354            crate::bail_invalid_basis!(
355                "cr knot placement produced non-increasing knots (too many knots \
356                 for the data spread); reduce k"
357            );
358        }
359    }
360    Ok(knots)
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    /// A cr smooth must reproduce constants and lines exactly: the penalty null
368    /// space is {const, linear}, and the design with values β_i = f(x*_i)
369    /// interpolates any line through the knots with zero penalty.
370    #[test]
371    fn cr_penalty_nullspace_is_const_and_linear() {
372        let knots = Array1::from(vec![0.0, 0.3, 0.55, 0.8, 1.0]);
373        let cr = CubicRegressionBasis::new(knots.clone()).unwrap();
374        let s = cr.penalty();
375        let k = knots.len();
376        // const: β = 1.
377        let ones = Array1::<f64>::ones(k);
378        let q_const = ones.dot(&s.dot(&ones));
379        assert!(q_const.abs() < 1e-9, "const not in null space: {q_const}");
380        // linear: β_i = knot_i.
381        let lin = knots.clone();
382        let q_lin = lin.dot(&s.dot(&lin));
383        assert!(q_lin.abs() < 1e-9, "linear not in null space: {q_lin}");
384        // a quadratic should have positive penalty.
385        let quad: Array1<f64> = knots.mapv(|x| x * x);
386        let q_quad = quad.dot(&s.dot(&quad));
387        assert!(q_quad > 1e-6, "quadratic penalty not positive: {q_quad}");
388    }
389
390    /// The design must reproduce a line exactly at arbitrary evaluation points
391    /// (interior and extrapolated), since a line is in the cr span.
392    #[test]
393    fn cr_design_reproduces_line_including_extrapolation() {
394        let knots = Array1::from(vec![0.0, 0.25, 0.5, 0.75, 1.0]);
395        let cr = CubicRegressionBasis::new(knots.clone()).unwrap();
396        // f(x) = 2 + 3x  → β_i = 2 + 3*knot_i.
397        let beta: Array1<f64> = knots.mapv(|x| 2.0 + 3.0 * x);
398        let xs = Array1::from(vec![-0.4, 0.0, 0.13, 0.5, 0.87, 1.0, 1.3]);
399        let design = cr.design(xs.view());
400        let fitted = design.dot(&beta);
401        for (i, &x) in xs.iter().enumerate() {
402            let truth = 2.0 + 3.0 * x;
403            assert!(
404                (fitted[i] - truth).abs() < 1e-9,
405                "line not reproduced at x={x}: got {}, want {truth}",
406                fitted[i]
407            );
408        }
409    }
410
411    /// Knot placement returns endpoints = min/max and strictly increasing knots.
412    #[test]
413    fn cr_knots_span_data_and_increase() {
414        let data = Array1::from((0..50).map(|i| i as f64 / 49.0).collect::<Vec<_>>());
415        let knots = select_cr_knots(data.view(), 5).unwrap();
416        assert_eq!(knots.len(), 5);
417        assert!((knots[0] - 0.0).abs() < 1e-12);
418        assert!((knots[4] - 1.0).abs() < 1e-12);
419        for i in 1..5 {
420            assert!(knots[i] > knots[i - 1]);
421        }
422    }
423}