gam_problem/seeding.rs
1#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2pub enum SeedRiskProfile {
3 Gaussian,
4 /// Gaussian location-scale keeps Gaussian's lowest-REML keep-best policy,
5 /// but its non-profiled log-scale predictor has the same capped-screening
6 /// over-smoothing risk as multi-parameter likelihoods.
7 GaussianLocationScale,
8 GeneralizedLinear,
9 Survival,
10}
11
12impl SeedRiskProfile {
13 #[inline]
14 pub const fn anchor_rho_shift(self) -> f64 {
15 match self {
16 Self::Gaussian | Self::GaussianLocationScale => 0.0,
17 Self::GeneralizedLinear => 1.0,
18 Self::Survival => 2.0,
19 }
20 }
21
22 #[inline]
23 pub const fn baseline_centers(self) -> &'static [f64] {
24 match self {
25 Self::Gaussian | Self::GaussianLocationScale => &[0.0, -3.0, 3.0, -6.0, 6.0],
26 Self::GeneralizedLinear => &[0.0, 2.0, 4.0, -2.0],
27 Self::Survival => &[0.0, 2.0, 4.0, 6.0],
28 }
29 }
30
31 #[inline]
32 pub const fn global_shifts(self) -> &'static [f64] {
33 match self {
34 Self::Gaussian | Self::GaussianLocationScale => &[-2.0, 2.0, -4.0, 4.0],
35 Self::GeneralizedLinear => &[0.0, 2.0, 4.0, -1.0, -2.0, -4.0],
36 Self::Survival => &[0.0, 2.0, 4.0, 6.0, -2.0, -4.0],
37 }
38 }
39
40 #[inline]
41 pub const fn exploratory_amplitude(self) -> f64 {
42 match self {
43 Self::Gaussian | Self::GaussianLocationScale => 2.0,
44 Self::GeneralizedLinear => 2.5,
45 Self::Survival => 3.0,
46 }
47 }
48
49 #[inline]
50 pub const fn promotes_interior_seed_extremes(self) -> bool {
51 // Plain Gaussian REML's profiled-scale basin does NOT exhibit the
52 // capped-screening over-smoothing bias the other profiles do, so it does
53 // not need the flexible slot-0 promotion for its own sake. But a
54 // weak-signal Gaussian fit on an over-rich spatial basis has the
55 // OPPOSITE failure: REML descends from the heuristic anchor into the
56 // flexible (low-λ) basin and over-fits (#1074 quakes: edf≈104 vs mgcv≈15,
57 // held-out R²≈0.02), because the heavily-penalized basin is a separate
58 // attractor never seeded/solved. Promoting the heaviest INTERIOR seed to
59 // the second full-budget slot (paired with the Gaussian over-smoothing
60 // probe and seed_budget≥2 in `external_reml_seed_config`) lets the
61 // multi-start SEE that basin; Gaussian's lowest-cost keep-best
62 // (`uses_lowest_cost_keep_best`) then adopts it only when it scores a
63 // strictly lower REML, so this can never worsen a flexible fit.
64 matches!(
65 self,
66 Self::Gaussian | Self::GaussianLocationScale | Self::GeneralizedLinear | Self::Survival
67 )
68 }
69
70 #[inline]
71 pub const fn uses_parsimonious_keep_best(self) -> bool {
72 matches!(self, Self::GeneralizedLinear | Self::Survival)
73 }
74
75 #[inline]
76 pub const fn uses_lowest_cost_keep_best(self) -> bool {
77 matches!(self, Self::Gaussian | Self::GaussianLocationScale)
78 }
79}
80
81#[derive(Clone, Copy, Debug)]
82pub struct SeedConfig {
83 pub bounds: (f64, f64),
84 pub max_seeds: usize,
85 /// Maximum number of seed starts to run in heuristic order.
86 pub seed_budget: usize,
87 /// Initial inner-iteration cap used while ranking candidate seeds.
88 pub screen_max_inner_iterations: usize,
89 pub risk_profile: SeedRiskProfile,
90 /// Number of trailing dimensions that are auxiliary parameters rather than
91 /// log-smoothing parameters.
92 pub num_auxiliary_trailing: usize,
93 /// Optional absolute over-smoothing probe on every smoothing dimension.
94 pub over_smoothing_probe_rho: Option<f64>,
95}
96
97impl Default for SeedConfig {
98 fn default() -> Self {
99 Self {
100 bounds: (-12.0, 12.0),
101 max_seeds: 12,
102 seed_budget: 2,
103 screen_max_inner_iterations: 3,
104 risk_profile: SeedRiskProfile::GeneralizedLinear,
105 num_auxiliary_trailing: 0,
106 over_smoothing_probe_rho: None,
107 }
108 }
109}
110
111/// A validated, finite, ordered ρ (log-λ) seed interval `[lo, hi]`.
112///
113/// Every seed clamp in the outer-optimizer prepass and the candidate lattice
114/// derives a trial ρ and pins it into a single uniform box assembled from two
115/// *independently-owned* constants — the outer ρ lower wall
116/// (`options.rho_lower_bound`) and an over-smoothing ceiling (`RHO_BOUND` or an
117/// effective-df crossing). When those constants drift apart the interval inverts
118/// (`lo > hi`): the #2370 disease, where an edf-ceiling that used to equal
119/// `-rho_lower_bound` was moved by #2356 and the emitted upper bound dropped
120/// below the lower one. The historical response — *silently swapping* the pair
121/// (`normalize_seed_bounds`) — does not make the fit correct; it makes the
122/// optimizer solve a *different, silently substituted* box and return a model as
123/// if nothing were wrong. That is strictly worse than the panic it replaced: a
124/// panic is loud, a silently-wrong λ-box is not.
125///
126/// This type makes the inverted state unrepresentable. It is constructed only
127/// through [`OrderedRhoBounds::new`], which refuses an inverted or non-finite
128/// interval with the same typed [`EstimationError::InvalidInput`] the outer
129/// entry (`run_outer_uncertified`) now enforces (#2379 / #2370). Every downstream
130/// clamp then operates on an interval that is ordered *by construction*, so
131/// `f64::clamp`'s `min <= max` precondition can never be violated.
132#[derive(Clone, Copy, Debug, PartialEq)]
133pub struct OrderedRhoBounds {
134 lo: f64,
135 hi: f64,
136}
137
138impl OrderedRhoBounds {
139 /// Validate and wrap a `[lo, hi]` ρ interval. Refuses (rather than silently
140 /// reorders) an inverted (`lo > hi`) or non-finite interval, naming both
141 /// endpoints. `lo == hi` is a valid degenerate single-point box.
142 pub fn new(lo: f64, hi: f64) -> Result<Self, crate::estimation_error::EstimationError> {
143 if !lo.is_finite() || !hi.is_finite() || lo > hi {
144 return Err(crate::estimation_error::EstimationError::InvalidInput(format!(
145 "seed ρ-box is inverted or non-finite: lower={lo}, upper={hi}; an \
146 inverted box means the ρ lower wall and the over-smoothing ceiling \
147 have drifted apart (cf. #2370) — refusing rather than silently \
148 reordering the interval (#2379)"
149 )));
150 }
151 Ok(Self { lo, hi })
152 }
153
154 /// The (validated) lower endpoint.
155 #[inline]
156 pub fn lower(self) -> f64 {
157 self.lo
158 }
159
160 /// The (validated) upper endpoint.
161 #[inline]
162 pub fn upper(self) -> f64 {
163 self.hi
164 }
165
166 /// Clamp `value` into `[lo, hi]`. Infallible: the interval is ordered by
167 /// construction, so `f64::clamp`'s `min <= max` precondition always holds.
168 #[inline]
169 pub fn clamp(self, value: f64) -> f64 {
170 value.clamp(self.lo, self.hi)
171 }
172
173 /// Raise the upper endpoint to at least `floor`, preserving orderedness.
174 ///
175 /// The criterion-ranked prepass widens its over-smoothing bound to the full
176 /// range the outer optimizer can reach (`RHO_BOUND`) so a genuinely large λ
177 /// seed is not clipped to the seed band. This only ever *raises* `hi`, so the
178 /// interval stays valid by construction. A non-finite `floor` is ignored to
179 /// preserve the finiteness invariant (callers pass the finite `RHO_BOUND`).
180 #[inline]
181 pub fn with_upper_at_least(self, floor: f64) -> Self {
182 if floor.is_finite() && floor > self.hi {
183 Self {
184 lo: self.lo,
185 hi: floor,
186 }
187 } else {
188 self
189 }
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 // ── anchor_rho_shift ───────────────────────────────────────────────────────
198
199 #[test]
200 fn anchor_rho_shift_gaussian_is_zero() {
201 assert_eq!(SeedRiskProfile::Gaussian.anchor_rho_shift(), 0.0);
202 assert_eq!(
203 SeedRiskProfile::GaussianLocationScale.anchor_rho_shift(),
204 0.0
205 );
206 }
207
208 #[test]
209 fn anchor_rho_shift_generalized_linear_is_one() {
210 assert_eq!(SeedRiskProfile::GeneralizedLinear.anchor_rho_shift(), 1.0);
211 }
212
213 #[test]
214 fn anchor_rho_shift_survival_is_two() {
215 assert_eq!(SeedRiskProfile::Survival.anchor_rho_shift(), 2.0);
216 }
217
218 // ── promotes_interior_seed_extremes ───────────────────────────────────────
219
220 #[test]
221 fn promotes_interior_extremes_for_all_profiles() {
222 // #1074: plain Gaussian was originally excluded (its profiled-scale REML
223 // basin has no capped-screening over-smoothing bias), but a weak-signal
224 // Gaussian fit on an over-rich basis has the OPPOSITE failure — it
225 // descends into the flexible (low-λ) basin and over-fits. Promoting the
226 // heaviest interior seed to the second full-budget slot (paired with the
227 // over-smoothing probe + `seed_budget ≥ 2`) lets the multi-start SEE the
228 // heavily-penalized basin; Gaussian's lowest-cost keep-best then adopts
229 // it only when it scores a strictly lower REML, so this can never worsen
230 // a flexible fit. Every risk profile now promotes the interior extremes.
231 assert!(SeedRiskProfile::Gaussian.promotes_interior_seed_extremes());
232 assert!(SeedRiskProfile::GaussianLocationScale.promotes_interior_seed_extremes());
233 assert!(SeedRiskProfile::GeneralizedLinear.promotes_interior_seed_extremes());
234 assert!(SeedRiskProfile::Survival.promotes_interior_seed_extremes());
235 }
236
237 // ── keep-best policy flags ────────────────────────────────────────────────
238
239 #[test]
240 fn parsimonious_keep_best_only_for_glm_and_survival() {
241 assert!(!SeedRiskProfile::Gaussian.uses_parsimonious_keep_best());
242 assert!(!SeedRiskProfile::GaussianLocationScale.uses_parsimonious_keep_best());
243 assert!(SeedRiskProfile::GeneralizedLinear.uses_parsimonious_keep_best());
244 assert!(SeedRiskProfile::Survival.uses_parsimonious_keep_best());
245 }
246
247 #[test]
248 fn lowest_cost_keep_best_only_for_gaussian_variants() {
249 assert!(SeedRiskProfile::Gaussian.uses_lowest_cost_keep_best());
250 assert!(SeedRiskProfile::GaussianLocationScale.uses_lowest_cost_keep_best());
251 assert!(!SeedRiskProfile::GeneralizedLinear.uses_lowest_cost_keep_best());
252 assert!(!SeedRiskProfile::Survival.uses_lowest_cost_keep_best());
253 }
254
255 // ── OrderedRhoBounds (#2379) ──────────────────────────────────────────────
256 // The validated-interval type that REPLACES the silent swap on every seed
257 // clamp: an inverted box must be a typed refusal, never a reordered interval.
258
259 #[test]
260 fn ordered_rho_bounds_accepts_ordered_interval() {
261 let b = OrderedRhoBounds::new(-12.0, 12.0).expect("ordered interval is valid");
262 assert_eq!(b.lower(), -12.0);
263 assert_eq!(b.upper(), 12.0);
264 }
265
266 #[test]
267 fn ordered_rho_bounds_accepts_degenerate_point_interval() {
268 // lo == hi is a valid single-point box (matches opt::Bounds, which uses
269 // `lower > upper` as the inversion test).
270 let b = OrderedRhoBounds::new(2.0, 2.0).expect("point interval is valid");
271 assert_eq!(b.clamp(5.0), 2.0);
272 assert_eq!(b.clamp(-5.0), 2.0);
273 }
274
275 #[test]
276 fn ordered_rho_bounds_refuses_inverted_interval_with_typed_error() {
277 // This is the #2379 contract: an inverted seed-bound pair reaching the
278 // seed path is a typed refusal, NOT a silently reordered box. The exact
279 // scenario from #2370 — lower = -10 (the ρ lower wall) above an
280 // independently-derived edf ceiling of -11.855.
281 let err = OrderedRhoBounds::new(-10.0, -11.855)
282 .expect_err("an inverted box must be refused, not swapped");
283 match err {
284 crate::estimation_error::EstimationError::InvalidInput(msg) => {
285 // Both endpoints are named, so a drift is diagnosable from the error.
286 assert!(msg.contains("-10"), "error names the lower bound: {msg}");
287 assert!(msg.contains("-11.855"), "error names the upper bound: {msg}");
288 assert!(msg.contains("invert"), "error explains the inversion: {msg}");
289 }
290 other => panic!("expected InvalidInput, got {other:?}"),
291 }
292 }
293
294 #[test]
295 fn ordered_rho_bounds_refuses_non_finite_interval() {
296 assert!(OrderedRhoBounds::new(f64::NAN, 12.0).is_err());
297 assert!(OrderedRhoBounds::new(-12.0, f64::INFINITY).is_err());
298 assert!(OrderedRhoBounds::new(f64::NEG_INFINITY, 12.0).is_err());
299 }
300
301 #[test]
302 fn ordered_rho_bounds_clamp_respects_both_ends() {
303 let b = OrderedRhoBounds::new(-3.0, 5.0).unwrap();
304 assert_eq!(b.clamp(1.0), 1.0);
305 assert_eq!(b.clamp(-10.0), -3.0);
306 assert_eq!(b.clamp(100.0), 5.0);
307 }
308
309 #[test]
310 fn ordered_rho_bounds_with_upper_only_raises_and_stays_ordered() {
311 let b = OrderedRhoBounds::new(-12.0, 8.0).unwrap();
312 // Widening to a larger ceiling raises the upper endpoint.
313 let widened = b.with_upper_at_least(30.0);
314 assert_eq!(widened.lower(), -12.0);
315 assert_eq!(widened.upper(), 30.0);
316 // Widening to a floor already below `hi` is a no-op (never lowers `hi`).
317 let unchanged = b.with_upper_at_least(2.0);
318 assert_eq!(unchanged.upper(), 8.0);
319 // A non-finite floor is ignored so the finiteness invariant is preserved.
320 let finite = b.with_upper_at_least(f64::INFINITY);
321 assert!(finite.upper().is_finite());
322 assert_eq!(finite.upper(), 8.0);
323 }
324}