gam_terms/analytic_penalties/penalty_trait.rs
1use super::*;
2
3pub(crate) const MIN_CONDITIONAL_PRECISION: f64 = 1.0e-12;
4
5/// Half-width of the open-interval clamp `[ε, 1−ε]` applied to IBP-assignment
6/// probabilities before `ln`/`1/p` so the Bernoulli cross-entropy and its score
7/// stay finite at the simplex boundary.
8pub(crate) const IBP_PROBABILITY_CLAMP: f64 = 1.0e-12;
9
10/// Interior tolerance for the IBP straight-through Bernoulli mean: the
11/// pass-through Jacobian `∂π/∂(mass)` is taken only when the unclamped mean lies
12/// strictly inside `(δ, 1−δ)`; at the saturated boundary the gradient is zero.
13pub(crate) const IBP_INTERIOR_TOL: f64 = 1.0e-9;
14
15/// Floor on the IBP posterior-count denominator `n + a − 1`, guarding the
16/// per-component mean against a zero (or negative) effective count.
17pub(crate) const IBP_COUNT_DENOM_FLOOR: f64 = 1.0e-9;
18
19// ---------------------------------------------------------------------------
20// Common trait
21// ---------------------------------------------------------------------------
22
23/// Whether a penalty's target is a slice of `β` (decoder coefficients), a
24/// slice of extension coordinates (per-observation latent field, e.g.
25/// `LatentCoordValues`),
26/// or a slice of `ρ` (a hyperparameter sub-block — rare, used by hyperpriors
27/// that we don't yet ship analytically).
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum PenaltyTier {
30 Beta,
31 Psi,
32 Rho,
33}
34
35/// Reference for the column / coordinate range a penalty operates over.
36///
37/// Mirrors `BlockwisePenalty::col_range` for the β tier and is the natural
38/// per-observation flat index for the extension-coordinate tier (matching the
39/// `LatentCoordValues` row-major flat layout: `n * d + a`).
40#[derive(Debug, Clone)]
41pub struct PsiSlice {
42 /// Inclusive-start, exclusive-end flat range into the underlying ext-coordinate vector.
43 pub range: std::ops::Range<usize>,
44 /// For latent-coordinate slices: the latent dimensionality, used to
45 /// reshape the flat slice into per-row `(n_obs, d)` blocks.
46 pub latent_dim: Option<usize>,
47}
48
49impl PsiSlice {
50 #[must_use]
51 pub fn full(len: usize, latent_dim: Option<usize>) -> Self {
52 Self {
53 range: 0..len,
54 latent_dim,
55 }
56 }
57
58 pub fn len(&self) -> usize {
59 self.range.len()
60 }
61
62 pub fn is_empty(&self) -> bool {
63 self.range.is_empty()
64 }
65}
66
67/// Resolve a learnable penalty strength `base_weight · exp(rho)` without ever
68/// overflowing to `inf` or (for a nonzero base weight) underflowing to exact
69/// `0.0`.
70///
71/// For finite `rho ≳ 709` the naive `base_weight * rho.exp()` overflows to
72/// `inf`; the resulting `inf` then poisons the solve via `inf · 0.0 = NaN` or
73/// `inf / inf = NaN` in the value/grad/Hessian. Conversely for `rho ≲ -745`
74/// `rho.exp()` underflows to `0.0`, silently disabling a penalty whose base
75/// weight is strictly positive and reintroducing `0/0` in ratios that divide by
76/// the strength.
77///
78/// The fix is to evaluate the product in log-space and clamp the *log-strength*
79/// into the finite-normal band before exponentiating, so the returned strength
80/// is always finite (and strictly positive whenever `base_weight ≠ 0`). The
81/// clamp band is symmetric in log-strength about zero, matched to the largest /
82/// smallest positive normal `f64`, leaving a safety margin so subsequent
83/// multiplications by `O(1)` factors stay finite.
84pub fn resolve_learnable_weight(base_weight: f64, rho: f64) -> f64 {
85 // Largest / smallest log-magnitude that keeps the strength a finite normal
86 // `f64` with headroom for downstream `O(1)` arithmetic.
87 const MAX_LOG_STRENGTH: f64 = 700.0;
88 const MIN_LOG_STRENGTH: f64 = -700.0;
89 if base_weight == 0.0 {
90 return 0.0;
91 }
92 assert!(
93 base_weight.is_finite() && rho.is_finite(),
94 "resolve_learnable_weight requires finite inputs; got base_weight={base_weight}, rho={rho}"
95 );
96 let log_strength = base_weight.abs().ln() + rho;
97 let clamped = log_strength.clamp(MIN_LOG_STRENGTH, MAX_LOG_STRENGTH);
98 clamped.exp().copysign(base_weight)
99}
100
101/// Exponentiate a learnable log-precision `exp(log_alpha)` with the exponent
102/// clamped into the finite-normal band, returning a finite, strictly-positive
103/// precision.
104///
105/// A raw `log_alpha.exp()` overflows to `inf` for `log_alpha ≳ 709` (an `inf`
106/// precision then poisons the ARD value/grad/Hessian via `inf · 0.0 = NaN`) and
107/// underflows to exact `0.0` for `log_alpha ≲ -745` (a zero precision drops a
108/// prior the term still expects to be positive). Clamping the exponent and
109/// flooring at the smallest positive normal keeps the precision a finite,
110/// strictly-positive `f64` while still spanning arbitrarily small / large
111/// values within range (#742, Issue 4).
112pub(crate) fn stable_exp_log_precision(log_alpha: f64) -> f64 {
113 const MAX_LOG_STRENGTH: f64 = 700.0;
114 const MIN_LOG_STRENGTH: f64 = -700.0;
115 log_alpha
116 .clamp(MIN_LOG_STRENGTH, MAX_LOG_STRENGTH)
117 .exp()
118 .max(f64::MIN_POSITIVE)
119}
120
121/// Scalar annealing schedule for analytic penalty weights.
122///
123/// This is the penalty-weight analogue of [`crate::terms::sae::manifold::GumbelTemperatureSchedule`]:
124/// it starts with a weak analytic regularizer and ramps toward the target
125/// weight during REML outer iterations. This follows the standard annealed
126/// regularization pattern in deep learning, where optimization first finds
127/// good fits before stronger structure constrains the solution. It also
128/// addresses the general observation that hand-picked analytic weights
129/// materially affect outcomes — fixed tight auxiliary scales can outperform
130/// learned weights on one dataset and underperform on another. A schedule
131/// side-steps that brittle initial choice by ramping the constraint.
132#[derive(Debug, Clone)]
133pub struct ScalarWeightSchedule {
134 pub w_start: f64,
135 pub w_end: f64,
136 pub kind: ScheduleKind,
137 pub iter_count: usize,
138}
139
140impl ScalarWeightSchedule {
141 #[must_use = "build error must be handled"]
142 pub fn new(w_start: f64, w_end: f64, kind: ScheduleKind) -> Result<Self, String> {
143 let schedule = Self {
144 w_start,
145 w_end,
146 kind,
147 iter_count: 0,
148 };
149 schedule.validate()?;
150 Ok(schedule)
151 }
152
153 pub fn validate(&self) -> Result<(), String> {
154 if !(self.w_start.is_finite() && self.w_start >= 0.0) {
155 return Err(format!(
156 "ScalarWeightSchedule: w_start must be finite and non-negative; got {}",
157 self.w_start
158 ));
159 }
160 if !(self.w_end.is_finite() && self.w_end >= 0.0) {
161 return Err(format!(
162 "ScalarWeightSchedule: w_end must be finite and non-negative; got {}",
163 self.w_end
164 ));
165 }
166 match &self.kind {
167 ScheduleKind::Geometric { rate } => {
168 if !(rate.is_finite() && *rate > 0.0 && *rate < 1.0) {
169 return Err(format!(
170 "ScalarWeightSchedule::Geometric: rate must be in (0, 1); got {rate}"
171 ));
172 }
173 }
174 ScheduleKind::Linear { steps } => {
175 if *steps == 0 {
176 return Err("ScalarWeightSchedule::Linear: steps must be positive".into());
177 }
178 }
179 ScheduleKind::ReciprocalIter => {}
180 }
181 Ok(())
182 }
183
184 pub fn current_weight(&self, iter: usize) -> f64 {
185 let delta = self.w_end - self.w_start;
186 let raw = match &self.kind {
187 ScheduleKind::Geometric { rate } => self.w_end - delta * rate.powf(iter as f64),
188 ScheduleKind::Linear { steps } => {
189 if iter >= *steps {
190 self.w_end
191 } else {
192 let frac = iter as f64 / *steps as f64;
193 self.w_start + frac * delta
194 }
195 }
196 ScheduleKind::ReciprocalIter => self.w_end - delta / (1.0 + iter as f64),
197 };
198 raw.clamp(self.w_start.min(self.w_end), self.w_start.max(self.w_end))
199 }
200
201 pub fn step(&mut self) -> f64 {
202 let weight = self.current_weight(self.iter_count);
203 self.iter_count += 1;
204 weight
205 }
206}
207
208/// Uniform interface implemented by every analytic penalty in this module.
209///
210/// `target` is the relevant slice of the β or extension-coordinate vector, viewed as
211/// a flat `ArrayView1`. The owning REML driver is responsible for slicing the
212/// global parameter vector before calling, and for routing the returned
213/// gradient back into the correct global indices.
214pub trait AnalyticPenalty: Send + Sync {
215 /// Tier the target lives in (β or ext-coord).
216 fn tier(&self) -> PenaltyTier;
217
218 /// Scalar penalty contribution `P(target; ρ)`. The strength factor
219 /// `exp(ρ)` (or whatever parameterization the penalty uses) is folded in.
220 fn value(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> f64;
221
222 /// Gradient `∂P/∂target`, same length as `target`.
223 fn grad_target(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64>;
224
225 /// Diagonal of the Hessian `diag(∂²P/∂target²)` when the Hessian is
226 /// block-diagonal. Returns `None` for penalties whose Hessian is dense
227 /// (Isometry); those implement [`Self::hvp`] instead. The default
228 /// signals "no closed-form diagonal" by returning `None` for any
229 /// non-empty target — concrete penalties either override with their
230 /// own analytic diagonal or rely on the matrix-free `hvp` path.
231 fn hessian_diag(
232 &self,
233 target: ArrayView1<'_, f64>,
234 rho: ArrayView1<'_, f64>,
235 ) -> Option<Array1<f64>> {
236 assert!(
237 rho.iter().all(|value| value.is_finite()),
238 "analytic-penalty rho must be finite"
239 );
240 if target.is_empty() {
241 Some(Array1::zeros(0))
242 } else {
243 None
244 }
245 }
246
247 /// Hessian-vector product `H v = (∂²P/∂target²) v`, in closed form.
248 ///
249 /// The default covers every penalty whose Hessian is diagonal: it reads the
250 /// analytic [`Self::hessian_diag`] and forms `diag ⊙ v`. Penalties with a
251 /// dense (non-diagonal) Hessian — e.g. `IsometryPenalty`,
252 /// `SheafConsistencyPenalty`, the orthogonality / nuclear-norm family —
253 /// return `None` from `hessian_diag` and supply their own analytic `hvp`
254 /// override (Laplacian/Gram-vector products). There is no finite-difference
255 /// path: a penalty that reaches the default without a closed-form diagonal
256 /// is a programming error and panics rather than silently differencing its
257 /// own gradient (SPEC: finite differences are never used outside tests).
258 fn hvp(
259 &self,
260 target: ArrayView1<'_, f64>,
261 rho: ArrayView1<'_, f64>,
262 v: ArrayView1<'_, f64>,
263 ) -> Array1<f64> {
264 let diag = self.hessian_diag(target, rho).unwrap_or_else(|| {
265 // SAFETY: programming-error invariant, never a runtime/data condition.
266 // A penalty whose Hessian is non-diagonal MUST override `hvp` with its
267 // closed-form Hessian-vector product; reaching this default means the
268 // impl is missing that override. SPEC forbids a finite-difference
269 // fallback outside tests, so there is no recoverable path — failing
270 // loud here is the contract.
271 panic!(
272 "AnalyticPenalty::hvp default reached for `{}`, whose Hessian is \
273 not diagonal (hessian_diag returned None). Such a penalty must \
274 override `hvp` with its closed-form Hessian-vector product; the \
275 default never finite-differences.",
276 self.name()
277 )
278 });
279 assert_eq!(diag.len(), v.len(), "hvp dimension mismatch");
280 let mut out = Array1::<f64>::zeros(v.len());
281 for i in 0..v.len() {
282 out[i] = diag[i] * v[i];
283 }
284 out
285 }
286
287 /// Diagonal of a **PSD majorizer** of the Hessian — the positive
288 /// re-weighted-ℓ₂ / MM surrogate `diag(B(target; ρ))` with
289 /// `B ⪰ ∂²P/∂target²` everywhere and `B ⪰ 0`. This is a *different*
290 /// operator from [`Self::hessian_diag`]: for nonconvex penalties (log
291 /// sparsity, JumpReLU) the exact Hessian is indefinite, but the inner
292 /// Newton / PIRLS solve and the log-det / preconditioner pipeline require
293 /// a PSD curvature block. For convex penalties the majorizer coincides
294 /// with the exact Hessian, so the default simply delegates to
295 /// [`Self::hessian_diag`]; nonconvex penalties override.
296 fn psd_majorizer_diag(
297 &self,
298 target: ArrayView1<'_, f64>,
299 rho: ArrayView1<'_, f64>,
300 ) -> Option<Array1<f64>> {
301 self.hessian_diag(target, rho)
302 }
303
304 /// Matrix-vector product against the **PSD majorizer** `B(target; ρ) v`
305 /// (see [`Self::psd_majorizer_diag`]). For convex penalties this is the
306 /// exact Hessian-vector product, so the default delegates to
307 /// [`Self::hvp`]; nonconvex penalties override to return their PSD
308 /// surrogate instead of the indefinite true Hessian.
309 fn psd_majorizer_hvp(
310 &self,
311 target: ArrayView1<'_, f64>,
312 rho: ArrayView1<'_, f64>,
313 v: ArrayView1<'_, f64>,
314 ) -> Array1<f64> {
315 if let Some(diag) = self.psd_majorizer_diag(target, rho) {
316 assert_eq!(diag.len(), v.len(), "psd_majorizer_hvp dimension mismatch");
317 let mut out = Array1::<f64>::zeros(v.len());
318 for i in 0..v.len() {
319 out[i] = diag[i] * v[i];
320 }
321 return out;
322 }
323 self.hvp(target, rho, v)
324 }
325
326 /// Gradient of the penalty value w.r.t. each owned ρ-axis. Length equals
327 /// [`Self::rho_count`].
328 fn grad_rho(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64>;
329
330 /// Number of REML-selectable hyperparameter axes this penalty contributes
331 /// to the outer ρ vector.
332 fn rho_count(&self) -> usize;
333
334 /// Human-readable identifier for diagnostics / logging.
335 fn name(&self) -> &str;
336
337 /// Update any attached scalar weight schedule at the given REML outer
338 /// iteration. Penalties without schedules keep their stored weight.
339 fn apply_schedule(&mut self, iter: usize) {
340 // REML outer loops are bounded well below 1,000,000; a value beyond
341 // that cap signals counter corruption rather than a legitimate
342 // iteration count, so refuse to silently accept it.
343 assert!(
344 iter < 1_000_000,
345 "apply_schedule received implausible outer iteration {iter}",
346 );
347 }
348}
349
350pub(crate) fn advance_scalar_weight(
351 weight: &mut f64,
352 schedule: &mut Option<ScalarWeightSchedule>,
353 iter: usize,
354) {
355 if let Some(schedule) = schedule.as_mut() {
356 *weight = schedule.current_weight(iter);
357 schedule.iter_count = iter + 1;
358 }
359}
360
361/// Emit the standard scalar-weight-schedule builder for a penalty struct whose
362/// scalar weight lives in `$field` and whose schedule lives in
363/// `weight_schedule: Option<ScalarWeightSchedule>`. The builder seeds the
364/// current weight from the schedule and stores the schedule. Invoke inside the
365/// struct's inherent `impl … {}` block.
366macro_rules! impl_with_weight_schedule {
367 ($field:ident) => {
368 /// Attach a scalar weight schedule, seeding the current weight from
369 /// the schedule's stored iteration counter.
370 #[must_use]
371 pub fn with_weight_schedule(mut self, schedule: ScalarWeightSchedule) -> Self {
372 self.$field = schedule.current_weight(schedule.iter_count);
373 self.weight_schedule = Some(schedule);
374 self
375 }
376 };
377}
378
379/// Emit the standard [`AnalyticPenalty::apply_schedule`] override for a penalty
380/// whose scalar weight lives in `$field`. Invoke inside the `impl
381/// AnalyticPenalty for …` block.
382macro_rules! impl_scalar_apply_schedule {
383 ($field:ident) => {
384 fn apply_schedule(&mut self, iter: usize) {
385 advance_scalar_weight(&mut self.$field, &mut self.weight_schedule, iter);
386 }
387 };
388}
389
390/// Emit the standard learnable-scalar-weight [`AnalyticPenalty::grad_rho`] for a
391/// penalty whose single owned ρ-axis is the (optionally learnable) log-weight at
392/// `self.rho_index`, gated by `self.learnable_weight`. Invoke inside the `impl
393/// AnalyticPenalty for …` block.
394macro_rules! impl_learnable_weight_grad_rho {
395 () => {
396 fn grad_rho(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
397 if !self.learnable_weight {
398 return Array1::<f64>::zeros(0);
399 }
400 let mut out = Array1::<f64>::zeros(1);
401 out[self.rho_index] = self.value(target, rho);
402 out
403 }
404 };
405}
406
407/// Emit the standard learnable-scalar-weight [`AnalyticPenalty::rho_count`]:
408/// one ρ-axis when the weight is learnable, none otherwise. Invoke inside the
409/// `impl AnalyticPenalty for …` block.
410macro_rules! impl_learnable_weight_rho_count {
411 () => {
412 fn rho_count(&self) -> usize {
413 usize::from(self.learnable_weight)
414 }
415 };
416}