Skip to main content

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}