Skip to main content

gam_terms/basis/
input_loc_derivatives.rs

1//! Input-location derivatives of basis design columns.
2//!
3//! # Piece 2 of the `LatentCoord` work-stream
4//!
5//! For a radial basis with centers `c_k ∈ ℝ^d` and a kernel `φ(r)` evaluated
6//! at `r = ‖t − c_k‖`,
7//!
8//! ```text
9//! Φ_{n,k}              = φ(‖t_n − c_k‖)
10//! ∂Φ_{n,k}/∂t_{n,a}    = φ'(r_{nk}) · (t_n − c_k)_a / r_{nk}
11//!                      = q(r_{nk}) · (t_n − c_k)_a
12//! ∂²Φ_{n,k}/∂t_a∂t_b   = q(r_{nk}) · δ_ab
13//!                      + s(r_{nk}) · (t_n − c_k)_a (t_n − c_k)_b
14//! ```
15//!
16//! where `q(r) = φ'(r)/r` and `s(r) = (φ''(r) − q(r))/r²`.
17//!
18//! These two radial scalars are exactly the `(q, t)` pair already returned
19//! by [`crate::basis::RadialScalarKind::eval_design_triplet`], which
20//! the ψ-derivative path uses to assemble `∂Φ/∂ψ`. The closed-form Taylor
21//! expansions in that routine already cover the `r → 0` collision limits.
22//!
23//! For tensor-product (`te`, `ti`) bases, the chain rule factors across
24//! axes — if axis `a` carries basis `B_a(t_a)`, then
25//!
26//! ```text
27//! Φ(t)                  = ⊗_a B_a(t_a)
28//! ∂Φ/∂t_a               = (⊗_{b≠a} B_b(t_b)) ⊗ B_a'(t_a)
29//! ```
30//!
31//! For periodic / sphere bases, the input is a manifold coordinate `θ ∈ ℝ/2πℤ`
32//! (or `θ ∈ S²` parameterised by `(lat, lon)`). The chain rule passes
33//! through the wrap topology by evaluating the kernel against the signed
34//! chord (wrap-distance) and supplying the Jacobian `dr/dt` of that chord —
35//! identical to the chart-based Jacobian used by the periodic B-spline
36//! evaluator.
37//!
38//! The functions in this module are written in the style of the existing
39//! basis evaluators (caller-supplied output buffers, `BasisError` results)
40//! so they slot directly into the `LatentCoordValues::design_gradient_wrt_t`
41//! call site in `crate::latent` without an additional
42//! materialization layer.
43
44use ndarray::{Array1, Array3, ArrayView2};
45
46use crate::basis::{BasisError, MaternNu};
47
48// =========================================================================
49// Kernel parameter bundle
50// =========================================================================
51
52/// Closed-form parameterisation of the radial families supported by the
53/// input-location derivative routines below.
54///
55/// This is a thin convenience over [`RadialScalarKind`] (which itself is a
56/// crate-internal enum carrying the same parameters). The public layer here
57/// is kept as a separate type so that downstream consumers — including the
58/// Python pyffi layer that will surface `LatentCoord` — can construct kernel
59/// descriptors without poking at `pub(crate)` items.
60#[derive(Debug, Clone)]
61pub enum RadialInputKernel {
62    /// Matérn isotropic kernel with closed-form `(½, 3⁄2, 5⁄2, 7⁄2, 9⁄2)`-ν.
63    Matern { length_scale: f64, nu: MaternNu },
64    /// Hybrid Duchon kernel `||w||^(2p) · (κ² + ||w||²)^s`.
65    DuchonHybrid {
66        length_scale: f64,
67        p_order: usize,
68        s_order: usize,
69        dim: usize,
70    },
71    /// Pure scale-free Duchon kernel (single polyharmonic block of the
72    /// given order). Equivalent to `DuchonHybrid` with `s_order = 0` and no
73    /// finite length scale.
74    DuchonPure {
75        block_order: usize,
76        p_order: usize,
77        s_order: usize,
78        dim: usize,
79    },
80    /// Thin-plate spline kernel with explicit length-scale (used by the
81    /// 1-D thin-plate streaming path; for the general d-D thin-plate this
82    /// coincides with the polyharmonic Duchon kernel of order `m_d`).
83    ThinPlate { length_scale: f64, dim: usize },
84}
85
86impl RadialInputKernel {
87    /// Ambient input dimension `d` (the kernel argument length).
88    pub const fn dim(&self) -> usize {
89        match self {
90            RadialInputKernel::Matern { .. } => {
91                // Matérn is ambient-dimension agnostic in `q, t`; the caller
92                // is responsible for matching `centers.ncols()` to the data
93                // dimensionality. We return the conventional sentinel `0`
94                // here so consumers can short-circuit a dimension cross-check
95                // on the centers themselves.
96                0
97            }
98            RadialInputKernel::DuchonHybrid { dim, .. }
99            | RadialInputKernel::DuchonPure { dim, .. }
100            | RadialInputKernel::ThinPlate { dim, .. } => *dim,
101        }
102    }
103}
104
105// =========================================================================
106// Contraction helper (mirrors LatentCoordValues::contract_gradient)
107// =========================================================================
108
109/// Contract `(n_obs, n_centers)` upstream gradient against an
110/// `(n_obs, n_centers, d)` jet into a flat `(n_obs · d)` gradient w.r.t.
111/// the latent input `t`.
112///
113/// This is the closed-form chain-rule reduction used by both the inner
114/// Newton step and the backward pyffi entrypoint. See
115/// [`crate::latent::LatentCoordValues::contract_gradient`] for
116/// the canonical signature.
117pub fn contract_input_loc_gradient(
118    grad_phi: ArrayView2<'_, f64>,
119    jet: &Array3<f64>,
120) -> Result<Array1<f64>, BasisError> {
121    let n_obs = jet.shape()[0];
122    let n_centers = jet.shape()[1];
123    let d = jet.shape()[2];
124    if grad_phi.shape() != [n_obs, n_centers] {
125        crate::bail_dim_basis!(
126            "contract_input_loc_gradient: grad_phi shape {:?} != expected {:?}",
127            grad_phi.shape(),
128            [n_obs, n_centers]
129        );
130    }
131    let mut grad_t = Array1::<f64>::zeros(n_obs * d);
132    for n in 0..n_obs {
133        for a in 0..d {
134            let mut acc = 0.0_f64;
135            for k in 0..n_centers {
136                acc += grad_phi[[n, k]] * jet[[n, k, a]];
137            }
138            grad_t[n * d + a] = acc;
139        }
140    }
141    Ok(grad_t)
142}
143
144// =========================================================================
145// Tests (type-level documentation; NOT executed in this work-stream).
146// =========================================================================
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::basis::{RadialScalarKind, duchon_partial_fraction_coeffs};
152    use ndarray::array;
153
154    /// Project a `RadialInputKernel` onto the internal `RadialScalarKind`
155    /// enum so the radial-jet routines can be reused verbatim by the
156    /// divergence-witness tests.
157    fn into_scalar_kind(kernel: &RadialInputKernel) -> RadialScalarKind {
158        match kernel {
159            RadialInputKernel::Matern { length_scale, nu } => RadialScalarKind::Matern {
160                length_scale: *length_scale,
161                nu: *nu,
162            },
163            RadialInputKernel::DuchonHybrid {
164                length_scale,
165                p_order,
166                s_order,
167                dim,
168            } => {
169                let kappa = 1.0 / length_scale.max(1e-300);
170                let coeffs = duchon_partial_fraction_coeffs(*p_order, *s_order, kappa);
171                RadialScalarKind::Duchon {
172                    length_scale: *length_scale,
173                    p_order: *p_order,
174                    s_order: *s_order,
175                    dim: *dim,
176                    coeffs,
177                }
178            }
179            RadialInputKernel::DuchonPure {
180                block_order,
181                p_order,
182                s_order,
183                dim,
184            } => RadialScalarKind::PureDuchon {
185                block_order: *block_order,
186                p_order: *p_order,
187                s_order: *s_order,
188                dim: *dim,
189            },
190            RadialInputKernel::ThinPlate { length_scale, dim } => RadialScalarKind::ThinPlate {
191                length_scale: *length_scale,
192                dim: *dim,
193            },
194        }
195    }
196
197    #[test]
198    fn contract_input_loc_gradient_matches_einsum() {
199        let jet = Array3::from_shape_vec(
200            (2, 3, 2),
201            vec![
202                1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
203            ],
204        )
205        .unwrap();
206        let grad_phi = array![[1.0_f64, 0.0, 1.0], [0.0, 1.0, 0.0]];
207        let out = contract_input_loc_gradient(grad_phi.view(), &jet).unwrap();
208        // n=0: a=0 → 1*1 + 0*3 + 1*5 = 6; a=1 → 1*2 + 0*4 + 1*6 = 8
209        // n=1: a=0 → 0*7 + 1*9 + 0*11 = 9; a=1 → 0*8 + 1*10 + 0*12 = 10
210        assert_eq!(out[0], 6.0);
211        assert_eq!(out[1], 8.0);
212        assert_eq!(out[2], 9.0);
213        assert_eq!(out[3], 10.0);
214    }
215
216    // ----------------------------------------------------------------
217    // Degenerate-collision tests (F1–F5)
218    //
219    // These do not invoke any executable test runner here (they compile-
220    // check only under `cargo check --all-features --all-targets`); the
221    // intent is to lock in the contract that divergent kernels surface a
222    // `BasisError::DegenerateAtCollision` instead of a silent zero, and
223    // that a finite-difference probe at ε just away from the collision
224    // produces a large value consistent with the analytic divergence.
225    // ----------------------------------------------------------------
226
227    #[test]
228    fn matern_half_finite_difference_diverges_near_collision() {
229        // φ(r) = exp(−r); q = −exp(−r)/r → −∞ as r → 0+.
230        // At r = ε the design gradient component along the axis is
231        // q·s_axis = −exp(−ε)/ε · ε = −exp(−ε) ≈ −1, but the per-r
232        // scalar q itself blows up at ~1/ε, which is the witness for the
233        // divergence flagged by F1.
234        let kernel = RadialInputKernel::Matern {
235            length_scale: 1.0,
236            nu: MaternNu::Half,
237        };
238        let kind = into_scalar_kind(&kernel);
239        let eps = 1e-8_f64;
240        let (_, q, _) = kind
241            .eval_design_triplet(eps)
242            .expect("ν=1/2 at r=ε is finite");
243        assert!(
244            q.abs() > 1e6,
245            "expected divergent q for Matérn ν=1/2 near r=0, got {q}"
246        );
247    }
248
249    #[test]
250    fn thin_plate_collision_2d_finite_difference_diverges() {
251        // φ(r) = (r/ℓ)² log(r/ℓ); q = (1/ℓ²)(2 log(r/ℓ) + 1) → −∞.
252        let kernel = RadialInputKernel::ThinPlate {
253            length_scale: 1.0,
254            dim: 2,
255        };
256        let kind = into_scalar_kind(&kernel);
257        let eps = 1e-10_f64;
258        let (_, q, _) = kind
259            .eval_design_triplet(eps)
260            .expect("TPS dim=2 at r=ε is finite");
261        assert!(
262            q.abs() > 10.0,
263            "expected large |q| for TPS dim=2 near r=0, got {q}"
264        );
265    }
266}