gam_models/transformation_normal/chart.rs
1//! The CTN coefficient chart — the single definition of what `β` means.
2//!
3//! Everything that turns fitted CTN coefficients into a transformed response
4//! goes through this module. That is not a stylistic preference: the family, the
5//! predictor, the observed-score path, the generated-regressor Jacobian and the
6//! ALO row replay all read the *same* `blocks[0].beta`, so if any one of them
7//! reads it through a different chart the model silently means two different
8//! things at fit time and at predict time. gam#2680 is exactly that failure —
9//! `#2306` moved the likelihood onto the direct-α chart and left three consumers
10//! evaluating `Σ_k I_k(y)·γ_k(x)²`, which reproduces a fitted score of
11//! `c·z + (1−c)·L` instead of `z` and is *identically correct only at `c = 1`*
12//! (hence: passes small fixtures, fails at production `n`).
13//!
14//! # The chart
15//!
16//! `β` is `vec(A)` for a `p_resp × p_cov` coefficient matrix `A`. The
17//! covariate-side coordinates are
18//!
19//! ```text
20//! α_k(x) = ψ(x)ᵀ A[k, :], k = 0 .. p_resp−1
21//! ```
22//!
23//! and the transform is **affine in `α`**, with the shape coordinates kept
24//! non-negative by the factored Khatri-Rao monotonicity cone
25//! (`TransformationNormalFamily::block_linear_constraints`) rather than by a
26//! squared latent reparameterization:
27//!
28//! ```text
29//! h(y, x) = Σ_k value_k(y) · α_k(x) + offset(x) + ε·(y − median)
30//! h'(y, x) = Σ_k derivative_k(y) · α_k(x) + ε
31//! L(x) = Σ_k lower_k · α_k(x) + offset(x) + ε·(y_lo − median)
32//! U(x) = Σ_k upper_k · α_k(x) + offset(x) + ε·(y_hi − median)
33//! ```
34//!
35//! with `value = [1, I_1(y), …]`, `derivative = [0, M_1(y), …]`,
36//! `lower = [1, 0, …, 0]` and `upper = [1, 1ᵀT_{·1}, …]` — the same I-splines
37//! evaluated at the two boundary knots, where every anchored `I_k` is exactly
38//! `0` and exactly `1` respectively.
39//!
40//! Because the chart is affine, the derivative of every one of those four
41//! quantities with respect to `A[k, j]` is just `basis_k · ψ_j(x)` — no chart
42//! factor. [`ctn_row_geometry`] and [`CtnRowBases`] are the only place that
43//! statement is written down.
44
45use super::{
46 ISplineBoundary, TRANSFORMATION_MONOTONICITY_EPS, initializewiggle_knots_from_seed,
47 ispline_modelling_interval, ispline_value_and_first_derivative,
48};
49use crate::inference::model::TransformationNormalParameterization;
50use ndarray::{Array1, Array2, ArrayView1};
51
52/// Number of leading response-basis columns that carry the unconstrained
53/// location field `b(x)` rather than a monotone shape coordinate. The location
54/// column is the constant `1` in the value basis and `0` in the derivative
55/// basis, and it is the one coordinate the monotonicity cone does not
56/// constrain.
57pub const CTN_LOCATION_COLUMNS: usize = 1;
58
59/// Fraction of the response span by which the certified support is widened past
60/// the observed extremes, so every observation the knots were built from sits
61/// STRICTLY inside `[y_lo, y_hi]` rather than on its boundary. A response
62/// exactly at an endpoint would make its PIT exactly `0` or `1` and clip, which
63/// is a real score for a genuinely extreme observation but a fabricated one for
64/// the sample maximum of any finite sample.
65pub const CTN_RESPONSE_SUPPORT_GUARD_FRACTION: f64 = 1.0e-3;
66
67/// Response-direction basis rows for one observation, in the chart's own order.
68///
69/// All four slices are `p_resp` long and are indexed by the same `k` as `alpha`.
70#[derive(Clone, Copy, Debug)]
71pub struct CtnRowBases<'a> {
72 /// `[1, I_1(y_i), …]` — the value basis at this row's response.
73 pub value: ArrayView1<'a, f64>,
74 /// `[0, M_1(y_i), …]` — the derivative basis at this row's response.
75 pub derivative: ArrayView1<'a, f64>,
76 /// `[1, 0, …, 0]` — the value basis at the lower support knot.
77 pub lower: ArrayView1<'a, f64>,
78 /// `[1, 1ᵀT_{·1}, …]` — the value basis at the upper support knot.
79 pub upper: ArrayView1<'a, f64>,
80}
81
82/// The additive scalars that do not depend on `α`: the composed linear-predictor
83/// offset (which enters `h`, `L` and `U` identically) and the three
84/// monotonicity-floor terms `ε·(y − median)`.
85#[derive(Clone, Copy, Debug)]
86pub struct CtnRowFloors {
87 /// The composed additive offset for this row.
88 pub additive_offset: f64,
89 /// `ε·(y_i − median)`.
90 pub value_floor: f64,
91 /// `ε·(y_lo − median)`.
92 pub lower_floor: f64,
93 /// `ε·(y_hi − median)`.
94 pub upper_floor: f64,
95}
96
97/// The transformed response and its support at one row.
98#[derive(Clone, Copy, Debug, PartialEq)]
99pub struct CtnRowGeometry {
100 /// `h(y_i, x_i)`.
101 pub h: f64,
102 /// `h'(y_i, x_i)`, always `≥ ε` by construction on a feasible `α`.
103 pub h_prime: f64,
104 /// `L(x_i) = h(y_lo, x_i)`.
105 pub lower: f64,
106 /// `U(x_i) = h(y_hi, x_i)`.
107 pub upper: f64,
108}
109
110/// One affine chart component: `floor + Σ_k basis_k · α_k`.
111///
112/// The accumulation starts from `basis[0]·α[0] + floor` and then adds the shape
113/// terms in index order — the order the family's row build has always used, so
114/// routing that build through here is bit-identical rather than merely
115/// mathematically equal.
116///
117/// `basis` is read at every index of `alpha`; a caller that passes mismatched
118/// widths gets a bounds panic, which is the correct outcome for a corrupted
119/// coefficient layout (the surrounding paths all validate `p_resp` first).
120///
121/// The arguments are strided views rather than slices deliberately. `α` is a row
122/// of `Ψ · Aᵀ`, whose memory layout is the linear-algebra backend's business —
123/// requiring contiguity here would make a correct chart evaluation depend on
124/// whether a matrix product happened to come back row-major, which is a latent
125/// panic waiting for a shape that flips it.
126#[inline]
127pub fn ctn_chart_component(
128 alpha: ArrayView1<'_, f64>,
129 basis: ArrayView1<'_, f64>,
130 floor: f64,
131) -> f64 {
132 let mut acc = basis[0] * alpha[0] + floor;
133 for k in CTN_LOCATION_COLUMNS..alpha.len() {
134 acc += basis[k] * alpha[k];
135 }
136 acc
137}
138
139/// Evaluate the CTN transform geometry at one row from its covariate-side
140/// coordinates `α_k(x_i)`.
141///
142/// This is *the* definition of the chart. Every consumer — the likelihood, the
143/// PIT score, the `E[Y|x]` inversion grid, the generated-regressor Jacobian, the
144/// ALO row replay — calls it, so none of them can drift onto a different
145/// parameterization of the same `β`.
146///
147/// `chart` is not decoration. A persisted CTN model carries
148/// [`TransformationNormalParameterization`] precisely so *"a reader can reject
149/// coefficients written under any other chart as a typed mismatch instead of
150/// silently reinterpreting them"* — and before gam#2680 every replay path
151/// validated that marker and then reinterpreted the coefficients anyway.
152/// Requiring it here makes the marker load-bearing: a replay path must name the
153/// chart it believes it is evaluating, and adding a second variant is a compile
154/// error in exactly one function instead of a silent divergence in five.
155#[inline]
156pub fn ctn_row_geometry(
157 chart: TransformationNormalParameterization,
158 alpha: ArrayView1<'_, f64>,
159 bases: CtnRowBases<'_>,
160 floors: CtnRowFloors,
161) -> CtnRowGeometry {
162 match chart {
163 TransformationNormalParameterization::DirectAlpha => CtnRowGeometry {
164 h: ctn_chart_component(
165 alpha,
166 bases.value,
167 floors.additive_offset + floors.value_floor,
168 ),
169 h_prime: ctn_chart_component(alpha, bases.derivative, TRANSFORMATION_MONOTONICITY_EPS),
170 lower: ctn_chart_component(
171 alpha,
172 bases.lower,
173 floors.additive_offset + floors.lower_floor,
174 ),
175 upper: ctn_chart_component(
176 alpha,
177 bases.upper,
178 floors.additive_offset + floors.upper_floor,
179 ),
180 },
181 }
182}
183
184/// The derivative of every component of [`ctn_row_geometry`] with respect to the
185/// coordinate `α_k` — which, the chart being affine, is just the basis entry.
186///
187/// Stated as a function so a consumer that differentiates the transform cannot
188/// invent a chart factor the evaluator does not have. The pre-gam#2680
189/// generated-regressor Jacobian carried `2·γ_k` here, the derivative of the
190/// squared chart, against a value path that had already moved.
191#[inline]
192pub fn ctn_component_sensitivity(
193 chart: TransformationNormalParameterization,
194 basis: ArrayView1<'_, f64>,
195 k: usize,
196) -> f64 {
197 match chart {
198 TransformationNormalParameterization::DirectAlpha => basis[k],
199 }
200}
201
202/// Number of knots the I-spline response basis carries for a given degree and
203/// internal-knot count.
204///
205/// The builder integrates a degree-`(response_degree + 1)` B-spline basis, so
206/// the seed produces `k_prime = K − 2` interior knots inside a clamped vector
207/// with `response_degree + 2` boundary repeats at each end.
208pub fn ctn_response_knot_count(
209 response_degree: usize,
210 response_num_internal_knots: usize,
211) -> Result<usize, String> {
212 let k_prime = response_num_internal_knots.checked_sub(2).ok_or_else(|| {
213 format!(
214 "response_num_internal_knots = {response_num_internal_knots}; I-spline contract \
215 requires K' = K − 2 ≥ 0, so need K ≥ 2"
216 )
217 })?;
218 Ok(k_prime + 2 * (response_degree + 2))
219}
220
221/// The clamped I-spline knot vector for a response column, and with it the
222/// **certified response support** `[knots.first, knots.last]` that every PIT
223/// normalizes against.
224///
225/// Interior knots come from the wiggle seed; the boundary repeats are pinned to
226/// `[min − guard, max + guard]` with a guard of `0.1 %` of the response span, so
227/// every observation used to build them sits strictly inside the support.
228///
229/// This is a function rather than an inline block in
230/// `super::build_response_basis` because the support it defines is a *shared*
231/// object whenever more than one CTN fit has to produce comparable scores. The
232/// cross-fit Stage-1 calibration is exactly that case: it refits the CTN on each
233/// fold complement and evaluates the score on the held-out rows, so a
234/// fold-local support both (a) fails outright on whichever fold holds out a
235/// response extreme — the held-out row is then outside its own fold's certified
236/// domain and the PIT refuses it — and (b) when it does not fail, assembles the
237/// out-of-fold score from `K` PITs taken against `K` *different* truncations,
238/// which is not one latent scale. Resolving it once on the full response and
239/// pinning it (`TransformationNormalConfig::response_knots_pinned`) removes both.
240pub fn ctn_response_knots(
241 response: ArrayView1<'_, f64>,
242 response_degree: usize,
243 response_num_internal_knots: usize,
244) -> Result<Array1<f64>, String> {
245 let k_prime = response_num_internal_knots.checked_sub(2).ok_or_else(|| {
246 format!(
247 "response_num_internal_knots = {response_num_internal_knots}; I-spline contract \
248 requires K' = K − 2 ≥ 0, so need K ≥ 2"
249 )
250 })?;
251 // The I-spline builder integrates a degree-`(response_degree + 1)` B-spline
252 // basis into a degree-`response_degree` value basis, so the seed-time degree
253 // is `response_degree + 1`.
254 let mut knots = initializewiggle_knots_from_seed(response, response_degree + 1, k_prime)?;
255 let response_min = response.iter().copied().fold(f64::INFINITY, f64::min);
256 let response_max = response.iter().copied().fold(f64::NEG_INFINITY, f64::max);
257 let response_span = (response_max - response_min).abs().max(1.0);
258 let support_guard = response_span * CTN_RESPONSE_SUPPORT_GUARD_FRACTION;
259 let boundary_repeats = response_degree + 2;
260 if knots.len() >= 2 * boundary_repeats {
261 for idx in 0..boundary_repeats {
262 knots[idx] = response_min - support_guard;
263 let right_idx = knots.len() - 1 - idx;
264 knots[right_idx] = response_max + support_guard;
265 }
266 }
267 Ok(knots)
268}
269
270/// The knot vector a CTN fit will actually use: the pinned one when the config
271/// carries it, otherwise one resolved from this fit's own response.
272///
273/// This is the single decision point for "whose response defines the certified
274/// support", which is why it is a named function rather than a branch inside
275/// `super::build_response_basis` — the cross-fit needs to make that decision
276/// and needs to be able to check it (gam#2680).
277pub fn ctn_resolved_response_knots(
278 response: ArrayView1<'_, f64>,
279 response_degree: usize,
280 response_num_internal_knots: usize,
281 pinned: Option<&Array1<f64>>,
282) -> Result<Array1<f64>, String> {
283 let expected = ctn_response_knot_count(response_degree, response_num_internal_knots)?;
284 match pinned {
285 Some(knots) => {
286 if knots.len() != expected {
287 return Err(format!(
288 "pinned response knot vector has {} entries but degree {response_degree} with \
289 {response_num_internal_knots} internal knots requires {expected}",
290 knots.len()
291 ));
292 }
293 Ok(knots.clone())
294 }
295 None => ctn_response_knots(response, response_degree, response_num_internal_knots),
296 }
297}
298
299/// The endpoint value bases `(lower, upper)` for a response-shape coefficient
300/// transform `T`.
301///
302/// An anchored I-spline satisfies `I_k(y_lo) = 0` and `I_k(y_hi) = 1` exactly,
303/// so the lower endpoint reads only the location column and the upper endpoint
304/// reads the column sums of `T`. Stating the endpoints structurally rather than
305/// re-evaluating the basis at the boundary knots is what makes `U − L` exactly
306/// the represented support width instead of that width plus two evaluations of
307/// round-off.
308pub fn ctn_endpoint_bases(transform: &Array2<f64>) -> (Array1<f64>, Array1<f64>) {
309 let p_shape = transform.ncols();
310 let mut lower = Array1::<f64>::zeros(p_shape + CTN_LOCATION_COLUMNS);
311 let mut upper = Array1::<f64>::zeros(p_shape + CTN_LOCATION_COLUMNS);
312 lower[0] = 1.0;
313 upper[0] = 1.0;
314 for col in 0..p_shape {
315 upper[col + CTN_LOCATION_COLUMNS] = transform.column(col).sum();
316 }
317 (lower, upper)
318}
319
320/// The three floor scalars for a whole column of responses.
321///
322/// Returns `(per-row ε·(y_i − median), ε·(y_lo − median), ε·(y_hi − median))`
323/// with the support endpoints taken from the fitted knot vector.
324pub fn ctn_floor_offsets(
325 response: ArrayView1<'_, f64>,
326 knots: ArrayView1<'_, f64>,
327 response_median: f64,
328) -> Result<(Array1<f64>, f64, f64), String> {
329 let (Some(&lower_y), Some(&upper_y)) = (knots.first(), knots.last()) else {
330 return Err("CTN floor offsets require a non-empty response knot vector".to_string());
331 };
332 let row_offsets = response.mapv(|y| TRANSFORMATION_MONOTONICITY_EPS * (y - response_median));
333 Ok((
334 row_offsets,
335 TRANSFORMATION_MONOTONICITY_EPS * (lower_y - response_median),
336 TRANSFORMATION_MONOTONICITY_EPS * (upper_y - response_median),
337 ))
338}
339
340/// Build the response-direction value and derivative bases `[1, I(y)·T]` and
341/// `[0, M(y)·T]` at arbitrary response values, on the frozen knots/degree.
342///
343/// `transform = None` means the identity chart (`T = I`), which is what
344/// `super::build_response_basis` constructs and therefore what the fit uses;
345/// a persisted model passes its saved `T` so a prediction reproduces the fitted
346/// basis exactly. The two callers differing on how the location column is
347/// prepended is precisely the class of bug this function exists to remove.
348///
349/// # Why the CTN transformation cannot saturate outside its knots
350///
351/// A conditional transformation-normal model *is* the statement
352/// `F(y | x) = Φ(h(y | x))`, and gam#2600 removed the endpoint renormalizer that
353/// used to truncate it — so the fitted density `φ(h)·h'` is a density on the
354/// whole real line and `h` is its quantile map. Under
355/// [`ISplineBoundary::Saturate`] — which is what the shared evaluator does by
356/// default, for the reasons its own module documents — `I_k` is constant and
357/// `M_k` is zero outside `[t_q, t_{n_B}]`, so the whole exterior of the fitted
358/// transform is
359///
360/// ```text
361/// h(y) = h(y_b) + ε·(y − y_b), ε = TRANSFORMATION_MONOTONICITY_EPS = 1e-8,
362/// ```
363///
364/// i.e. the model's entire tail behaviour is a readout of a numerical floor.
365/// Measured on an intercept-only fit to `Y = exp(N(0,1))` at `n = 256`
366/// (gam#2600): `Φ(h(y_lo)) = 2.4e-2` of the model's own predictive mass sits
367/// below the tabulated support, the transform needs `Δy ≈ 1.4e8` to carry `Φ(h)`
368/// from `Φ(U)` to `0.9999`, and two responses a factor `1.8` apart on the far
369/// side of the boundary receive PIT scores identical to seven digits.
370///
371/// [`ISplineBoundary::LinearTails`] is the classical answer — Royston-Parmar's
372/// linear tails, `mlt`'s `extrapolate` — and gives a `C¹`, strictly increasing
373/// `h` on all of `ℝ` with `h' ≥ ε` preserved (`M_k(y_b) ≥ 0`, `α ≥ 0` on the
374/// monotonicity cone), so `Φ(h)` is a proper CDF whose tails are the fitted
375/// transform's. Evaluation AT or INSIDE the knots is bit-identical, and
376/// `ctn_response_knots` guards the support by `0.1 %` of the response span so
377/// every training row is strictly inside — no fitted quantity moves.
378///
379/// The exterior I-spline entries do leave `[0, 1]`, which is why the convention
380/// is a parameter of the shared evaluator rather than its default: for the CTN
381/// the entries are coefficients of a transformation that must keep increasing,
382/// not weights that must stay in a simplex, and the survival link warp that
383/// shares the evaluator (gam#2695) genuinely wants the saturating convention.
384///
385/// This does mean the fitted CTN puts mass on the whole line, so a strictly
386/// positive response can be extrapolated below zero. That is a property of a
387/// Gaussian transformation model on the raw response scale — the same property
388/// the likelihood is already maximised under — and it is honest where a clamp
389/// was not; a model that must respect a bound belongs on the transformed scale
390/// (fit `log y`), exactly as `mlt`'s `log_first` does.
391///
392/// The continuation is applied to the RAW I-spline frame, before `T`; the chart
393/// is linear in the basis, so extending-then-transforming and
394/// transforming-then-extending are the same matrix and the raw frame is where
395/// the boundary derivative is defined.
396pub fn ctn_response_bases_at(
397 response: ArrayView1<'_, f64>,
398 knots: ArrayView1<'_, f64>,
399 degree: usize,
400 transform: Option<&Array2<f64>>,
401) -> Result<(Array2<f64>, Array2<f64>), String> {
402 // The interval is read off the same index arithmetic the evaluator uses
403 // rather than as `knots.first()` / `knots.last()`: on the clamped vectors
404 // `ctn_response_knots` builds the two agree, but it is the EVALUATOR's
405 // interval that decides where the basis stops being a spline and starts
406 // being an extension. A degenerate one is refused here rather than silently
407 // producing a basis with no exterior.
408 ispline_modelling_interval(knots, degree)
409 .map_err(|error| format!("CTN response I-spline knot vector is unusable: {error}"))?
410 .ok_or_else(|| {
411 format!(
412 "CTN response I-spline modelling interval is degenerate for degree {degree} on \
413 {} knot(s)",
414 knots.len()
415 )
416 })?;
417 let (raw_value, raw_derivative) = ispline_value_and_first_derivative(
418 response,
419 knots,
420 degree,
421 ISplineBoundary::LinearTails,
422 )
423 .map_err(|error| format!("CTN response I-spline value/derivative bases failed: {error}"))?;
424
425 let (shape_value, shape_derivative) = match transform {
426 Some(t) => {
427 if raw_value.ncols() != t.nrows() {
428 return Err(format!(
429 "CTN response transform has {} rows but the I-spline basis has {} columns",
430 t.nrows(),
431 raw_value.ncols()
432 ));
433 }
434 (raw_value.dot(t), raw_derivative.dot(t))
435 }
436 None => (raw_value, raw_derivative),
437 };
438 if shape_derivative.ncols() != shape_value.ncols() {
439 return Err(format!(
440 "CTN response derivative basis has {} columns but the value basis has {}",
441 shape_derivative.ncols(),
442 shape_value.ncols()
443 ));
444 }
445
446 let n = shape_value.nrows();
447 let p_resp = shape_value.ncols() + CTN_LOCATION_COLUMNS;
448 let mut value = Array2::<f64>::zeros((n, p_resp));
449 let mut derivative = Array2::<f64>::zeros((n, p_resp));
450 value.column_mut(0).fill(1.0);
451 value
452 .slice_mut(ndarray::s![.., CTN_LOCATION_COLUMNS..])
453 .assign(&shape_value);
454 derivative
455 .slice_mut(ndarray::s![.., CTN_LOCATION_COLUMNS..])
456 .assign(&shape_derivative);
457 Ok((value, derivative))
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463
464 fn bases<'a>(
465 value: &'a [f64],
466 derivative: &'a [f64],
467 lower: &'a [f64],
468 upper: &'a [f64],
469 ) -> CtnRowBases<'a> {
470 CtnRowBases {
471 value: ArrayView1::from(value),
472 derivative: ArrayView1::from(derivative),
473 lower: ArrayView1::from(lower),
474 upper: ArrayView1::from(upper),
475 }
476 }
477
478 #[test]
479 fn chart_component_is_affine_in_alpha() {
480 // The defining property: doubling alpha doubles the response of every
481 // component about its floor. A squared chart fails this by 4x on the
482 // shape block, which is exactly gam#2680.
483 let basis = [1.0, 0.4, 0.9];
484 let alpha = [0.5, 1.5, 2.5];
485 let doubled: Vec<f64> = alpha.iter().map(|a| 2.0 * a).collect();
486 let floor = -0.25;
487 let base = ctn_chart_component(
488 ArrayView1::from(&alpha[..]),
489 ArrayView1::from(&basis[..]),
490 floor,
491 );
492 let twice = ctn_chart_component(
493 ArrayView1::from(&doubled[..]),
494 ArrayView1::from(&basis[..]),
495 floor,
496 );
497 assert!(
498 ((twice - floor) - 2.0 * (base - floor)).abs() < 1e-14,
499 "chart is not affine: base={base} twice={twice}"
500 );
501 }
502
503 #[test]
504 fn row_geometry_matches_the_hand_written_chart() {
505 let value = [1.0, 0.25, 0.75];
506 let derivative = [0.0, 0.6, 1.1];
507 let lower = [1.0, 0.0, 0.0];
508 let upper = [1.0, 1.0, 1.0];
509 let alpha = [-1.5, 0.8, 1.2];
510 let floors = CtnRowFloors {
511 additive_offset: 0.1,
512 value_floor: 1.0e-9,
513 lower_floor: -2.0e-9,
514 upper_floor: 3.0e-9,
515 };
516 let g = ctn_row_geometry(
517 TransformationNormalParameterization::DirectAlpha,
518 ArrayView1::from(&alpha[..]),
519 bases(&value, &derivative, &lower, &upper),
520 floors,
521 );
522 let expect_h = 0.1 + 1.0e-9 + 1.0 * -1.5 + 0.25 * 0.8 + 0.75 * 1.2;
523 let expect_hp = TRANSFORMATION_MONOTONICITY_EPS + 0.6 * 0.8 + 1.1 * 1.2;
524 let expect_lo = 0.1 - 2.0e-9 + -1.5;
525 let expect_hi = 0.1 + 3.0e-9 + -1.5 + 0.8 + 1.2;
526 assert!((g.h - expect_h).abs() < 1e-12, "h {} vs {expect_h}", g.h);
527 assert!(
528 (g.h_prime - expect_hp).abs() < 1e-12,
529 "h' {} vs {expect_hp}",
530 g.h_prime
531 );
532 assert!((g.lower - expect_lo).abs() < 1e-12);
533 assert!((g.upper - expect_hi).abs() < 1e-12);
534 }
535
536 #[test]
537 fn endpoint_bases_read_the_location_column_and_the_transform_column_sums() {
538 let transform = ndarray::array![[1.0, 0.0], [0.5, 2.0], [0.25, -1.0]];
539 let (lower, upper) = ctn_endpoint_bases(&transform);
540 assert_eq!(lower.to_vec(), vec![1.0, 0.0, 0.0]);
541 assert_eq!(upper.to_vec(), vec![1.0, 1.75, 1.0]);
542 }
543
544 #[test]
545 fn support_width_is_the_shape_coordinates_own_scale() {
546 // U − L on the identity chart is exactly Σ_k α_k: the represented span
547 // of the transformation. Under a squared chart it would be Σ_k α_k²,
548 // which is the quantity gam#2680's over-dispersion is a readout of.
549 let transform = Array2::<f64>::eye(3);
550 let (lower, upper) = ctn_endpoint_bases(&transform);
551 let alpha = [-2.0, 0.7, 1.3, 0.5];
552 let floors = CtnRowFloors {
553 additive_offset: 0.0,
554 value_floor: 0.0,
555 lower_floor: 0.0,
556 upper_floor: 0.0,
557 };
558 let value = [1.0, 0.0, 0.0, 0.0];
559 let derivative = [0.0, 1.0, 1.0, 1.0];
560 let g = ctn_row_geometry(
561 TransformationNormalParameterization::DirectAlpha,
562 ArrayView1::from(&alpha[..]),
563 bases(
564 &value,
565 &derivative,
566 lower.as_slice().expect("contiguous"),
567 upper.as_slice().expect("contiguous"),
568 ),
569 floors,
570 );
571 assert!((g.upper - g.lower - (0.7 + 1.3 + 0.5)).abs() < 1e-12);
572 }
573
574 #[test]
575 fn response_bases_carry_the_location_column_and_a_zero_derivative_column() {
576 let knots = Array1::from_vec(vec![
577 -1.2, -1.2, -1.2, -1.2, -1.2, 0.0, 1.2, 1.2, 1.2, 1.2, 1.2,
578 ]);
579 let y = Array1::from_vec(vec![-1.2, -0.3, 0.4, 1.2]);
580 let (value, derivative) =
581 ctn_response_bases_at(y.view(), knots.view(), 3, None).expect("bases");
582 assert_eq!(value.nrows(), 4);
583 assert_eq!(value.ncols(), derivative.ncols());
584 for i in 0..4 {
585 assert_eq!(value[[i, 0]], 1.0, "location column must be 1");
586 assert_eq!(derivative[[i, 0]], 0.0, "location column carries no slope");
587 }
588 // Anchored I-splines: every shape column is 0 at the left boundary knot
589 // and 1 at the right one — the structural fact `ctn_endpoint_bases`
590 // encodes.
591 for k in CTN_LOCATION_COLUMNS..value.ncols() {
592 assert!(
593 value[[0, k]].abs() < 1e-12,
594 "I_{k}(y_lo) = {} is not 0",
595 value[[0, k]]
596 );
597 assert!(
598 (value[[3, k]] - 1.0).abs() < 1e-12,
599 "I_{k}(y_hi) = {} is not 1",
600 value[[3, k]]
601 );
602 }
603 }
604
605 /// A clamped I-spline knot vector for `degree = 3`: `degree + 2 = 5`
606 /// boundary repeats at each end, one interior knot, support `[-1.2, 1.2]`.
607 fn tail_knots() -> Array1<f64> {
608 Array1::from_vec(vec![
609 -1.2, -1.2, -1.2, -1.2, -1.2, 0.0, 1.2, 1.2, 1.2, 1.2, 1.2,
610 ])
611 }
612
613 #[test]
614 fn response_bases_continue_affinely_past_the_knots_2600() {
615 // The defect gam#2600 left behind: `I_k` saturated outside the knot
616 // range and `M_k` was zero there, so the CTN transform's whole exterior
617 // was `h(y_b) + ε·(y − y_b)` with `ε = 1e-8`. The basis must instead
618 // continue at its own boundary derivative.
619 let knots = tail_knots();
620 let (y_lo, y_hi) = (-1.2_f64, 1.2_f64);
621 let steps = [1.0e-6_f64, 0.25, 3.0, 250.0];
622 let mut points = vec![y_lo, y_hi];
623 for step in steps {
624 points.push(y_lo - step);
625 points.push(y_hi + step);
626 }
627 let y = Array1::from_vec(points);
628 let (value, derivative) =
629 ctn_response_bases_at(y.view(), knots.view(), 3, None).expect("bases with tails");
630
631 // Row 0 / row 1 are the two anchors; every exterior row is the anchor
632 // plus its own distance times the anchor's slope, and carries exactly
633 // the anchor's slope.
634 for (index, &point) in y.iter().enumerate().skip(2) {
635 let (anchor_row, anchor) = if point < y_lo { (0, y_lo) } else { (1, y_hi) };
636 let step = point - anchor;
637 for k in CTN_LOCATION_COLUMNS..value.ncols() {
638 let slope = derivative[[anchor_row, k]];
639 let expected = value[[anchor_row, k]] + step * slope;
640 assert!(
641 (value[[index, k]] - expected).abs() <= 1.0e-12 * expected.abs().max(1.0),
642 "column {k} at y={point} is {} not the affine continuation {expected}",
643 value[[index, k]]
644 );
645 assert!(
646 (derivative[[index, k]] - slope).abs() <= 1.0e-15,
647 "column {k} at y={point} has slope {} not the boundary slope {slope}",
648 derivative[[index, k]]
649 );
650 }
651 }
652
653 // Non-degeneracy: the continuation would be vacuous if the boundary
654 // derivative were zero, which is exactly the state this replaces.
655 let lower_slope: f64 = derivative
656 .row(0)
657 .iter()
658 .skip(CTN_LOCATION_COLUMNS)
659 .sum::<f64>();
660 let upper_slope: f64 = derivative
661 .row(1)
662 .iter()
663 .skip(CTN_LOCATION_COLUMNS)
664 .sum::<f64>();
665 assert!(
666 lower_slope > 1.0e-3 && upper_slope > 1.0e-3,
667 "the boundary derivatives the continuation is anchored at are degenerate: \
668 lower={lower_slope:.6e} upper={upper_slope:.6e}"
669 );
670 }
671
672 #[test]
673 fn response_bases_are_c1_across_both_boundary_knots_2600() {
674 // The invariant that separates "extrapolates" from "saturates" with no
675 // tolerance to argue about: `M_k` has no jump at the boundary. Before
676 // this, the jump was the whole boundary derivative (O(1) inside, exactly
677 // 0 outside).
678 let knots = tail_knots();
679 let delta = 1.0e-9_f64;
680 let y = Array1::from_vec(vec![-1.2 - delta, -1.2 + delta, 1.2 - delta, 1.2 + delta]);
681 let (value, derivative) =
682 ctn_response_bases_at(y.view(), knots.view(), 3, None).expect("bases across the knots");
683 for (outside, inside) in [(0usize, 1usize), (3usize, 2usize)] {
684 // Scale every comparison by the largest slope the interior row
685 // carries: the boundary derivative lives in one column, and the
686 // question is whether that column survives the crossing at all.
687 let scale = derivative
688 .row(inside)
689 .iter()
690 .fold(0.0_f64, |acc, value| acc.max(value.abs()));
691 assert!(
692 scale > 1.0e-3,
693 "the interior boundary derivative is already degenerate ({scale:.6e}); \
694 there is nothing for the continuation to match"
695 );
696 for k in CTN_LOCATION_COLUMNS..value.ncols() {
697 let jump = (derivative[[outside, k]] - derivative[[inside, k]]).abs();
698 assert!(
699 jump <= 1.0e-4 * scale,
700 "M_{k} jumps by {jump:.6e} across a boundary knot (inside {:.6e}, \
701 outside {:.6e}); before gam#2600's continuation this jump WAS the whole \
702 boundary derivative",
703 derivative[[inside, k]],
704 derivative[[outside, k]]
705 );
706 let value_jump = (value[[outside, k]] - value[[inside, k]]).abs();
707 assert!(
708 value_jump <= 1.0e-4 * scale.max(1.0),
709 "I_{k} jumps by {value_jump:.6e} across a boundary knot"
710 );
711 }
712 }
713 }
714
715 #[test]
716 fn the_transform_is_strictly_increasing_and_unbounded_on_the_whole_line_2600() {
717 // The modelling consequence, on a feasible coefficient vector: with
718 // `α ≥ 0` (the Khatri-Rao monotonicity cone) the transform must run from
719 // −∞ to +∞ so that `F = Φ(h)` is a proper CDF. Before this it ran
720 // between two finite plateaux joined by the `1e-8` floor, so the model
721 // needed `Δy ~ 1e8` to spend its tail mass.
722 let knots = tail_knots();
723 let probe = Array1::from_vec(vec![-1.2, 1.2]);
724 let (probe_value, _) =
725 ctn_response_bases_at(probe.view(), knots.view(), 3, None).expect("endpoint bases");
726 let p_resp = probe_value.ncols();
727 // `α₀ = 0` and every shape coordinate 1: a feasible, strictly monotone
728 // transformation on the Khatri-Rao cone.
729 let mut alpha = Array1::from_elem(p_resp, 1.0);
730 alpha[0] = 0.0;
731 let (lower_basis, upper_basis) =
732 ctn_endpoint_bases(&Array2::<f64>::eye(p_resp - CTN_LOCATION_COLUMNS));
733 let floors = CtnRowFloors {
734 additive_offset: 0.0,
735 value_floor: 0.0,
736 lower_floor: 0.0,
737 upper_floor: 0.0,
738 };
739 let far = Array1::from_vec(vec![-1.0e3, -1.2, 0.0, 1.2, 1.0e3]);
740 let (value, derivative) =
741 ctn_response_bases_at(far.view(), knots.view(), 3, None).expect("far bases");
742 let mut previous = f64::NEG_INFINITY;
743 let mut extremes = Vec::new();
744 for row in 0..far.len() {
745 let value_row = value.row(row);
746 let derivative_row = derivative.row(row);
747 let geometry = ctn_row_geometry(
748 TransformationNormalParameterization::DirectAlpha,
749 alpha.view(),
750 bases(
751 value_row.as_slice().expect("contiguous value row"),
752 derivative_row.as_slice().expect("contiguous slope row"),
753 lower_basis.as_slice().expect("contiguous"),
754 upper_basis.as_slice().expect("contiguous"),
755 ),
756 floors,
757 );
758 assert!(
759 geometry.h > previous,
760 "h is not strictly increasing at y={}: {} <= {previous}",
761 far[row],
762 geometry.h
763 );
764 previous = geometry.h;
765 assert!(
766 geometry.h_prime > 1.0e-3,
767 "h' at y={} collapsed to {:.6e}; the exterior slope must be the boundary \
768 derivative, not the monotonicity floor",
769 far[row],
770 geometry.h_prime
771 );
772 if row == 0 || row + 1 == far.len() {
773 extremes.push(geometry.h);
774 }
775 }
776 // `h` really does reach the far tails of the standard normal: at
777 // `|y| = 1e3` the latent is hundreds of sigma, not the `±U` plateau.
778 assert!(
779 extremes[0] < -1.0e2 && extremes[1] > 1.0e2,
780 "the transform is still bounded outside its knots: h(-1e3)={:.6e}, h(1e3)={:.6e}",
781 extremes[0],
782 extremes[1]
783 );
784 }
785
786 #[test]
787 fn response_bases_inside_the_knots_are_untouched_by_the_continuation_2600() {
788 // The fit only ever evaluates strictly inside the certified support
789 // (`ctn_response_knots` guards it by 0.1 % of the response span), so the
790 // continuation must be bit-identical there or it would silently move
791 // every fitted model.
792 let knots = tail_knots();
793 let interior = Array1::from_vec(vec![-1.2, -0.9, -0.3, 0.0, 0.4, 1.1, 1.2]);
794 let (interior_value, interior_derivative) =
795 ctn_response_bases_at(interior.view(), knots.view(), 3, None).expect("interior");
796 // Same points, evaluated in a batch that also contains exterior rows, so
797 // the continuation branch is definitely taken.
798 let mut mixed = interior.to_vec();
799 mixed.push(-9.0);
800 mixed.push(9.0);
801 let mixed = Array1::from_vec(mixed);
802 let (mixed_value, mixed_derivative) =
803 ctn_response_bases_at(mixed.view(), knots.view(), 3, None).expect("mixed");
804 for row in 0..interior.len() {
805 for k in 0..interior_value.ncols() {
806 assert_eq!(
807 interior_value[[row, k]].to_bits(),
808 mixed_value[[row, k]].to_bits(),
809 "interior value moved at (row {row}, column {k})"
810 );
811 assert_eq!(
812 interior_derivative[[row, k]].to_bits(),
813 mixed_derivative[[row, k]].to_bits(),
814 "interior derivative moved at (row {row}, column {k})"
815 );
816 }
817 }
818 }
819
820 #[test]
821 fn response_bases_apply_a_non_identity_transform() {
822 let knots = Array1::from_vec(vec![
823 -1.2, -1.2, -1.2, -1.2, -1.2, 0.0, 1.2, 1.2, 1.2, 1.2, 1.2,
824 ]);
825 let y = Array1::from_vec(vec![-0.3, 0.4]);
826 let (raw_value, _) = ctn_response_bases_at(y.view(), knots.view(), 3, None).expect("raw");
827 let p_shape = raw_value.ncols() - CTN_LOCATION_COLUMNS;
828 let transform = Array2::<f64>::eye(p_shape) * 2.0;
829 let (scaled, _) =
830 ctn_response_bases_at(y.view(), knots.view(), 3, Some(&transform)).expect("scaled");
831 for i in 0..2 {
832 assert_eq!(scaled[[i, 0]], 1.0);
833 for k in CTN_LOCATION_COLUMNS..scaled.ncols() {
834 assert!((scaled[[i, k]] - 2.0 * raw_value[[i, k]]).abs() < 1e-12);
835 }
836 }
837 }
838}