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}