Skip to main content

gam_solve/estimate/
evaluation.rs

1use super::*;
2
3pub(crate) fn sas_log_deltaridgeweight() -> f64 {
4    // Weak fixed stabilization for the SAS tail parameter to avoid
5    // boundary/flat-region pathologies in outer optimization.
6    1e-4
7}
8
9#[inline]
10pub(crate) fn sas_log_delta_edge_barrierweight() -> f64 {
11    // Keep SAS raw log-delta away from tanh-saturation edges where
12    // link sensitivities collapse and outer gradients become uninformative.
13    1e-2
14}
15
16#[inline]
17pub(crate) fn sas_log_delta_bound() -> f64 {
18    crate::mixture_link::SAS_LOG_DELTA_BOUND
19}
20
21#[inline]
22pub(crate) fn sas_log_delta_edge_barriercostgrad(raw_log_delta: f64) -> (f64, f64) {
23    let w = sas_log_delta_edge_barrierweight();
24    if w <= 0.0 || !raw_log_delta.is_finite() {
25        return (0.0, 0.0);
26    }
27    let b = sas_log_delta_bound().max(f64::EPSILON);
28    let u = raw_log_delta / b;
29    let t = u.tanh();
30    // `−w·ln(1 − t²) = 2w·ln cosh u`, in the form that never forms `1 − t²`
31    // (see `ln_cosh`); the former `(1 − t²).max(1e-12)` capped the barrier at
32    // `27.6·w` once `tanh` had rounded to `±1` (#2469).
33    let cost = 2.0 * w * ln_cosh(u);
34    // d/draw[-w log(1-t^2)] = (2w/B) * t.
35    let grad = (2.0 * w / b) * t;
36    (cost, grad)
37}
38
39/// `ln cosh u` without forming `cosh u` (overflows past `|u| ≈ 710`) or
40/// `1 − tanh²u` (cancels to exactly zero past `|u| ≈ 19`):
41/// `ln cosh u = |u| + ln(1 + e^{−2|u|}) − ln 2`, exact for every finite `u`.
42#[inline]
43fn ln_cosh(u: f64) -> f64 {
44    let a = u.abs();
45    a + (-2.0 * a).exp().ln_1p() - std::f64::consts::LN_2
46}
47
48#[inline]
49pub(crate) fn sas_epsilon_bound() -> f64 {
50    // Fixed smooth bound on raw SAS epsilon during outer optimization.
51    8.0
52}
53
54#[inline]
55pub(crate) fn sas_effective_epsilon(raw_epsilon: f64) -> (f64, f64) {
56    let bound = sas_epsilon_bound().max(f64::EPSILON);
57    let t = (raw_epsilon / bound).tanh();
58    let epsilon = bound * t;
59    let d_epsilon_d_raw = 1.0 - t * t;
60    (epsilon, d_epsilon_d_raw)
61}
62
63#[inline]
64pub(crate) fn sas_effective_epsilon_second(raw_epsilon: f64) -> (f64, f64, f64) {
65    let bound = sas_epsilon_bound().max(f64::EPSILON);
66    let t = (raw_epsilon / bound).tanh();
67    let first = 1.0 - t * t;
68    let second = -2.0 * t * first / bound;
69    (bound * t, first, second)
70}
71
72#[inline]
73pub(crate) fn sas_log_delta_edge_barriercostgradhess(raw_log_delta: f64) -> (f64, f64, f64) {
74    let w = sas_log_delta_edge_barrierweight();
75    if w <= 0.0 || !raw_log_delta.is_finite() {
76        return (0.0, 0.0, 0.0);
77    }
78    let b = sas_log_delta_bound().max(f64::EPSILON);
79    let u = raw_log_delta / b;
80    let t = u.tanh();
81    let ln_cosh_u = ln_cosh(u);
82    let cost = 2.0 * w * ln_cosh_u;
83    let grad = (2.0 * w / b) * t;
84    // `1 − t² = sech²u = e^{−2·ln cosh u}`: underflows to an honest zero far
85    // past the bound instead of being floored.
86    let one_minus_t2 = (-2.0 * ln_cosh_u).exp();
87    let hess = (2.0 * w / (b * b)) * one_minus_t2;
88    (cost, grad, hess)
89}
90
91pub(crate) fn materialize_link_outer_hessian(
92    hessian: gam_problem::HessianValue,
93    theta_dim: usize,
94) -> Result<Array2<f64>, EstimationError> {
95    match hessian.materialize_dense() {
96        Ok(Some(h)) => {
97            if h.nrows() != theta_dim || h.ncols() != theta_dim {
98                crate::bail_invalid_estim!(
99                    "unified evaluator Hessian shape {}x{} != theta_dim {}",
100                    h.nrows(),
101                    h.ncols(),
102                    theta_dim
103                );
104            }
105            Ok(h)
106        }
107        Ok(None) => Err(EstimationError::InvalidInput(
108            "unified evaluator returned no analytic Hessian in ValueGradientHessian mode"
109                .to_string(),
110        )),
111        Err(err) => Err(EstimationError::InvalidInput(format!(
112            "failed to materialize analytic link Hessian: {err}"
113        ))),
114    }
115}
116
117/// Evaluate the analytic gradient of the external REML objective.
118pub fn evaluate_externalgradient<X>(
119    y: ArrayView1<'_, f64>,
120    w: ArrayView1<'_, f64>,
121    x: X,
122    offset: ArrayView1<'_, f64>,
123    s_list: &[BlockwisePenalty],
124    opts: &ExternalOptimOptions,
125    rho: &Array1<f64>,
126) -> Result<Array1<f64>, EstimationError>
127where
128    X: Into<DesignMatrix>,
129{
130    let specs: Vec<PenaltySpec> = s_list.iter().map(PenaltySpec::from_blockwise_ref).collect();
131    let x = x.into();
132    if let Some(message) = row_mismatch_message(y.len(), w.len(), x.nrows(), offset.len()) {
133        crate::bail_invalid_estim!("{}", message);
134    }
135
136    let p = x.ncols();
137    validate_penalty_specs(&specs, p, "evaluate_externalgradient")?;
138    let (canonical, active_nullspace_dims) = gam_terms::construction::canonicalize_penalty_specs(
139        &specs,
140        &opts.nullspace_dims,
141        p,
142        "evaluate_externalgradient",
143    )?;
144    if rho.len() != active_nullspace_dims.len() {
145        crate::bail_invalid_estim!(
146            "rho dimension mismatch: rho_dim={}, active_penalties={}",
147            rho.len(),
148            active_nullspace_dims.len()
149        );
150    }
151
152    let (cfg, _) = resolved_external_config(opts)?;
153
154    let y_o = y.to_owned();
155    let w_o = w.to_owned();
156    let offset_o = offset.to_owned();
157    let conditioning = ParametricColumnConditioning::infer_from_penalty_specs(&x, &specs);
158    let x_fit = conditioning.apply_to_design(&x);
159    let fit_linear_constraints =
160        conditioning.transform_linear_constraints_to_internal(opts.linear_constraints.clone());
161
162    let mut reml_state = RemlState::newwith_offset(
163        y_o.view(),
164        x_fit,
165        w_o.view(),
166        offset_o.view(),
167        canonical,
168        p,
169        &cfg,
170        Some(active_nullspace_dims),
171        None,
172        fit_linear_constraints,
173    )?;
174    reml_state.set_rho_prior(opts.rho_prior.clone());
175    reml_state.set_link_states(
176        cfg.link_kind.mixture_state().cloned(),
177        cfg.link_kind.sas_state().copied(),
178    );
179
180    reml_state.compute_gradient(rho)
181}
182
183/// Evaluate the external cost and report the stabilization ridge used.
184/// This is a diagnostic helper for tests that need to detect ridge jitter.
185pub fn evaluate_externalcost_andridge<X>(
186    y: ArrayView1<'_, f64>,
187    w: ArrayView1<'_, f64>,
188    x: X,
189    offset: ArrayView1<'_, f64>,
190    s_list: &[BlockwisePenalty],
191    opts: &ExternalOptimOptions,
192    rho: &Array1<f64>,
193) -> Result<(f64, f64), EstimationError>
194where
195    X: Into<DesignMatrix>,
196{
197    let specs: Vec<PenaltySpec> = s_list.iter().map(PenaltySpec::from_blockwise_ref).collect();
198    let x = x.into();
199    if let Some(message) = row_mismatch_message(y.len(), w.len(), x.nrows(), offset.len()) {
200        crate::bail_invalid_estim!("{}", message);
201    }
202
203    let p = x.ncols();
204    validate_penalty_specs(&specs, p, "evaluate_externalcost_andridge")?;
205    let (canonical, active_nullspace_dims) = gam_terms::construction::canonicalize_penalty_specs(
206        &specs,
207        &opts.nullspace_dims,
208        p,
209        "evaluate_externalcost_andridge",
210    )?;
211    if rho.len() != active_nullspace_dims.len() {
212        crate::bail_invalid_estim!(
213            "rho dimension mismatch: rho_dim={}, active_penalties={}",
214            rho.len(),
215            active_nullspace_dims.len()
216        );
217    }
218
219    let (cfg, _) = resolved_external_config(opts)?;
220
221    let y_o = y.to_owned();
222    let w_o = w.to_owned();
223    let offset_o = offset.to_owned();
224    let conditioning = ParametricColumnConditioning::infer_from_penalty_specs(&x, &specs);
225    let x_fit = conditioning.apply_to_design(&x);
226    let fit_linear_constraints =
227        conditioning.transform_linear_constraints_to_internal(opts.linear_constraints.clone());
228
229    let mut reml_state = RemlState::newwith_offset(
230        y_o.view(),
231        x_fit,
232        w_o.view(),
233        offset_o.view(),
234        canonical,
235        p,
236        &cfg,
237        Some(active_nullspace_dims),
238        None,
239        fit_linear_constraints,
240    )?;
241    reml_state.set_rho_prior(opts.rho_prior.clone());
242    reml_state.set_link_states(
243        cfg.link_kind.mixture_state().cloned(),
244        cfg.link_kind.sas_state().copied(),
245    );
246
247    let cost = reml_state.compute_cost(rho)?;
248    let ridge = reml_state.last_ridge_used().unwrap_or(0.0);
249    Ok((cost, ridge))
250}