gam_solve/estimate/edf_accounting.rs
1//! One accounting for the penalized effective-degrees-of-freedom bundle.
2//!
3//! Every fitting route computes the per-block penalty traces
4//! `tr_k = λ_k·tr(H⁻¹ S_k)` with whatever linear algebra its parameterisation
5//! affords — a factorized solve against the canonical transformed blocks, a
6//! dense product against a latent covariance, a Cholesky solve against an
7//! assembled Hessian. That part is genuinely route-specific and stays where it
8//! is. What is *not* route-specific is the **accounting** those traces feed:
9//! which ceiling a trace is clamped to, what a non-finite trace resolves to,
10//! what `edf_by_block` is measured against, and what floor `edf_total` may not
11//! fall below.
12//!
13//! Those four rules were written out independently at six sites and did not
14//! agree (issue #2470). Two disagreements were load-bearing:
15//!
16//! * **The per-block ceiling.** `rank(S_k)` and `block_cols` are not the same
17//! number; they differ by `nullity(S_k)`, which is a whole integer of reported
18//! complexity for every penalized block. `rank(S_k)` is the correct one, and
19//! not merely by convention: it is the quantity the REML criterion already
20//! prices as `rank(S_k)·ρ_k`, so it is the ceiling that agrees with the
21//! objective being optimized. Passing the ranks in explicitly is deliberate —
22//! a caller must *state* its rank oracle rather than reach for a column count
23//! because that is what happened to be in scope.
24//! * **The floor.** `edf_total` cannot fall below the joint penalty null-space
25//! dimension `mp = p − rank(Σ_k S_k)`: those directions are unpenalized, so no
26//! amount of smoothing removes them. Clamping to `[0, p]` instead lets a noisy
27//! trace report an effective dimension below the mathematically attainable
28//! minimum, and nothing downstream notices.
29//!
30//! `edf_total` feeds `σ̂² = RSS/(n − edf_total)`, conditional AIC, the
31//! likelihood-ratio reference df and every interval width, so a disagreement
32//! here is not a reporting curiosity.
33//!
34//! Because the floor `mp` is stated here, this is also the one place that can
35//! see `edf_total` LAND on it — a fit that kept none of the penalized
36//! directions its design offered and returned the model no amount of smoothing
37//! can remove. #2607 and #2579 both produced exactly that, by unrelated routes,
38//! and both reported `Converged` with nothing said. `collapsed_to_penalty_null_space`
39//! names the state by its outcome, so one check covers every route into it.
40
41/// The three EDF quantities a fit publishes, produced together so they cannot
42/// disagree with one another.
43#[derive(Clone, Debug, PartialEq)]
44pub struct EdfBundle {
45 /// `p − Σ_k tr_k`, clamped to `[mp, p]`.
46 pub edf_total: f64,
47 /// `rank_k − tr_k` per penalty block, clamped to `[0, rank_k]`.
48 pub edf_by_block: Vec<f64>,
49 /// The admitted per-block traces `tr_k`, each clamped to `[0, rank_k]`.
50 /// Retained because the per-term EDF decomposition is assembled from them
51 /// (issue #1219), so downstream must read the same numbers this accounting
52 /// used rather than re-clamping the raw values itself.
53 pub penalty_block_trace: Vec<f64>,
54}
55
56/// Admit one raw penalty trace against its block rank.
57///
58/// A PSD penalty can absorb at most its own rank, so `tr_k` is mathematically
59/// confined to `[0, rank_k]`. When the outer optimizer drives a redundant
60/// block's `λ_k = exp(ρ_k)` to the ceiling, the raw product `λ_k·frob` can
61/// overflow to `+∞` on a ridge-stabilized Hessian even though the true value is
62/// exactly `rank_k` (gam#1379): `+∞` is the one non-finite value with a known
63/// limit, and it resolves to the saturated bound.
64///
65/// `NaN` and `−∞` have no such limit. A NaN says the trace was never computed
66/// (a poisoned solve, or `∞·0` with no sign to read), and `−∞` is an overflow
67/// in the direction a nonnegative trace cannot go. Both are passed through as
68/// NaN for the fit-result finiteness validators to refuse. They used to
69/// resolve to `rank_k` as well — chosen only because `f64::clamp` propagates
70/// NaN — which turned a failed computation into a plausible EDF, and from there
71/// into a plausible `σ̂² = RSS/(n − edf)`, conditional AIC and interval width.
72fn admit_trace(raw: f64, rank: usize) -> f64 {
73 let ceiling = rank as f64;
74 if raw == f64::INFINITY {
75 ceiling
76 } else if raw.is_finite() {
77 raw.clamp(0.0, ceiling)
78 } else {
79 f64::NAN
80 }
81}
82
83/// Assemble the EDF bundle from already-computed per-block penalty traces.
84///
85/// `raw_block_traces` and `block_ranks` are aligned 1:1 with the penalty blocks.
86/// `coefficient_count` is `p`. `joint_penalty_nullity` is `mp = p − rank(Σ_k S_k)`,
87/// taken as a parameter rather than derived from `block_ranks`: the joint rank is
88/// the rank of the *stacked* penalty root, which is not in general the sum of the
89/// per-block ranks.
90///
91/// Traces are summed with compensated (Kahan) addition because `edf_total` is a
92/// difference of two like-sized quantities, where naive summation error lands
93/// directly in the reported effective dimension.
94pub fn penalized_edf_bundle(
95 raw_block_traces: &[f64],
96 block_ranks: &[usize],
97 coefficient_count: usize,
98 joint_penalty_nullity: f64,
99) -> EdfBundle {
100 assert_blocks_aligned(raw_block_traces.len(), block_ranks.len());
101 let penalty_block_trace: Vec<f64> = raw_block_traces
102 .iter()
103 .zip(block_ranks.iter())
104 .map(|(&raw, &rank)| admit_trace(raw, rank))
105 .collect();
106 let edf_by_block: Vec<f64> = penalty_block_trace
107 .iter()
108 .zip(block_ranks.iter())
109 .map(|(&trace, &rank)| {
110 let ceiling = rank as f64;
111 (ceiling - trace).clamp(0.0, ceiling)
112 })
113 .collect();
114 let p = coefficient_count as f64;
115 let edf_total = (p - super::penalty::kahan_sum(penalty_block_trace.iter().copied()))
116 .clamp(joint_penalty_nullity.min(p), p);
117 if collapsed_to_penalty_null_space(edf_total, coefficient_count, joint_penalty_nullity) {
118 let mp = joint_penalty_nullity.clamp(0.0, p);
119 log::warn!(
120 "fit collapsed to its penalty null space: effective df {edf_total:.3} of {p} \
121 coefficients, against a joint penalty nullity of {mp}. The {} penalized \
122 directions this design offered were smoothed away entirely -- the model \
123 returned is the one no amount of smoothing can remove. On a saturated or \
124 near-saturated design that is the criterion's own optimum rather than a \
125 solver failure; otherwise it is the signature of a lambda railed at its \
126 ceiling (#2607).",
127 p - mp,
128 );
129 }
130 EdfBundle {
131 edf_total,
132 edf_by_block,
133 penalty_block_trace,
134 }
135}
136
137/// How many of the penalized directions a design offered survived into the fit,
138/// and whether that count is small enough to call the result the penalty's own
139/// null model.
140///
141/// `mp = p − rank(Σ_k S_k)` is the dimension smoothing cannot touch, so
142/// `attainable = p − mp` is what λ-selection actually chooses over and
143/// `spent = edf_total − mp` is what it kept. #2607 and #2579 reached the SAME
144/// visible end state — `Converged`, `edf` within rounding of the intercept —
145/// from two unrelated mechanisms (a saturated design whose REML optimum is
146/// maximum smoothing; a df floor that iterated an emptied penalty list). Naming
147/// the state by its *outcome* rather than by either route is what makes one
148/// check cover both, and any third route that lands here.
149///
150/// The two bounds are what separate a collapse from an ordinary answer:
151///
152/// * `spent <= 0.5` — less than half of ONE direction retained out of every
153/// direction on offer. `hifreq_tensor_k10` measured `edf = 1.294` against
154/// `mp = 1`, i.e. `spent = 0.294` of an attainable 575.
155/// * `attainable >= 20` — a single smooth term legitimately shrinks to its own
156/// null space whenever the truth really is linear, and a `k = 10` marginal
157/// offers only ~8 penalized directions, so firing there would report a
158/// correct model selection as a defect. Twenty is the point past which a
159/// design has been given substantial flexibility and kept none of it; it is a
160/// threshold on how much was discarded, not on how well the fit did.
161///
162/// Returns `false` for any non-finite input rather than warning about arithmetic
163/// that has already failed somewhere upstream.
164pub fn collapsed_to_penalty_null_space(
165 edf_total: f64,
166 coefficient_count: usize,
167 joint_penalty_nullity: f64,
168) -> bool {
169 /// Penalized directions a design must offer before keeping none of them is
170 /// reported as a collapse rather than as a linear truth being found.
171 const MIN_ATTAINABLE_DIRECTIONS: f64 = 20.0;
172 /// Retained penalized df, below which the fit IS its penalty null model.
173 const MAX_RETAINED_DF: f64 = 0.5;
174 if !edf_total.is_finite() || !joint_penalty_nullity.is_finite() {
175 return false;
176 }
177 let p = coefficient_count as f64;
178 let mp = joint_penalty_nullity.clamp(0.0, p);
179 let attainable = p - mp;
180 let spent = edf_total - mp;
181 attainable >= MIN_ATTAINABLE_DIRECTIONS && spent <= MAX_RETAINED_DF
182}
183
184/// Length agreement between the traces and their ranks is a caller contract, not
185/// a runtime condition to recover from: a mismatch means the caller paired the
186/// wrong penalty blocks, and silently zipping to the shorter of the two would
187/// drop a block's complexity from `edf_total` without a word.
188fn assert_blocks_aligned(traces: usize, ranks: usize) {
189 assert_eq!(
190 traces, ranks,
191 "penalized_edf_bundle: {traces} traces against {ranks} block ranks; \
192 they are aligned 1:1 with the penalty blocks"
193 );
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 #[test]
201 fn a_trace_is_admitted_against_its_block_rank_not_its_column_count() {
202 // A rank-2 penalty on a 5-column block: the trace saturates at 2, and
203 // the reported block EDF is measured against 2. Using the column count
204 // as the ceiling would report `5 - 2 = 3` here instead of `0`, which is
205 // exactly the nullity(S_k) = 3 overstatement this accounting exists to
206 // remove.
207 let bundle = penalized_edf_bundle(&[7.0], &[2], 5, 3.0);
208 assert_eq!(bundle.penalty_block_trace, vec![2.0]);
209 assert_eq!(bundle.edf_by_block, vec![0.0]);
210 }
211
212 #[test]
213 fn a_positive_overflow_trace_resolves_to_the_saturated_rank() {
214 // A ceiling-λ redundant block overflows to +inf on a ridge-stabilized
215 // Hessian; the true penalized trace is the block rank.
216 let bundle = penalized_edf_bundle(&[f64::INFINITY], &[3], 6, 3.0);
217 assert_eq!(bundle.penalty_block_trace, vec![3.0]);
218 assert_eq!(bundle.edf_by_block, vec![0.0]);
219 assert!(bundle.edf_total.is_finite());
220 }
221
222 #[test]
223 fn a_nan_or_negative_overflow_trace_is_not_admitted_as_saturation() {
224 // NaN carries no value and -inf points the way a nonnegative trace
225 // cannot go; neither has the +inf saturation limit. Resolving either to
226 // the rank fabricated a finite EDF from a failed computation, so both
227 // propagate as NaN for the fit-result finiteness validators to refuse.
228 for raw in [f64::NAN, f64::NEG_INFINITY] {
229 let bundle = penalized_edf_bundle(&[raw], &[3], 6, 3.0);
230 assert!(
231 bundle.penalty_block_trace[0].is_nan(),
232 "raw trace {raw} must not be admitted at the block rank"
233 );
234 assert!(bundle.edf_by_block[0].is_nan());
235 assert!(bundle.edf_total.is_nan());
236 }
237 }
238
239 #[test]
240 fn a_negative_trace_is_admitted_at_zero() {
241 let bundle = penalized_edf_bundle(&[-0.25], &[4], 4, 0.0);
242 assert_eq!(bundle.penalty_block_trace, vec![0.0]);
243 assert_eq!(bundle.edf_by_block, vec![4.0]);
244 }
245
246 #[test]
247 fn edf_total_cannot_fall_below_the_joint_penalty_null_space() {
248 // p = 10 with mp = 3 unpenalized directions. Even a fully saturated
249 // penalty cannot remove them, so the floor is 3, not 0. A `[0, p]` clamp
250 // would report 0 here — an effective dimension below the mathematically
251 // attainable minimum, with nothing downstream to notice.
252 let bundle = penalized_edf_bundle(&[7.0], &[7], 10, 3.0);
253 assert_eq!(bundle.edf_total, 3.0);
254 }
255
256 #[test]
257 fn edf_total_is_p_minus_the_admitted_traces_when_interior() {
258 let bundle = penalized_edf_bundle(&[1.5, 2.25], &[4, 5], 12, 3.0);
259 assert_eq!(bundle.penalty_block_trace, vec![1.5, 2.25]);
260 assert_eq!(bundle.edf_by_block, vec![2.5, 2.75]);
261 assert_eq!(bundle.edf_total, 12.0 - 3.75);
262 }
263
264 #[test]
265 fn an_unpenalized_fit_reports_every_coefficient() {
266 let bundle = penalized_edf_bundle(&[], &[], 6, 6.0);
267 assert_eq!(bundle.edf_total, 6.0);
268 assert!(bundle.edf_by_block.is_empty());
269 assert!(bundle.penalty_block_trace.is_empty());
270 }
271
272 #[test]
273 #[should_panic(expected = "aligned 1:1 with the penalty blocks")]
274 fn mismatched_traces_and_ranks_are_refused_not_zipped_short() {
275 penalized_edf_bundle(&[1.0, 2.0], &[3], 5, 0.0);
276 }
277
278 #[test]
279 fn the_state_2607_recorded_is_detected_by_its_outcome() {
280 // The numbers `hifreq_tensor_k10` reported while it was saturated:
281 // `edf = 1.294` of `p = 576`, joint penalty nullity 1 (the intercept —
282 // a te() double penalty leaves nothing else unpenalized). Every one of
283 // the 575 penalized directions was smoothed away, the fit reported
284 // `Converged`, and nothing said so.
285 assert!(collapsed_to_penalty_null_space(1.294, 576, 1.0));
286 // The same fixture BEFORE the collapse, from the history #2585 records:
287 // `edf = 227.938` of the same 576. Same design, same nullity — only the
288 // outcome differs, which is the whole point of naming the state by its
289 // outcome.
290 assert!(!collapsed_to_penalty_null_space(227.938, 576, 1.0));
291 }
292
293 #[test]
294 fn a_linear_truth_under_one_smooth_is_a_selection_not_a_collapse() {
295 // A `k = 10` marginal offers ~8 penalized directions on top of a
296 // 2-dimensional null space. When the truth really is linear, REML
297 // shrinking that smooth to exactly its null space is the CORRECT
298 // answer, and reporting it as a collapse would turn a good model
299 // selection into a warning on a large fraction of honest fits.
300 assert!(!collapsed_to_penalty_null_space(2.0, 10, 2.0));
301 // Widen the same shape past the point where keeping nothing stops being
302 // an ordinary selection, holding `spent` fixed at zero: the bound is on
303 // how much was discarded, so this and the case above must disagree.
304 assert!(collapsed_to_penalty_null_space(2.0, 30, 2.0));
305 }
306
307 #[test]
308 fn an_unpenalized_design_can_never_collapse() {
309 // `mp = p` means λ selects over nothing at all: `edf_total` is pinned to
310 // `p` by construction, so there is no collapse available to report and a
311 // predicate keyed on `spent` alone would fire on every such fit.
312 assert!(!collapsed_to_penalty_null_space(40.0, 40, 40.0));
313 }
314
315 #[test]
316 fn a_non_finite_edf_is_not_reported_as_a_collapse() {
317 // NaN compares false against every bound, so `spent <= 0.5` would be
318 // false for NaN but true for −inf. Neither is a statement about
319 // smoothing: the arithmetic already failed upstream, and the finiteness
320 // validators own that.
321 for edf in [f64::NAN, f64::NEG_INFINITY, f64::INFINITY] {
322 assert!(
323 !collapsed_to_penalty_null_space(edf, 576, 1.0),
324 "non-finite edf {edf} is an upstream arithmetic failure, not a collapse"
325 );
326 }
327 assert!(!collapsed_to_penalty_null_space(1.294, 576, f64::NAN));
328 }
329}