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/// The three EDF quantities a fit publishes, produced together so they cannot
35/// disagree with one another.
36#[derive(Clone, Debug, PartialEq)]
37pub struct EdfBundle {
38    /// `p − Σ_k tr_k`, clamped to `[mp, p]`.
39    pub edf_total: f64,
40    /// `rank_k − tr_k` per penalty block, clamped to `[0, rank_k]`.
41    pub edf_by_block: Vec<f64>,
42    /// The admitted per-block traces `tr_k`, each clamped to `[0, rank_k]`.
43    /// Retained because the per-term EDF decomposition is assembled from them
44    /// (issue #1219), so downstream must read the same numbers this accounting
45    /// used rather than re-clamping the raw values itself.
46    pub penalty_block_trace: Vec<f64>,
47}
48
49/// Admit one raw penalty trace against its block rank.
50///
51/// A PSD penalty can absorb at most its own rank, so `tr_k` is mathematically
52/// confined to `[0, rank_k]`. When the outer optimizer drives a redundant
53/// block's `λ_k = exp(ρ_k)` to the ceiling, the raw product `λ_k·frob` can
54/// overflow to `+∞` on a ridge-stabilized Hessian even though the true value is
55/// exactly `rank_k` (gam#1379). `f64::clamp` does **not** rescue NaN — it
56/// propagates it — and a NaN would reach the fit-result finiteness validator, so
57/// any non-finite product resolves to the saturated bound, which is where the
58/// `+∞` case lands anyway.
59fn admit_trace(raw: f64, rank: usize) -> f64 {
60    let ceiling = rank as f64;
61    if raw.is_finite() {
62        raw.clamp(0.0, ceiling)
63    } else {
64        ceiling
65    }
66}
67
68/// Assemble the EDF bundle from already-computed per-block penalty traces.
69///
70/// `raw_block_traces` and `block_ranks` are aligned 1:1 with the penalty blocks.
71/// `coefficient_count` is `p`. `joint_penalty_nullity` is `mp = p − rank(Σ_k S_k)`,
72/// taken as a parameter rather than derived from `block_ranks`: the joint rank is
73/// the rank of the *stacked* penalty root, which is not in general the sum of the
74/// per-block ranks.
75///
76/// Traces are summed with compensated (Kahan) addition because `edf_total` is a
77/// difference of two like-sized quantities, where naive summation error lands
78/// directly in the reported effective dimension.
79pub fn penalized_edf_bundle(
80    raw_block_traces: &[f64],
81    block_ranks: &[usize],
82    coefficient_count: usize,
83    joint_penalty_nullity: f64,
84) -> EdfBundle {
85    assert_blocks_aligned(raw_block_traces.len(), block_ranks.len());
86    let penalty_block_trace: Vec<f64> = raw_block_traces
87        .iter()
88        .zip(block_ranks.iter())
89        .map(|(&raw, &rank)| admit_trace(raw, rank))
90        .collect();
91    let edf_by_block: Vec<f64> = penalty_block_trace
92        .iter()
93        .zip(block_ranks.iter())
94        .map(|(&trace, &rank)| {
95            let ceiling = rank as f64;
96            (ceiling - trace).clamp(0.0, ceiling)
97        })
98        .collect();
99    let p = coefficient_count as f64;
100    let edf_total = (p - super::penalty::kahan_sum(penalty_block_trace.iter().copied()))
101        .clamp(joint_penalty_nullity.min(p), p);
102    EdfBundle {
103        edf_total,
104        edf_by_block,
105        penalty_block_trace,
106    }
107}
108
109/// Length agreement between the traces and their ranks is a caller contract, not
110/// a runtime condition to recover from: a mismatch means the caller paired the
111/// wrong penalty blocks, and silently zipping to the shorter of the two would
112/// drop a block's complexity from `edf_total` without a word.
113fn assert_blocks_aligned(traces: usize, ranks: usize) {
114    assert_eq!(
115        traces, ranks,
116        "penalized_edf_bundle: {traces} traces against {ranks} block ranks; \
117         they are aligned 1:1 with the penalty blocks"
118    );
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn a_trace_is_admitted_against_its_block_rank_not_its_column_count() {
127        // A rank-2 penalty on a 5-column block: the trace saturates at 2, and
128        // the reported block EDF is measured against 2. Using the column count
129        // as the ceiling would report `5 - 2 = 3` here instead of `0`, which is
130        // exactly the nullity(S_k) = 3 overstatement this accounting exists to
131        // remove.
132        let bundle = penalized_edf_bundle(&[7.0], &[2], 5, 3.0);
133        assert_eq!(bundle.penalty_block_trace, vec![2.0]);
134        assert_eq!(bundle.edf_by_block, vec![0.0]);
135    }
136
137    #[test]
138    fn a_non_finite_trace_resolves_to_the_saturated_rank_not_nan() {
139        // A ceiling-λ redundant block overflows to +inf on a ridge-stabilized
140        // Hessian; the true penalized trace is the block rank. NaN must resolve
141        // the same way — `f64::clamp` propagates NaN, so this cannot be left to
142        // the clamp alone.
143        for raw in [f64::INFINITY, f64::NAN] {
144            let bundle = penalized_edf_bundle(&[raw], &[3], 6, 3.0);
145            assert_eq!(
146                bundle.penalty_block_trace,
147                vec![3.0],
148                "non-finite raw trace {raw} must saturate at the block rank"
149            );
150            assert!(bundle.edf_total.is_finite());
151        }
152    }
153
154    #[test]
155    fn a_negative_trace_is_admitted_at_zero() {
156        let bundle = penalized_edf_bundle(&[-0.25], &[4], 4, 0.0);
157        assert_eq!(bundle.penalty_block_trace, vec![0.0]);
158        assert_eq!(bundle.edf_by_block, vec![4.0]);
159    }
160
161    #[test]
162    fn edf_total_cannot_fall_below_the_joint_penalty_null_space() {
163        // p = 10 with mp = 3 unpenalized directions. Even a fully saturated
164        // penalty cannot remove them, so the floor is 3, not 0. A `[0, p]` clamp
165        // would report 0 here — an effective dimension below the mathematically
166        // attainable minimum, with nothing downstream to notice.
167        let bundle = penalized_edf_bundle(&[7.0], &[7], 10, 3.0);
168        assert_eq!(bundle.edf_total, 3.0);
169    }
170
171    #[test]
172    fn edf_total_is_p_minus_the_admitted_traces_when_interior() {
173        let bundle = penalized_edf_bundle(&[1.5, 2.25], &[4, 5], 12, 3.0);
174        assert_eq!(bundle.penalty_block_trace, vec![1.5, 2.25]);
175        assert_eq!(bundle.edf_by_block, vec![2.5, 2.75]);
176        assert_eq!(bundle.edf_total, 12.0 - 3.75);
177    }
178
179    #[test]
180    fn an_unpenalized_fit_reports_every_coefficient() {
181        let bundle = penalized_edf_bundle(&[], &[], 6, 6.0);
182        assert_eq!(bundle.edf_total, 6.0);
183        assert!(bundle.edf_by_block.is_empty());
184        assert!(bundle.penalty_block_trace.is_empty());
185    }
186
187    #[test]
188    #[should_panic(expected = "aligned 1:1 with the penalty blocks")]
189    fn mismatched_traces_and_ranks_are_refused_not_zipped_short() {
190        penalized_edf_bundle(&[1.0, 2.0], &[3], 5, 0.0);
191    }
192}